diff --git a/V52_FRONTEND_TASK.md b/V52_FRONTEND_TASK.md new file mode 100644 index 0000000..64254b9 --- /dev/null +++ b/V52_FRONTEND_TASK.md @@ -0,0 +1,63 @@ +# V5.2 Frontend Differentiation Task + +## Problem +V5.1 and V5.2 currently share the same pages. Boss wants clear visual separation. + +## Requirements + +### 1. Signals Page (/signals) - Side-by-side comparison +Currently shows one set of scores per coin. Change to show BOTH V5.1 and V5.2 scores side by side. + +For the "Latest Signal" cards at the top, each coin should show: +``` +BTC SHORT V5.1: 80分 | V5.2: 85分 5m前 +``` + +The V5.2 score should show FR and Liquidation subscores that V5.1 doesn't have. + +To get V5.2 scores, add a new API endpoint `/api/signals/latest-v52` that returns the V5.2 evaluation alongside V5.1. Or modify the existing `/api/signals/latest` to include both strategy scores. + +### 2. Paper Trading Page (/paper) - Strategy Tabs at TOP +Add prominent tabs at the very top of the page: + +``` +[全部] [V5.1 模拟盘] [V5.2 模拟盘] +``` + +When selecting a strategy tab: +- Current positions: only show positions for that strategy +- Trade history: only show trades for that strategy +- Stats: only show stats for that strategy +- Equity curve: only show curve for that strategy +- The "全部" tab shows everything combined (current behavior) + +### 3. Visual Differentiation +- V5.1 trades/positions: use a subtle blue-gray badge +- V5.2 trades/positions: use a green badge with ✨ icon +- V5.2 positions should show extra info: FR score and Liquidation score prominently + +### 4. Backend API Changes Needed + +#### Modify `/api/signals/latest` endpoint in main.py +Return both V5.1 and V5.2 evaluations. The signal_engine already evaluates both strategies per cycle and saves the primary one. We need to also save V5.2 evaluations or compute them on-the-fly. + +Simplest approach: Add a field to the signal_indicators table or return strategy-specific data. + +Actually, the simplest approach for NOW: In the latest signal cards, just show the score that's already there (from primary strategy), and add a note showing which strategy it's from. The real differentiation happens in paper trades where the strategy column exists. + +#### `/api/paper/trades` already supports `?strategy=` filter (Codex added this) +#### `/api/paper/stats-by-strategy` already exists + +### 5. Key Files to Modify +- `frontend/app/paper/page.tsx` - Add strategy tabs at top, filter everything by selected strategy +- `frontend/app/signals/page.tsx` - Show V5.2 specific info (FR/Liq scores) in latest signal cards +- Backend: may need minor API tweaks + +### 6. Important +- Don't break existing functionality +- The strategy tabs should be very prominent (not small buttons buried in a section) +- Use consistent styling: slate-800 bg for active tab, slate-100 for inactive +- Test with `npm run build` + +When completely finished, run: +openclaw system event --text "Done: V5.2 frontend differentiation - strategy tabs, visual badges, FR/Liq display" --mode now diff --git a/V52_TASK.md b/V52_TASK.md new file mode 100644 index 0000000..0e79d4a --- /dev/null +++ b/V52_TASK.md @@ -0,0 +1,224 @@ +# V5.2 Development Task + +## Context +You are working on the `dev` branch of the ArbitrageEngine project. +This is a quantitative trading signal system with: +- Backend: Python (FastAPI + PostgreSQL) +- Frontend: Next.js + shadcn/ui + Tailwind + +## Database Connection +- Host: 34.85.117.248 (Cloud SQL) +- Port: 5432, DB: arb_engine, User: arb, Password: arb_engine_2026 + +## What to Build (V5.2) + +### 1. Strategy Configuration Framework +Create `backend/strategies/` directory with JSON configs: + +**backend/strategies/v51_baseline.json:** +```json +{ + "name": "v51_baseline", + "version": "5.1", + "threshold": 75, + "weights": { + "direction": 45, + "crowding": 20, + "environment": 15, + "confirmation": 15, + "auxiliary": 5 + }, + "accel_bonus": 5, + "tp_sl": { + "sl_multiplier": 2.0, + "tp1_multiplier": 1.5, + "tp2_multiplier": 3.0 + }, + "signals": ["cvd", "p99", "accel", "ls_ratio", "oi", "coinbase_premium"] +} +``` + +**backend/strategies/v52_8signals.json:** +```json +{ + "name": "v52_8signals", + "version": "5.2", + "threshold": 75, + "weights": { + "direction": 40, + "crowding": 25, + "environment": 15, + "confirmation": 20, + "auxiliary": 5 + }, + "accel_bonus": 5, + "tp_sl": { + "sl_multiplier": 2.0, + "tp1_multiplier": 1.5, + "tp2_multiplier": 3.0 + }, + "signals": ["cvd", "p99", "accel", "ls_ratio", "oi", "coinbase_premium", "funding_rate", "liquidation"] +} +``` + +### 2. Signal Engine Changes (signal_engine.py) + +#### 2a. Add FR scoring to evaluate_signal() +After the crowding section, add funding_rate scoring: + +```python +# Funding Rate scoring (拥挤层加分) +# Read from market_indicators table +funding_rate = to_float(self.market_indicators.get("funding_rate")) +fr_score = 0 +if funding_rate is not None: + fr_abs = abs(funding_rate) + if fr_abs >= 0.001: # extreme ±0.1% + # Extreme: penalize if going WITH the crowd + if (direction == "LONG" and funding_rate > 0.001) or \ + (direction == "SHORT" and funding_rate < -0.001): + fr_score = -5 + else: + fr_score = 5 + elif fr_abs >= 0.0003: # moderate ±0.03% + # Moderate: reward going AGAINST the crowd + if (direction == "LONG" and funding_rate < -0.0003) or \ + (direction == "SHORT" and funding_rate > 0.0003): + fr_score = 5 + else: + fr_score = 0 +``` + +#### 2b. Add liquidation scoring +```python +# Liquidation scoring (确认层加分) +liq_score = 0 +liq_data = self.fetch_recent_liquidations() # new method +if liq_data: + liq_long_usd = liq_data.get("long_usd", 0) + liq_short_usd = liq_data.get("short_usd", 0) + # Thresholds by symbol + thresholds = {"BTCUSDT": 500000, "ETHUSDT": 200000, "XRPUSDT": 100000, "SOLUSDT": 100000} + threshold = thresholds.get(self.symbol, 100000) + total = liq_long_usd + liq_short_usd + if total >= threshold: + if liq_short_usd > 0 and liq_long_usd > 0: + ratio = liq_short_usd / liq_long_usd + elif liq_short_usd > 0: + ratio = float('inf') + else: + ratio = 0 + if ratio >= 2.0 and direction == "LONG": + liq_score = 5 # shorts getting liquidated, price going up + elif ratio <= 0.5 and direction == "SHORT": + liq_score = 5 # longs getting liquidated, price going down +``` + +#### 2c. Add fetch_recent_liquidations method to SymbolState +```python +def fetch_recent_liquidations(self, window_ms=300000): + """Fetch last 5min liquidation totals from liquidations table""" + now_ms = int(time.time() * 1000) + cutoff = now_ms - window_ms + with get_sync_conn() as conn: + with conn.cursor() as cur: + cur.execute(""" + SELECT + COALESCE(SUM(CASE WHEN side='SELL' THEN usd_value ELSE 0 END), 0) as long_liq, + COALESCE(SUM(CASE WHEN side='BUY' THEN usd_value ELSE 0 END), 0) as short_liq + FROM liquidations + WHERE symbol=%s AND trade_time >= %s + """, (self.symbol, cutoff)) + row = cur.fetchone() + if row: + return {"long_usd": row[0], "short_usd": row[1]} + return None +``` + +#### 2d. Add funding_rate to fetch_market_indicators +Add "funding_rate" to the indicator types: +```python +for ind_type in ["long_short_ratio", "top_trader_position", "open_interest_hist", "coinbase_premium", "funding_rate"]: +``` +And the extraction: +```python +elif ind_type == "funding_rate": + indicators[ind_type] = float(val.get("lastFundingRate", 0)) +``` + +#### 2e. Update total_score calculation +Currently: +```python +total_score = direction_score + accel_bonus + crowding_score + environment_score + confirmation_score + aux_score +``` +Change to: +```python +total_score = direction_score + accel_bonus + crowding_score + fr_score + environment_score + confirmation_score + liq_score + aux_score +``` + +#### 2f. Update factors dict +Add fr_score and liq_score to the factors: +```python +result["factors"] = { + ...existing factors..., + "funding_rate": {"score": fr_score, "value": funding_rate}, + "liquidation": {"score": liq_score, "long_usd": liq_data.get("long_usd", 0) if liq_data else 0, "short_usd": liq_data.get("short_usd", 0) if liq_data else 0}, +} +``` + +#### 2g. Change threshold from 60 to 75 +In evaluate_signal, change: +```python +# OLD +elif total_score >= 60 and not no_direction and not in_cooldown: + result["signal"] = direction + result["tier"] = "light" +# NEW: remove the 60 tier entirely, minimum is 75 +``` + +Also update reverse signal threshold from 60 to 75: +In main() loop: +```python +# OLD +if existing_dir and eval_dir and existing_dir != eval_dir and result["score"] >= 60: +# NEW +if existing_dir and eval_dir and existing_dir != eval_dir and result["score"] >= 75: +``` + +### 3. Strategy field in paper_trades +Add SQL migration at top of init_schema() or in a migration: +```sql +ALTER TABLE paper_trades ADD COLUMN IF NOT EXISTS strategy VARCHAR(32) DEFAULT 'v51_baseline'; +``` + +### 4. AB Test: Both strategies evaluate each cycle +In the main loop, evaluate signal twice (once per strategy config) and potentially open trades for both. Each trade records which strategy triggered it. + +### 5. Frontend: Update paper/page.tsx +- Show strategy column in trade history table +- Show FR and liquidation scores in signal details +- Add strategy filter/tab (v51 vs v52) + +### 6. API: Add strategy stats endpoint +In main.py, add `/api/paper/stats-by-strategy` that groups stats by strategy field. + +## Important Notes +- Keep ALL existing functionality working +- Don't break the existing V5.1 scoring - it should still work as strategy "v51_baseline" +- The FR data is already in market_indicators table (collected every 5min) +- The liquidation data is already in liquidations table +- Test with: `cd frontend && npm run build` to verify no frontend errors +- Test backend: `python3 -c "from signal_engine import *; print('OK')"` to verify imports +- Port for dev testing: API=8100, Frontend=3300 +- Total score CAN exceed 100 (that's by design) + +## Files to modify: +1. `backend/signal_engine.py` - core scoring changes +2. `backend/main.py` - new API endpoints +3. `backend/db.py` - add strategy column migration +4. `frontend/app/paper/page.tsx` - UI updates +5. NEW: `backend/strategies/v51_baseline.json` +6. NEW: `backend/strategies/v52_8signals.json` + +When completely finished, run this command to notify me: +openclaw system event --text "Done: V5.2 core implementation complete - FR+liquidation scoring, threshold 75, strategy configs, AB test framework" --mode now diff --git a/backend/db.py b/backend/db.py index 5a0df3a..041155b 100644 --- a/backend/db.py +++ b/backend/db.py @@ -355,5 +355,9 @@ def init_schema(): conn.rollback() # 忽略已存在错误 continue + cur.execute( + "ALTER TABLE paper_trades " + "ADD COLUMN IF NOT EXISTS strategy VARCHAR(32) DEFAULT 'v51_baseline'" + ) conn.commit() ensure_partitions() diff --git a/backend/main.py b/backend/main.py index 7baa144..c53513d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -2,7 +2,7 @@ from fastapi import FastAPI, HTTPException, Depends, Request from fastapi.middleware.cors import CORSMiddleware import httpx from datetime import datetime, timedelta -import asyncio, time, os +import asyncio, time, os, json from auth import router as auth_router, get_current_user, ensure_tables as ensure_auth_tables from db import ( @@ -436,13 +436,109 @@ async def get_signal_latest(user: dict = Depends(get_current_user)): return result +def _primary_signal_strategy() -> str: + strategy_dir = os.path.join(os.path.dirname(__file__), "strategies") + try: + names = [] + for fn in os.listdir(strategy_dir): + if not fn.endswith(".json"): + continue + with open(os.path.join(strategy_dir, fn), "r", encoding="utf-8") as f: + cfg = json.load(f) + if cfg.get("name"): + names.append(cfg["name"]) + if "v52_8signals" in names: + return "v52_8signals" + if "v51_baseline" in names: + return "v51_baseline" + except Exception: + pass + return "v51_baseline" + + +def _normalize_factors(raw): + if not raw: + return {} + if isinstance(raw, str): + try: + return json.loads(raw) + except Exception: + return {} + if isinstance(raw, dict): + return raw + return {} + + +@app.get("/api/signals/latest-v52") +async def get_signal_latest_v52(user: dict = Depends(get_current_user)): + """返回V5.1/V5.2并排展示所需的最新信号信息。""" + primary_strategy = _primary_signal_strategy() + result = {} + for sym in SYMBOLS: + base_row = await async_fetchrow( + "SELECT ts, score, signal FROM signal_indicators WHERE symbol = $1 ORDER BY ts DESC LIMIT 1", + sym, + ) + strategy_rows = await async_fetch( + "SELECT strategy, score, direction, entry_ts, score_factors " + "FROM paper_trades WHERE symbol = $1 AND strategy IN ('v51_baseline','v52_8signals') " + "ORDER BY entry_ts DESC", + sym, + ) + latest_by_strategy: dict[str, dict] = {} + for row in strategy_rows: + st = (row.get("strategy") or "v51_baseline") + if st not in latest_by_strategy: + latest_by_strategy[st] = row + if "v51_baseline" in latest_by_strategy and "v52_8signals" in latest_by_strategy: + break + + def build_strategy_payload(strategy_name: str): + trade_row = latest_by_strategy.get(strategy_name) + if trade_row: + payload = { + "score": trade_row.get("score"), + "signal": trade_row.get("direction"), + "ts": trade_row.get("entry_ts"), + "source": "paper_trade", + } + elif base_row and primary_strategy == strategy_name: + payload = { + "score": base_row.get("score"), + "signal": base_row.get("signal"), + "ts": base_row.get("ts"), + "source": "signal_indicators", + } + else: + payload = { + "score": None, + "signal": None, + "ts": None, + "source": "unavailable", + } + + factors = _normalize_factors(trade_row.get("score_factors") if trade_row else None) + payload["funding_rate_score"] = factors.get("funding_rate", {}).get("score") + payload["liquidation_score"] = factors.get("liquidation", {}).get("score") + return payload + + result[sym.replace("USDT", "")] = { + "primary_strategy": primary_strategy, + "latest_signal": base_row.get("signal") if base_row else None, + "latest_ts": base_row.get("ts") if base_row else None, + "v51": build_strategy_payload("v51_baseline"), + "v52": build_strategy_payload("v52_8signals"), + } + return result + + @app.get("/api/signals/market-indicators") async def get_market_indicators(user: dict = Depends(get_current_user)): """返回最新的market_indicators数据(V5.1新增4个数据源)""" result = {} for sym in SYMBOLS: indicators = {} - for ind_type in ["long_short_ratio", "top_trader_position", "open_interest_hist", "coinbase_premium"]: + for ind_type in ["long_short_ratio", "top_trader_position", "open_interest_hist", "coinbase_premium", "funding_rate"]: row = await async_fetchrow( "SELECT value, timestamp_ms FROM market_indicators WHERE symbol = $1 AND indicator_type = $2 ORDER BY timestamp_ms DESC LIMIT 1", sym, @@ -501,12 +597,23 @@ async def get_signal_trades( # 模拟盘配置状态(与signal_engine共享的运行时状态) paper_config = { "enabled": False, + "enabled_strategies": [], # 分策略开关: ["v51_baseline", "v52_8signals"] "initial_balance": 10000, "risk_per_trade": 0.02, "max_positions": 4, "tier_multiplier": {"light": 0.5, "standard": 1.0, "heavy": 1.5}, } +# 启动时加载已有配置 +_config_path = os.path.join(os.path.dirname(__file__), "paper_config.json") +if os.path.exists(_config_path): + try: + with open(_config_path, "r") as _f: + _saved = json.load(_f) + paper_config.update(_saved) + except Exception: + pass + @app.get("/api/paper/config") async def paper_get_config(user: dict = Depends(get_current_user)): @@ -520,11 +627,10 @@ async def paper_set_config(request: Request, user: dict = Depends(get_current_us if user.get("role") != "admin": raise HTTPException(status_code=403, detail="仅管理员可修改") body = await request.json() - for k in ["enabled", "initial_balance", "risk_per_trade", "max_positions"]: + for k in ["enabled", "enabled_strategies", "initial_balance", "risk_per_trade", "max_positions"]: if k in body: paper_config[k] = body[k] # 写入配置文件让signal_engine也能读到 - import json config_path = os.path.join(os.path.dirname(__file__), "paper_config.json") with open(config_path, "w") as f: json.dump(paper_config, f, indent=2) @@ -532,15 +638,33 @@ async def paper_set_config(request: Request, user: dict = Depends(get_current_us @app.get("/api/paper/summary") -async def paper_summary(user: dict = Depends(get_current_user)): +async def paper_summary( + strategy: str = "all", + user: dict = Depends(get_current_user), +): """模拟盘总览""" - closed = await async_fetch( - "SELECT pnl_r, direction FROM paper_trades WHERE status NOT IN ('active','tp1_hit')" - ) - active = await async_fetch( - "SELECT id FROM paper_trades WHERE status IN ('active','tp1_hit')" - ) - first = await async_fetchrow("SELECT MIN(created_at) as start FROM paper_trades") + if strategy == "all": + closed = await async_fetch( + "SELECT pnl_r, direction FROM paper_trades WHERE status NOT IN ('active','tp1_hit')" + ) + active = await async_fetch( + "SELECT id FROM paper_trades WHERE status IN ('active','tp1_hit')" + ) + first = await async_fetchrow("SELECT MIN(created_at) as start FROM paper_trades") + else: + closed = await async_fetch( + "SELECT pnl_r, direction FROM paper_trades " + "WHERE status NOT IN ('active','tp1_hit') AND strategy = $1", + strategy, + ) + active = await async_fetch( + "SELECT id FROM paper_trades WHERE status IN ('active','tp1_hit') AND strategy = $1", + strategy, + ) + first = await async_fetchrow( + "SELECT MIN(created_at) as start FROM paper_trades WHERE strategy = $1", + strategy, + ) total = len(closed) wins = len([r for r in closed if r["pnl_r"] > 0]) @@ -565,13 +689,24 @@ async def paper_summary(user: dict = Depends(get_current_user)): @app.get("/api/paper/positions") -async def paper_positions(user: dict = Depends(get_current_user)): +async def paper_positions( + strategy: str = "all", + user: dict = Depends(get_current_user), +): """当前活跃持仓(含实时价格和浮动盈亏)""" - rows = await async_fetch( - "SELECT id, symbol, direction, score, tier, entry_price, entry_ts, " - "tp1_price, tp2_price, sl_price, tp1_hit, status, atr_at_entry " - "FROM paper_trades WHERE status IN ('active','tp1_hit') ORDER BY entry_ts DESC" - ) + if strategy == "all": + rows = await async_fetch( + "SELECT id, symbol, direction, score, tier, strategy, entry_price, entry_ts, " + "tp1_price, tp2_price, sl_price, tp1_hit, status, atr_at_entry, score_factors " + "FROM paper_trades WHERE status IN ('active','tp1_hit') ORDER BY entry_ts DESC" + ) + else: + rows = await async_fetch( + "SELECT id, symbol, direction, score, tier, strategy, entry_price, entry_ts, " + "tp1_price, tp2_price, sl_price, tp1_hit, status, atr_at_entry, score_factors " + "FROM paper_trades WHERE status IN ('active','tp1_hit') AND strategy = $1 ORDER BY entry_ts DESC", + strategy, + ) # 从币安API获取实时价格 prices = {} symbols_needed = list(set(r["symbol"] for r in rows)) @@ -624,6 +759,7 @@ async def paper_positions(user: dict = Depends(get_current_user)): async def paper_trades( symbol: str = "all", result: str = "all", + strategy: str = "all", limit: int = 100, user: dict = Depends(get_current_user), ): @@ -642,11 +778,16 @@ async def paper_trades( elif result == "loss": conditions.append("pnl_r <= 0") + if strategy != "all": + conditions.append(f"strategy = ${idx}") + params.append(strategy) + idx += 1 + where = " AND ".join(conditions) params.append(limit) rows = await async_fetch( - f"SELECT id, symbol, direction, score, tier, entry_price, exit_price, " - f"entry_ts, exit_ts, pnl_r, status, tp1_hit " + f"SELECT id, symbol, direction, score, tier, strategy, entry_price, exit_price, " + f"entry_ts, exit_ts, pnl_r, status, tp1_hit, score_factors " f"FROM paper_trades WHERE {where} ORDER BY exit_ts DESC LIMIT ${idx}", *params ) @@ -654,11 +795,22 @@ async def paper_trades( @app.get("/api/paper/equity-curve") -async def paper_equity_curve(user: dict = Depends(get_current_user)): +async def paper_equity_curve( + strategy: str = "all", + user: dict = Depends(get_current_user), +): """权益曲线""" - rows = await async_fetch( - "SELECT exit_ts, pnl_r FROM paper_trades WHERE status NOT IN ('active','tp1_hit') ORDER BY exit_ts ASC" - ) + if strategy == "all": + rows = await async_fetch( + "SELECT exit_ts, pnl_r FROM paper_trades " + "WHERE status NOT IN ('active','tp1_hit') ORDER BY exit_ts ASC" + ) + else: + rows = await async_fetch( + "SELECT exit_ts, pnl_r FROM paper_trades " + "WHERE status NOT IN ('active','tp1_hit') AND strategy = $1 ORDER BY exit_ts ASC", + strategy, + ) cumulative = 0.0 curve = [] for r in rows: @@ -668,12 +820,22 @@ async def paper_equity_curve(user: dict = Depends(get_current_user)): @app.get("/api/paper/stats") -async def paper_stats(user: dict = Depends(get_current_user)): +async def paper_stats( + strategy: str = "all", + user: dict = Depends(get_current_user), +): """详细统计""" - rows = await async_fetch( - "SELECT symbol, direction, pnl_r, tier, entry_ts, exit_ts " - "FROM paper_trades WHERE status NOT IN ('active','tp1_hit')" - ) + if strategy == "all": + rows = await async_fetch( + "SELECT symbol, direction, pnl_r, tier, entry_ts, exit_ts " + "FROM paper_trades WHERE status NOT IN ('active','tp1_hit')" + ) + else: + rows = await async_fetch( + "SELECT symbol, direction, pnl_r, tier, entry_ts, exit_ts " + "FROM paper_trades WHERE status NOT IN ('active','tp1_hit') AND strategy = $1", + strategy, + ) if not rows: return {"error": "暂无数据"} @@ -788,6 +950,62 @@ async def paper_stats(user: dict = Depends(get_current_user)): } +@app.get("/api/paper/stats-by-strategy") +async def paper_stats_by_strategy(user: dict = Depends(get_current_user)): + """按策略聚合模拟盘表现""" + rows = await async_fetch( + "SELECT strategy, pnl_r FROM paper_trades WHERE status NOT IN ('active','tp1_hit')" + ) + active_rows = await async_fetch( + "SELECT strategy, COUNT(*) AS active_count FROM paper_trades " + "WHERE status IN ('active','tp1_hit') GROUP BY strategy" + ) + if not rows and not active_rows: + return {"data": []} + + active_map = {r["strategy"] or "v51_baseline": int(r["active_count"]) for r in active_rows} + by_strategy: dict[str, list[float]] = {} + for row in rows: + strategy = row["strategy"] or "v51_baseline" + by_strategy.setdefault(strategy, []).append(float(row["pnl_r"])) + + stats = [] + for strategy, pnls in by_strategy.items(): + total = len(pnls) + wins = [p for p in pnls if p > 0] + losses = [p for p in pnls if p <= 0] + avg_win = sum(wins) / len(wins) if wins else 0 + avg_loss = abs(sum(losses) / len(losses)) if losses else 0 + stats.append( + { + "strategy": strategy, + "total": total, + "win_rate": round((len(wins) / total) * 100, 1) if total else 0, + "total_pnl": round(sum(pnls), 2), + "avg_win": round(avg_win, 2), + "avg_loss": round(avg_loss, 2), + "active_positions": active_map.get(strategy, 0), + } + ) + + for strategy, active_count in active_map.items(): + if strategy not in by_strategy: + stats.append( + { + "strategy": strategy, + "total": 0, + "win_rate": 0, + "total_pnl": 0, + "avg_win": 0, + "avg_loss": 0, + "active_positions": active_count, + } + ) + + stats.sort(key=lambda x: x["strategy"]) + return {"data": stats} + + # ─── 服务器状态监控 ─────────────────────────────────────────────── import shutil, subprocess, psutil diff --git a/backend/signal_engine.py b/backend/signal_engine.py index cc7dde5..dc7eec2 100644 --- a/backend/signal_engine.py +++ b/backend/signal_engine.py @@ -18,6 +18,7 @@ signal_engine.py — V5 短线交易信号引擎(PostgreSQL版) import logging import os import time +import json from collections import deque from datetime import datetime, timezone from typing import Any, Optional @@ -36,12 +37,41 @@ logger = logging.getLogger("signal-engine") SYMBOLS = ["BTCUSDT", "ETHUSDT", "XRPUSDT", "SOLUSDT"] LOOP_INTERVAL = 15 # 秒(从5改15,CPU降60%,信号质量无影响) +STRATEGY_DIR = os.path.join(os.path.dirname(__file__), "strategies") +DEFAULT_STRATEGY_FILES = ["v51_baseline.json", "v52_8signals.json"] + + +def load_strategy_configs() -> list[dict]: + configs = [] + for filename in DEFAULT_STRATEGY_FILES: + path = os.path.join(STRATEGY_DIR, filename) + try: + with open(path, "r", encoding="utf-8") as f: + cfg = json.load(f) + if isinstance(cfg, dict) and cfg.get("name"): + configs.append(cfg) + except FileNotFoundError: + logger.warning(f"策略配置缺失: {path}") + except Exception as e: + logger.error(f"策略配置加载失败 {path}: {e}") + if not configs: + logger.warning("未加载到策略配置,回退到v51_baseline默认配置") + configs.append( + { + "name": "v51_baseline", + "threshold": 75, + "signals": ["cvd", "p99", "accel", "ls_ratio", "oi", "coinbase_premium"], + "tp_sl": {"sl_multiplier": 2.0, "tp1_multiplier": 1.5, "tp2_multiplier": 3.0}, + } + ) + return configs # ─── 模拟盘配置 ─────────────────────────────────────────────────── -PAPER_TRADING_ENABLED = False # 开关(范总确认后通过API开启) +PAPER_TRADING_ENABLED = False # 总开关(兼容旧逻辑) +PAPER_ENABLED_STRATEGIES = [] # 分策略开关: ["v51_baseline", "v52_8signals"] PAPER_INITIAL_BALANCE = 10000 # 虚拟初始资金 USDT PAPER_RISK_PER_TRADE = 0.02 # 单笔风险 2%(即200U) -PAPER_MAX_POSITIONS = 4 # 最大同时持仓数 +PAPER_MAX_POSITIONS = 4 # 每套策略最大同时持仓数 PAPER_TIER_MULTIPLIER = { # 档位仓位倍数 "light": 0.5, # 轻仓: 1% "standard": 1.0, # 标准: 2% @@ -51,18 +81,29 @@ PAPER_FEE_RATE = 0.0005 # Taker手续费 0.05%(开仓+平仓各一 def load_paper_config(): """从配置文件加载模拟盘开关和参数""" - global PAPER_TRADING_ENABLED, PAPER_INITIAL_BALANCE, PAPER_RISK_PER_TRADE, PAPER_MAX_POSITIONS + global PAPER_TRADING_ENABLED, PAPER_ENABLED_STRATEGIES, PAPER_INITIAL_BALANCE, PAPER_RISK_PER_TRADE, PAPER_MAX_POSITIONS config_path = os.path.join(os.path.dirname(__file__), "paper_config.json") try: with open(config_path, "r") as f: import json as _json2 cfg = _json2.load(f) PAPER_TRADING_ENABLED = cfg.get("enabled", False) + PAPER_ENABLED_STRATEGIES = cfg.get("enabled_strategies", []) PAPER_INITIAL_BALANCE = cfg.get("initial_balance", 10000) PAPER_RISK_PER_TRADE = cfg.get("risk_per_trade", 0.02) PAPER_MAX_POSITIONS = cfg.get("max_positions", 4) except FileNotFoundError: pass + + +def is_strategy_enabled(strategy_name: str) -> bool: + """检查某策略是否启用模拟盘""" + if not PAPER_TRADING_ENABLED: + return False + # 如果enabled_strategies为空,走旧逻辑(全部启用) + if not PAPER_ENABLED_STRATEGIES: + return True + return strategy_name in PAPER_ENABLED_STRATEGIES # ───────────────────────────────────────────────────────────────── # 窗口大小(毫秒) @@ -85,7 +126,7 @@ def fetch_market_indicators(symbol: str) -> dict: with get_sync_conn() as conn: with conn.cursor() as cur: indicators = {} - for ind_type in ["long_short_ratio", "top_trader_position", "open_interest_hist", "coinbase_premium"]: + for ind_type in ["long_short_ratio", "top_trader_position", "open_interest_hist", "coinbase_premium", "funding_rate"]: cur.execute( "SELECT value FROM market_indicators WHERE symbol=%s AND indicator_type=%s ORDER BY timestamp_ms DESC LIMIT 1", (symbol, ind_type), @@ -111,6 +152,8 @@ def fetch_market_indicators(symbol: str) -> dict: indicators[ind_type] = float(val.get("sumOpenInterestValue", 0)) elif ind_type == "coinbase_premium": indicators[ind_type] = float(val.get("premium_pct", 0)) + elif ind_type == "funding_rate": + indicators[ind_type] = float(val.get("lastFundingRate", 0)) return indicators @@ -227,8 +270,8 @@ class SymbolState: self.prev_cvd_fast_slope = 0.0 self.prev_oi_value = 0.0 self.market_indicators = fetch_market_indicators(symbol) - self.last_signal_ts = 0 - self.last_signal_dir = "" + self.last_signal_ts: dict[str, int] = {} + self.last_signal_dir: dict[str, str] = {} self.recent_large_trades: deque = deque() def process_trade(self, agg_id: int, time_ms: int, price: float, qty: float, is_buyer_maker: int): @@ -268,9 +311,10 @@ class SymbolState: self.recent_large_trades.append((t[0], t[1], t[3])) seen.add(t[0]) - def evaluate_signal(self, now_ms: int) -> dict: + def build_evaluation_snapshot(self, now_ms: int) -> dict: cvd_fast = self.win_fast.cvd cvd_mid = self.win_mid.cvd + cvd_day = self.win_day.cvd vwap = self.win_vwap.vwap atr = self.atr_calc.atr atr_pct = self.atr_calc.atr_percentile @@ -282,11 +326,94 @@ class SymbolState: self.prev_cvd_fast = cvd_fast self.prev_cvd_fast_slope = cvd_fast_slope - result = { - "cvd_fast": cvd_fast, "cvd_mid": cvd_mid, "cvd_day": self.win_day.cvd, + oi_value = to_float(self.market_indicators.get("open_interest_hist")) + if oi_value is None or self.prev_oi_value == 0: + oi_change = 0.0 + environment_score = 10 + else: + oi_change = (oi_value - self.prev_oi_value) / self.prev_oi_value if self.prev_oi_value > 0 else 0.0 + if oi_change >= 0.03: + environment_score = 15 + elif oi_change > 0: + environment_score = 10 + else: + environment_score = 5 + if oi_value is not None and oi_value > 0: + self.prev_oi_value = oi_value + + return { + "cvd_fast": cvd_fast, + "cvd_mid": cvd_mid, + "cvd_day": cvd_day, + "vwap": vwap, + "atr": atr, + "atr_pct": atr_pct, + "p95": p95, + "p99": p99, + "price": price, "cvd_fast_slope": cvd_fast_slope, - "atr": atr, "atr_pct": atr_pct, "vwap": vwap, "price": price, - "p95": p95, "p99": p99, "signal": None, "direction": None, "score": 0, + "cvd_fast_accel": cvd_fast_accel, + "oi_change": oi_change, + "environment_score": environment_score, + "oi_value": oi_value, + } + + def fetch_recent_liquidations(self, window_ms: int = 300000): + """Fetch last 5min liquidation totals from liquidations table""" + now_ms = int(time.time() * 1000) + cutoff = now_ms - window_ms + with get_sync_conn() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT + COALESCE(SUM(CASE WHEN side='SELL' THEN usd_value ELSE 0 END), 0) as long_liq, + COALESCE(SUM(CASE WHEN side='BUY' THEN usd_value ELSE 0 END), 0) as short_liq + FROM liquidations + WHERE symbol=%s AND trade_time >= %s + """, + (self.symbol, cutoff), + ) + row = cur.fetchone() + if row: + return {"long_usd": row[0], "short_usd": row[1]} + return None + + def evaluate_signal(self, now_ms: int, strategy_cfg: Optional[dict] = None, snapshot: Optional[dict] = None) -> dict: + strategy_cfg = strategy_cfg or {} + strategy_name = strategy_cfg.get("name", "v51_baseline") + strategy_threshold = int(strategy_cfg.get("threshold", 75)) + enabled_signals = set(strategy_cfg.get("signals", [])) + + snap = snapshot or self.build_evaluation_snapshot(now_ms) + cvd_fast = snap["cvd_fast"] + cvd_mid = snap["cvd_mid"] + vwap = snap["vwap"] + atr = snap["atr"] + atr_pct = snap["atr_pct"] + p95 = snap["p95"] + p99 = snap["p99"] + price = snap["price"] + cvd_fast_slope = snap["cvd_fast_slope"] + cvd_fast_accel = snap["cvd_fast_accel"] + oi_change = snap["oi_change"] + environment_score = snap["environment_score"] + + result = { + "strategy": strategy_name, + "cvd_fast": cvd_fast, + "cvd_mid": cvd_mid, + "cvd_day": snap["cvd_day"], + "cvd_fast_slope": cvd_fast_slope, + "atr": atr, + "atr_pct": atr_pct, + "vwap": vwap, + "price": price, + "p95": p95, + "p99": p99, + "signal": None, + "direction": None, + "score": 0, "tier": None, "factors": {}, } @@ -296,7 +423,8 @@ class SymbolState: # 判断倾向方向(用于评分展示,即使冷却或方向不一致也计算) no_direction = False - in_cooldown = (now_ms - self.last_signal_ts < COOLDOWN_MS) + last_signal_ts = self.last_signal_ts.get(strategy_name, 0) + in_cooldown = now_ms - last_signal_ts < COOLDOWN_MS if cvd_fast > 0 and cvd_mid > 0: direction = "LONG" @@ -326,8 +454,10 @@ class SymbolState: elif not has_adverse_p99: direction_score += 10 accel_bonus = 0 - if (direction == "LONG" and cvd_fast_accel > 0) or (direction == "SHORT" and cvd_fast_accel < 0): - accel_bonus = 5 + if "accel" in enabled_signals and ( + (direction == "LONG" and cvd_fast_accel > 0) or (direction == "SHORT" and cvd_fast_accel < 0) + ): + accel_bonus = int(strategy_cfg.get("accel_bonus", 5)) # 2) 拥挤层(20分)- market_indicators缺失时给中间分 long_short_ratio = to_float(self.market_indicators.get("long_short_ratio")) @@ -358,24 +488,53 @@ class SymbolState: top_trader_score = 5 crowding_score = ls_score + top_trader_score - # 3) 环境层(15分)— OI变化率 - oi_value = to_float(self.market_indicators.get("open_interest_hist")) - if oi_value is None or self.prev_oi_value == 0: - environment_score = 10 - oi_change = 0.0 - else: - oi_change = (oi_value - self.prev_oi_value) / self.prev_oi_value if self.prev_oi_value > 0 else 0 - if oi_change >= 0.03: - environment_score = 15 - elif oi_change > 0: - environment_score = 10 - else: - environment_score = 5 - if oi_value is not None and oi_value > 0: - self.prev_oi_value = oi_value + # Funding Rate scoring (拥挤层加分) + # Read from market_indicators table + funding_rate = to_float(self.market_indicators.get("funding_rate")) + fr_score = 0 + if "funding_rate" in enabled_signals and funding_rate is not None: + fr_abs = abs(funding_rate) + if fr_abs >= 0.001: # extreme ±0.1% + # Extreme: penalize if going WITH the crowd + if (direction == "LONG" and funding_rate > 0.001) or (direction == "SHORT" and funding_rate < -0.001): + fr_score = -5 + else: + fr_score = 5 + elif fr_abs >= 0.0003: # moderate ±0.03% + # Moderate: reward going AGAINST the crowd + if (direction == "LONG" and funding_rate < -0.0003) or (direction == "SHORT" and funding_rate > 0.0003): + fr_score = 5 + else: + fr_score = 0 # 4) 确认层(15分) - confirmation_score = 15 if ((direction == "LONG" and cvd_fast > 0 and cvd_mid > 0) or (direction == "SHORT" and cvd_fast < 0 and cvd_mid < 0)) else 0 + confirmation_score = 15 if ( + (direction == "LONG" and cvd_fast > 0 and cvd_mid > 0) + or (direction == "SHORT" and cvd_fast < 0 and cvd_mid < 0) + ) else 0 + + # Liquidation scoring (确认层加分) + liq_score = 0 + liq_data = None + if "liquidation" in enabled_signals: + liq_data = self.fetch_recent_liquidations() + if liq_data: + liq_long_usd = liq_data.get("long_usd", 0) + liq_short_usd = liq_data.get("short_usd", 0) + thresholds = {"BTCUSDT": 500000, "ETHUSDT": 200000, "XRPUSDT": 100000, "SOLUSDT": 100000} + threshold = thresholds.get(self.symbol, 100000) + total = liq_long_usd + liq_short_usd + if total >= threshold: + if liq_short_usd > 0 and liq_long_usd > 0: + ratio = liq_short_usd / liq_long_usd + elif liq_short_usd > 0: + ratio = float("inf") + else: + ratio = 0 + if ratio >= 2.0 and direction == "LONG": + liq_score = 5 + elif ratio <= 0.5 and direction == "SHORT": + liq_score = 5 # 5) 辅助层(5分) coinbase_premium = to_float(self.market_indicators.get("coinbase_premium")) @@ -388,7 +547,7 @@ class SymbolState: else: aux_score = 0 - total_score = direction_score + accel_bonus + crowding_score + environment_score + confirmation_score + aux_score + total_score = direction_score + accel_bonus + crowding_score + fr_score + environment_score + confirmation_score + liq_score + aux_score result["score"] = total_score result["direction"] = direction result["factors"] = { @@ -403,27 +562,31 @@ class SymbolState: "environment": {"score": environment_score, "open_interest_hist": oi_change}, "confirmation": {"score": confirmation_score}, "auxiliary": {"score": aux_score, "coinbase_premium": coinbase_premium}, + "funding_rate": {"score": fr_score, "value": funding_rate}, + "liquidation": { + "score": liq_score, + "long_usd": liq_data.get("long_usd", 0) if liq_data else 0, + "short_usd": liq_data.get("short_usd", 0) if liq_data else 0, + }, } # 始终输出direction供反向平仓判断(不受冷却限制) result["direction"] = direction if not no_direction else None - if total_score >= 85 and not no_direction and not in_cooldown: + heavy_threshold = max(strategy_threshold + 10, 85) + if total_score >= heavy_threshold and not no_direction and not in_cooldown: result["signal"] = direction result["tier"] = "heavy" - elif total_score >= 75 and not no_direction and not in_cooldown: + elif total_score >= strategy_threshold and not no_direction and not in_cooldown: result["signal"] = direction result["tier"] = "standard" - elif total_score >= 60 and not no_direction and not in_cooldown: - result["signal"] = direction - result["tier"] = "light" else: result["signal"] = None result["tier"] = None if result["signal"]: - self.last_signal_ts = now_ms - self.last_signal_dir = direction + self.last_signal_ts[strategy_name] = now_ms + self.last_signal_dir[strategy_name] = direction return result @@ -499,31 +662,60 @@ def save_indicator_1m(ts: int, symbol: str, result: dict): # ─── 模拟盘 ────────────────────────────────────────────────────── -def paper_open_trade(symbol: str, direction: str, price: float, score: int, tier: str, atr: float, now_ms: int, factors: dict = None): +def paper_open_trade( + symbol: str, + direction: str, + price: float, + score: int, + tier: str, + atr: float, + now_ms: int, + factors: dict = None, + strategy: str = "v51_baseline", + tp_sl: Optional[dict] = None, +): """模拟开仓""" import json as _json3 risk_atr = 0.7 * atr if risk_atr <= 0: return + sl_multiplier = float((tp_sl or {}).get("sl_multiplier", 2.0)) + tp1_multiplier = float((tp_sl or {}).get("tp1_multiplier", 1.5)) + tp2_multiplier = float((tp_sl or {}).get("tp2_multiplier", 3.0)) if direction == "LONG": - sl = price - 2.0 * risk_atr - tp1 = price + 1.5 * risk_atr - tp2 = price + 3.0 * risk_atr + sl = price - sl_multiplier * risk_atr + tp1 = price + tp1_multiplier * risk_atr + tp2 = price + tp2_multiplier * risk_atr else: - sl = price + 2.0 * risk_atr - tp1 = price - 1.5 * risk_atr - tp2 = price - 3.0 * risk_atr + sl = price + sl_multiplier * risk_atr + tp1 = price - tp1_multiplier * risk_atr + tp2 = price - tp2_multiplier * risk_atr with get_sync_conn() as conn: with conn.cursor() as cur: cur.execute( - "INSERT INTO paper_trades (symbol,direction,score,tier,entry_price,entry_ts,tp1_price,tp2_price,sl_price,atr_at_entry,score_factors) " - "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", - (symbol, direction, score, tier, price, now_ms, tp1, tp2, sl, atr, - _json3.dumps(factors) if factors else None) + "INSERT INTO paper_trades (symbol,direction,score,tier,entry_price,entry_ts,tp1_price,tp2_price,sl_price,atr_at_entry,score_factors,strategy) " + "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", + ( + symbol, + direction, + score, + tier, + price, + now_ms, + tp1, + tp2, + sl, + atr, + _json3.dumps(factors) if factors else None, + strategy, + ), ) conn.commit() - logger.info(f"[{symbol}] 📝 模拟开仓: {direction} @ {price:.2f} score={score} tier={tier} TP1={tp1:.2f} TP2={tp2:.2f} SL={sl:.2f}") + logger.info( + f"[{symbol}] 📝 模拟开仓: {direction} @ {price:.2f} score={score} tier={tier} strategy={strategy} " + f"TP1={tp1:.2f} TP2={tp2:.2f} SL={sl:.2f}" + ) def paper_check_positions(symbol: str, current_price: float, now_ms: int): @@ -620,32 +812,54 @@ def paper_check_positions(symbol: str, current_price: float, now_ms: int): conn.commit() -def paper_has_active_position(symbol: str) -> bool: +def paper_has_active_position(symbol: str, strategy: Optional[str] = None) -> bool: """检查该币种是否有活跃持仓""" with get_sync_conn() as conn: with conn.cursor() as cur: - cur.execute("SELECT COUNT(*) FROM paper_trades WHERE symbol=%s AND status IN ('active','tp1_hit')", (symbol,)) + if strategy: + cur.execute( + "SELECT COUNT(*) FROM paper_trades WHERE symbol=%s AND strategy=%s AND status IN ('active','tp1_hit')", + (symbol, strategy), + ) + else: + cur.execute("SELECT COUNT(*) FROM paper_trades WHERE symbol=%s AND status IN ('active','tp1_hit')", (symbol,)) return cur.fetchone()[0] > 0 -def paper_get_active_direction(symbol: str) -> str | None: +def paper_get_active_direction(symbol: str, strategy: Optional[str] = None) -> str | None: """获取该币种活跃持仓的方向,无持仓返回None""" with get_sync_conn() as conn: with conn.cursor() as cur: - cur.execute("SELECT direction FROM paper_trades WHERE symbol=%s AND status IN ('active','tp1_hit') LIMIT 1", (symbol,)) + if strategy: + cur.execute( + "SELECT direction FROM paper_trades WHERE symbol=%s AND strategy=%s AND status IN ('active','tp1_hit') LIMIT 1", + (symbol, strategy), + ) + else: + cur.execute( + "SELECT direction FROM paper_trades WHERE symbol=%s AND status IN ('active','tp1_hit') LIMIT 1", + (symbol,), + ) row = cur.fetchone() return row[0] if row else None -def paper_close_by_signal(symbol: str, current_price: float, now_ms: int): +def paper_close_by_signal(symbol: str, current_price: float, now_ms: int, strategy: Optional[str] = None): """反向信号平仓:按当前价平掉该币种所有活跃仓位""" with get_sync_conn() as conn: with conn.cursor() as cur: - cur.execute( - "SELECT id, direction, entry_price, tp1_hit, atr_at_entry " - "FROM paper_trades WHERE symbol=%s AND status IN ('active','tp1_hit')", - (symbol,) - ) + if strategy: + cur.execute( + "SELECT id, direction, entry_price, tp1_hit, atr_at_entry " + "FROM paper_trades WHERE symbol=%s AND strategy=%s AND status IN ('active','tp1_hit')", + (symbol, strategy), + ) + else: + cur.execute( + "SELECT id, direction, entry_price, tp1_hit, atr_at_entry " + "FROM paper_trades WHERE symbol=%s AND status IN ('active','tp1_hit')", + (symbol,), + ) positions = cur.fetchall() for pos in positions: pid, direction, entry_price, tp1_hit, atr_entry = pos @@ -661,15 +875,21 @@ def paper_close_by_signal(symbol: str, current_price: float, now_ms: int): "UPDATE paper_trades SET status='signal_flip', exit_price=%s, exit_ts=%s, pnl_r=%s WHERE id=%s", (current_price, now_ms, round(pnl_r, 4), pid) ) - logger.info(f"[{symbol}] 📝 反向信号平仓: {direction} @ {current_price:.2f} pnl={pnl_r:+.2f}R") + logger.info( + f"[{symbol}] 📝 反向信号平仓: {direction} @ {current_price:.2f} pnl={pnl_r:+.2f}R" + f"{f' strategy={strategy}' if strategy else ''}" + ) conn.commit() -def paper_active_count() -> int: - """当前所有币种活跃持仓总数""" +def paper_active_count(strategy: Optional[str] = None) -> int: + """当前活跃持仓总数(按策略独立计数)""" with get_sync_conn() as conn: with conn.cursor() as cur: - cur.execute("SELECT COUNT(*) FROM paper_trades WHERE status IN ('active','tp1_hit')") + if strategy: + cur.execute("SELECT COUNT(*) FROM paper_trades WHERE strategy=%s AND status IN ('active','tp1_hit')", (strategy,)) + else: + cur.execute("SELECT COUNT(*) FROM paper_trades WHERE status IN ('active','tp1_hit')") return cur.fetchone()[0] @@ -677,6 +897,11 @@ def paper_active_count() -> int: def main(): init_schema() + strategy_configs = load_strategy_configs() + strategy_names = [cfg.get("name", "unknown") for cfg in strategy_configs] + logger.info(f"已加载策略配置: {', '.join(strategy_names)}") + primary_strategy_name = "v52_8signals" if any(cfg.get("name") == "v52_8signals" for cfg in strategy_configs) else strategy_names[0] + states = {sym: SymbolState(sym) for sym in SYMBOLS} for sym, state in states.items(): @@ -699,36 +924,65 @@ def main(): state.process_trade(t["agg_id"], t["time_ms"], t["price"], t["qty"], t["is_buyer_maker"]) state.market_indicators = fetch_market_indicators(sym) - result = state.evaluate_signal(now_ms) - save_indicator(now_ms, sym, result) + snapshot = state.build_evaluation_snapshot(now_ms) + strategy_results: list[tuple[dict, dict]] = [] + for strategy_cfg in strategy_configs: + strategy_result = state.evaluate_signal(now_ms, strategy_cfg=strategy_cfg, snapshot=snapshot) + strategy_results.append((strategy_cfg, strategy_result)) + + primary_result = strategy_results[0][1] + for strategy_cfg, strategy_result in strategy_results: + if strategy_cfg.get("name") == primary_strategy_name: + primary_result = strategy_result + break + + save_indicator(now_ms, sym, primary_result) bar_1m = (now_ms // 60000) * 60000 if last_1m_save.get(sym) != bar_1m: - save_indicator_1m(now_ms, sym, result) + save_indicator_1m(now_ms, sym, primary_result) last_1m_save[sym] = bar_1m - # 反向信号平仓:基于direction(不受冷却限制),score>=60才触发 - if PAPER_TRADING_ENABLED and warmup_cycles <= 0: - eval_dir = result.get("direction") - existing_dir = paper_get_active_direction(sym) - if existing_dir and eval_dir and existing_dir != eval_dir and result["score"] >= 60: - paper_close_by_signal(sym, result["price"], now_ms) - logger.info(f"[{sym}] 📝 反向信号平仓: {existing_dir} → {eval_dir} (score={result['score']})") + # 反向信号平仓:按策略独立判断,score>=75才触发 + if warmup_cycles <= 0: + for strategy_cfg, result in strategy_results: + strategy_name = strategy_cfg.get("name", "v51_baseline") + if not is_strategy_enabled(strategy_name): + continue + eval_dir = result.get("direction") + existing_dir = paper_get_active_direction(sym, strategy_name) + if existing_dir and eval_dir and existing_dir != eval_dir and result["score"] >= 75: + paper_close_by_signal(sym, result["price"], now_ms, strategy_name) + logger.info( + f"[{sym}] 📝 反向信号平仓[{strategy_name}]: {existing_dir} → {eval_dir} " + f"(score={result['score']})" + ) - if result.get("signal"): - logger.info(f"[{sym}] 🚨 信号: {result['signal']} score={result['score']} price={result['price']:.1f}") - # 模拟盘开仓(需开关开启 + 跳过冷启动) - if PAPER_TRADING_ENABLED and warmup_cycles <= 0: - if not paper_has_active_position(sym): - active_count = paper_active_count() - if active_count < PAPER_MAX_POSITIONS: - tier = result.get("tier", "standard") - paper_open_trade( - sym, result["signal"], result["price"], - result["score"], tier, - result["atr"], now_ms, - factors=result.get("factors") - ) + for strategy_cfg, result in strategy_results: + strategy_name = strategy_cfg.get("name", "v51_baseline") + if result.get("signal"): + logger.info( + f"[{sym}] 🚨 信号[{strategy_name}]: {result['signal']} " + f"score={result['score']} price={result['price']:.1f}" + ) + # 模拟盘开仓(需该策略启用 + 跳过冷启动) + if is_strategy_enabled(strategy_name) and warmup_cycles <= 0: + if not paper_has_active_position(sym, strategy_name): + active_count = paper_active_count(strategy_name) + if active_count < PAPER_MAX_POSITIONS: + tier = result.get("tier", "standard") + paper_open_trade( + sym, + result["signal"], + result["price"], + result["score"], + tier, + result["atr"], + now_ms, + factors=result.get("factors"), + strategy=strategy_name, + tp_sl=strategy_cfg.get("tp_sl"), + ) # 模拟盘持仓检查由paper_monitor.py通过WebSocket实时处理,这里不再检查 diff --git a/backend/strategies/v51_baseline.json b/backend/strategies/v51_baseline.json new file mode 100644 index 0000000..814d6bf --- /dev/null +++ b/backend/strategies/v51_baseline.json @@ -0,0 +1,19 @@ +{ + "name": "v51_baseline", + "version": "5.1", + "threshold": 75, + "weights": { + "direction": 45, + "crowding": 20, + "environment": 15, + "confirmation": 15, + "auxiliary": 5 + }, + "accel_bonus": 5, + "tp_sl": { + "sl_multiplier": 2.0, + "tp1_multiplier": 1.5, + "tp2_multiplier": 3.0 + }, + "signals": ["cvd", "p99", "accel", "ls_ratio", "oi", "coinbase_premium"] +} diff --git a/backend/strategies/v52_8signals.json b/backend/strategies/v52_8signals.json new file mode 100644 index 0000000..5589f92 --- /dev/null +++ b/backend/strategies/v52_8signals.json @@ -0,0 +1,19 @@ +{ + "name": "v52_8signals", + "version": "5.2", + "threshold": 75, + "weights": { + "direction": 40, + "crowding": 25, + "environment": 15, + "confirmation": 20, + "auxiliary": 5 + }, + "accel_bonus": 5, + "tp_sl": { + "sl_multiplier": 2.0, + "tp1_multiplier": 1.5, + "tp2_multiplier": 3.0 + }, + "signals": ["cvd", "p99", "accel", "ls_ratio", "oi", "coinbase_premium", "funding_rate", "liquidation"] +} diff --git a/frontend/app/globals.css b/frontend/app/globals.css index deb4ccd..f2b3c2a 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -9,6 +9,8 @@ --muted: #64748b; --primary: #2563eb; --primary-foreground: #ffffff; + --font-geist-sans: "Segoe UI", "PingFang SC", "Noto Sans", sans-serif; + --font-geist-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; } @theme inline { diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 0747d89..ef32d93 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,13 +1,9 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import Sidebar from "@/components/Sidebar"; import { AuthProvider } from "@/lib/auth"; import AuthHeader from "@/components/AuthHeader"; -const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] }); -const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"] }); - export const metadata: Metadata = { title: "Arbitrage Engine", description: "Funding rate arbitrage monitoring system", @@ -16,7 +12,7 @@ export const metadata: Metadata = { export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { return ( -
+当前资金
-= 10000 ? "text-emerald-600" : "text-red-500"}`}>${data.balance?.toLocaleString()}
+= 10000 ? "text-emerald-600" : "text-red-500"}`}> + ${data.balance?.toLocaleString()} +
总盈亏(R)
-= 0 ? "text-emerald-600" : "text-red-500"}`}>{data.total_pnl >= 0 ? "+" : ""}{data.total_pnl}R
-= 0 ? "text-emerald-500" : "text-red-400"}`}>{data.total_pnl_usdt >= 0 ? "+" : ""}${data.total_pnl_usdt}
+= 0 ? "text-emerald-600" : "text-red-500"}`}> + {data.total_pnl >= 0 ? "+" : ""} + {data.total_pnl}R +
+= 0 ? "text-emerald-500" : "text-red-400"}`}> + {data.total_pnl_usdt >= 0 ? "+" : ""}${data.total_pnl_usdt} +
胜率
@@ -118,17 +194,19 @@ function LatestSignals() { const f = async () => { for (const sym of COINS) { try { - const r = await authFetch(`/api/signals/signal-history?symbol=${sym.replace("USDT","")}&limit=1`); + const r = await authFetch(`/api/signals/signal-history?symbol=${sym.replace("USDT", "")}&limit=1`); if (r.ok) { const j = await r.json(); if (j.data && j.data.length > 0) { - setSignals(prev => ({ ...prev, [sym]: j.data[0] })); + setSignals((prev) => ({ ...prev, [sym]: j.data[0] })); } } } catch {} } }; - f(); const iv = setInterval(f, 15000); return () => clearInterval(iv); + f(); + const iv = setInterval(f, 15000); + return () => clearInterval(iv); }, []); return ( @@ -137,7 +215,7 @@ function LatestSignals() {