Compare commits

..

8 Commits

Author SHA1 Message Date
root
87f19cb7d8 merge: V5.2 strategy differentiation + independent paper trading
- V5.2 frontend: sidebar entries, strategy tabs, visual badges
- Independent strategy paper trading (per-strategy position count)
- Per-strategy enable/disable control
- Signals page: V5.1 vs V5.2 side-by-side comparison
- FR/Liquidation scoring display
2026-03-01 13:47:09 +00:00
root
a9c3523a24 feat: independent strategy paper trading controls
- Each strategy has its own position count (max 4 each)
- paper_config.enabled_strategies: per-strategy toggle
- is_strategy_enabled() checks both master switch and strategy list
- API: /api/paper/config now supports enabled_strategies array
- Config auto-loads on API startup

Usage: POST /api/paper/config {enabled_strategies: ['v52_8signals']}
2026-03-01 12:43:46 +00:00
root
ee90b8dcfa feat: sidebar navigation with V5.1/V5.2 separate entries
- Sidebar: 信号/模拟盘 section headers
- Three paper trade entries: 全部持仓, V5.1模拟盘, V5.2模拟盘 (NEW badge)
- Paper page reads strategy from URL query params
- Suspense boundary for useSearchParams
2026-03-01 12:25:40 +00:00
root
778cf8cce1 feat: V5.2 frontend differentiation - strategy tabs, side-by-side scores, visual badges
- Paper page: prominent strategy tabs (全部/V5.1/V5.2) at top
- Paper trades: strategy column with color-coded badges (blue=V5.1, green=V5.2)
- Paper positions: FR/Liq scores displayed prominently for V5.2
- Signals page: side-by-side V5.1 vs V5.2 score comparison cards
- Signals page title updated to 'V5.1 vs V5.2'
- New API endpoint for strategy comparison data
- Layout: local font fallback for build stability
2026-03-01 12:21:19 +00:00
root
7ba53a5005 Update paper UI for strategy filters and FR/liquidation details 2026-03-01 11:55:03 +00:00
root
f6156a2cfe Add strategy-aware paper trade schema and API endpoints 2026-03-01 11:55:00 +00:00
root
732b01691b Implement V5.2 FR/liquidation scoring and strategy AB loop 2026-03-01 11:54:58 +00:00
root
a7600e8db1 Add V5.2 strategy configuration files 2026-03-01 11:54:54 +00:00
14 changed files with 1449 additions and 257 deletions

63
V52_FRONTEND_TASK.md Normal file
View File

@ -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

224
V52_TASK.md Normal file
View File

@ -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

View File

@ -355,5 +355,9 @@ def init_schema():
conn.rollback() conn.rollback()
# 忽略已存在错误 # 忽略已存在错误
continue continue
cur.execute(
"ALTER TABLE paper_trades "
"ADD COLUMN IF NOT EXISTS strategy VARCHAR(32) DEFAULT 'v51_baseline'"
)
conn.commit() conn.commit()
ensure_partitions() ensure_partitions()

View File

@ -2,7 +2,7 @@ from fastapi import FastAPI, HTTPException, Depends, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
import httpx import httpx
from datetime import datetime, timedelta 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 auth import router as auth_router, get_current_user, ensure_tables as ensure_auth_tables
from db import ( from db import (
@ -436,13 +436,109 @@ async def get_signal_latest(user: dict = Depends(get_current_user)):
return result 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") @app.get("/api/signals/market-indicators")
async def get_market_indicators(user: dict = Depends(get_current_user)): async def get_market_indicators(user: dict = Depends(get_current_user)):
"""返回最新的market_indicators数据V5.1新增4个数据源""" """返回最新的market_indicators数据V5.1新增4个数据源"""
result = {} result = {}
for sym in SYMBOLS: for sym in SYMBOLS:
indicators = {} 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( 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", "SELECT value, timestamp_ms FROM market_indicators WHERE symbol = $1 AND indicator_type = $2 ORDER BY timestamp_ms DESC LIMIT 1",
sym, sym,
@ -501,12 +597,23 @@ async def get_signal_trades(
# 模拟盘配置状态与signal_engine共享的运行时状态 # 模拟盘配置状态与signal_engine共享的运行时状态
paper_config = { paper_config = {
"enabled": False, "enabled": False,
"enabled_strategies": [], # 分策略开关: ["v51_baseline", "v52_8signals"]
"initial_balance": 10000, "initial_balance": 10000,
"risk_per_trade": 0.02, "risk_per_trade": 0.02,
"max_positions": 4, "max_positions": 4,
"tier_multiplier": {"light": 0.5, "standard": 1.0, "heavy": 1.5}, "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") @app.get("/api/paper/config")
async def paper_get_config(user: dict = Depends(get_current_user)): 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": if user.get("role") != "admin":
raise HTTPException(status_code=403, detail="仅管理员可修改") raise HTTPException(status_code=403, detail="仅管理员可修改")
body = await request.json() 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: if k in body:
paper_config[k] = body[k] paper_config[k] = body[k]
# 写入配置文件让signal_engine也能读到 # 写入配置文件让signal_engine也能读到
import json
config_path = os.path.join(os.path.dirname(__file__), "paper_config.json") config_path = os.path.join(os.path.dirname(__file__), "paper_config.json")
with open(config_path, "w") as f: with open(config_path, "w") as f:
json.dump(paper_config, f, indent=2) json.dump(paper_config, f, indent=2)
@ -532,8 +638,12 @@ async def paper_set_config(request: Request, user: dict = Depends(get_current_us
@app.get("/api/paper/summary") @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),
):
"""模拟盘总览""" """模拟盘总览"""
if strategy == "all":
closed = await async_fetch( closed = await async_fetch(
"SELECT pnl_r, direction FROM paper_trades WHERE status NOT IN ('active','tp1_hit')" "SELECT pnl_r, direction FROM paper_trades WHERE status NOT IN ('active','tp1_hit')"
) )
@ -541,6 +651,20 @@ async def paper_summary(user: dict = Depends(get_current_user)):
"SELECT id FROM paper_trades WHERE status IN ('active','tp1_hit')" "SELECT id FROM paper_trades WHERE status IN ('active','tp1_hit')"
) )
first = await async_fetchrow("SELECT MIN(created_at) as start FROM paper_trades") 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) total = len(closed)
wins = len([r for r in closed if r["pnl_r"] > 0]) 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") @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),
):
"""当前活跃持仓(含实时价格和浮动盈亏)""" """当前活跃持仓(含实时价格和浮动盈亏)"""
if strategy == "all":
rows = await async_fetch( rows = await async_fetch(
"SELECT id, symbol, direction, score, tier, entry_price, entry_ts, " "SELECT id, symbol, direction, score, tier, strategy, entry_price, entry_ts, "
"tp1_price, tp2_price, sl_price, tp1_hit, status, atr_at_entry " "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" "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获取实时价格 # 从币安API获取实时价格
prices = {} prices = {}
symbols_needed = list(set(r["symbol"] for r in rows)) 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( async def paper_trades(
symbol: str = "all", symbol: str = "all",
result: str = "all", result: str = "all",
strategy: str = "all",
limit: int = 100, limit: int = 100,
user: dict = Depends(get_current_user), user: dict = Depends(get_current_user),
): ):
@ -642,11 +778,16 @@ async def paper_trades(
elif result == "loss": elif result == "loss":
conditions.append("pnl_r <= 0") conditions.append("pnl_r <= 0")
if strategy != "all":
conditions.append(f"strategy = ${idx}")
params.append(strategy)
idx += 1
where = " AND ".join(conditions) where = " AND ".join(conditions)
params.append(limit) params.append(limit)
rows = await async_fetch( rows = await async_fetch(
f"SELECT id, symbol, direction, score, tier, entry_price, exit_price, " f"SELECT id, symbol, direction, score, tier, strategy, entry_price, exit_price, "
f"entry_ts, exit_ts, pnl_r, status, tp1_hit " 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}", f"FROM paper_trades WHERE {where} ORDER BY exit_ts DESC LIMIT ${idx}",
*params *params
) )
@ -654,10 +795,21 @@ async def paper_trades(
@app.get("/api/paper/equity-curve") @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),
):
"""权益曲线""" """权益曲线"""
if strategy == "all":
rows = await async_fetch( rows = await async_fetch(
"SELECT exit_ts, pnl_r FROM paper_trades WHERE status NOT IN ('active','tp1_hit') ORDER BY exit_ts ASC" "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 cumulative = 0.0
curve = [] curve = []
@ -668,12 +820,22 @@ async def paper_equity_curve(user: dict = Depends(get_current_user)):
@app.get("/api/paper/stats") @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),
):
"""详细统计""" """详细统计"""
if strategy == "all":
rows = await async_fetch( rows = await async_fetch(
"SELECT symbol, direction, pnl_r, tier, entry_ts, exit_ts " "SELECT symbol, direction, pnl_r, tier, entry_ts, exit_ts "
"FROM paper_trades WHERE status NOT IN ('active','tp1_hit')" "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: if not rows:
return {"error": "暂无数据"} 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 import shutil, subprocess, psutil

View File

@ -18,6 +18,7 @@ signal_engine.py — V5 短线交易信号引擎PostgreSQL版
import logging import logging
import os import os
import time import time
import json
from collections import deque from collections import deque
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Optional from typing import Any, Optional
@ -36,12 +37,41 @@ logger = logging.getLogger("signal-engine")
SYMBOLS = ["BTCUSDT", "ETHUSDT", "XRPUSDT", "SOLUSDT"] SYMBOLS = ["BTCUSDT", "ETHUSDT", "XRPUSDT", "SOLUSDT"]
LOOP_INTERVAL = 15 # 秒从5改15CPU降60%,信号质量无影响) LOOP_INTERVAL = 15 # 秒从5改15CPU降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_INITIAL_BALANCE = 10000 # 虚拟初始资金 USDT
PAPER_RISK_PER_TRADE = 0.02 # 单笔风险 2%即200U PAPER_RISK_PER_TRADE = 0.02 # 单笔风险 2%即200U
PAPER_MAX_POSITIONS = 4 # 最大同时持仓数 PAPER_MAX_POSITIONS = 4 # 每套策略最大同时持仓数
PAPER_TIER_MULTIPLIER = { # 档位仓位倍数 PAPER_TIER_MULTIPLIER = { # 档位仓位倍数
"light": 0.5, # 轻仓: 1% "light": 0.5, # 轻仓: 1%
"standard": 1.0, # 标准: 2% "standard": 1.0, # 标准: 2%
@ -51,18 +81,29 @@ PAPER_FEE_RATE = 0.0005 # Taker手续费 0.05%(开仓+平仓各一
def load_paper_config(): 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") config_path = os.path.join(os.path.dirname(__file__), "paper_config.json")
try: try:
with open(config_path, "r") as f: with open(config_path, "r") as f:
import json as _json2 import json as _json2
cfg = _json2.load(f) cfg = _json2.load(f)
PAPER_TRADING_ENABLED = cfg.get("enabled", False) PAPER_TRADING_ENABLED = cfg.get("enabled", False)
PAPER_ENABLED_STRATEGIES = cfg.get("enabled_strategies", [])
PAPER_INITIAL_BALANCE = cfg.get("initial_balance", 10000) PAPER_INITIAL_BALANCE = cfg.get("initial_balance", 10000)
PAPER_RISK_PER_TRADE = cfg.get("risk_per_trade", 0.02) PAPER_RISK_PER_TRADE = cfg.get("risk_per_trade", 0.02)
PAPER_MAX_POSITIONS = cfg.get("max_positions", 4) PAPER_MAX_POSITIONS = cfg.get("max_positions", 4)
except FileNotFoundError: except FileNotFoundError:
pass 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 get_sync_conn() as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
indicators = {} 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( cur.execute(
"SELECT value FROM market_indicators WHERE symbol=%s AND indicator_type=%s ORDER BY timestamp_ms DESC LIMIT 1", "SELECT value FROM market_indicators WHERE symbol=%s AND indicator_type=%s ORDER BY timestamp_ms DESC LIMIT 1",
(symbol, ind_type), (symbol, ind_type),
@ -111,6 +152,8 @@ def fetch_market_indicators(symbol: str) -> dict:
indicators[ind_type] = float(val.get("sumOpenInterestValue", 0)) indicators[ind_type] = float(val.get("sumOpenInterestValue", 0))
elif ind_type == "coinbase_premium": elif ind_type == "coinbase_premium":
indicators[ind_type] = float(val.get("premium_pct", 0)) indicators[ind_type] = float(val.get("premium_pct", 0))
elif ind_type == "funding_rate":
indicators[ind_type] = float(val.get("lastFundingRate", 0))
return indicators return indicators
@ -227,8 +270,8 @@ class SymbolState:
self.prev_cvd_fast_slope = 0.0 self.prev_cvd_fast_slope = 0.0
self.prev_oi_value = 0.0 self.prev_oi_value = 0.0
self.market_indicators = fetch_market_indicators(symbol) self.market_indicators = fetch_market_indicators(symbol)
self.last_signal_ts = 0 self.last_signal_ts: dict[str, int] = {}
self.last_signal_dir = "" self.last_signal_dir: dict[str, str] = {}
self.recent_large_trades: deque = deque() self.recent_large_trades: deque = deque()
def process_trade(self, agg_id: int, time_ms: int, price: float, qty: float, is_buyer_maker: int): 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])) self.recent_large_trades.append((t[0], t[1], t[3]))
seen.add(t[0]) 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_fast = self.win_fast.cvd
cvd_mid = self.win_mid.cvd cvd_mid = self.win_mid.cvd
cvd_day = self.win_day.cvd
vwap = self.win_vwap.vwap vwap = self.win_vwap.vwap
atr = self.atr_calc.atr atr = self.atr_calc.atr
atr_pct = self.atr_calc.atr_percentile atr_pct = self.atr_calc.atr_percentile
@ -282,11 +326,94 @@ class SymbolState:
self.prev_cvd_fast = cvd_fast self.prev_cvd_fast = cvd_fast
self.prev_cvd_fast_slope = cvd_fast_slope self.prev_cvd_fast_slope = cvd_fast_slope
result = { oi_value = to_float(self.market_indicators.get("open_interest_hist"))
"cvd_fast": cvd_fast, "cvd_mid": cvd_mid, "cvd_day": self.win_day.cvd, 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, "cvd_fast_slope": cvd_fast_slope,
"atr": atr, "atr_pct": atr_pct, "vwap": vwap, "price": price, "cvd_fast_accel": cvd_fast_accel,
"p95": p95, "p99": p99, "signal": None, "direction": None, "score": 0, "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, "tier": None,
"factors": {}, "factors": {},
} }
@ -296,7 +423,8 @@ class SymbolState:
# 判断倾向方向(用于评分展示,即使冷却或方向不一致也计算) # 判断倾向方向(用于评分展示,即使冷却或方向不一致也计算)
no_direction = False 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: if cvd_fast > 0 and cvd_mid > 0:
direction = "LONG" direction = "LONG"
@ -326,8 +454,10 @@ class SymbolState:
elif not has_adverse_p99: elif not has_adverse_p99:
direction_score += 10 direction_score += 10
accel_bonus = 0 accel_bonus = 0
if (direction == "LONG" and cvd_fast_accel > 0) or (direction == "SHORT" and cvd_fast_accel < 0): if "accel" in enabled_signals and (
accel_bonus = 5 (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缺失时给中间分 # 2) 拥挤层20分- market_indicators缺失时给中间分
long_short_ratio = to_float(self.market_indicators.get("long_short_ratio")) long_short_ratio = to_float(self.market_indicators.get("long_short_ratio"))
@ -358,24 +488,53 @@ class SymbolState:
top_trader_score = 5 top_trader_score = 5
crowding_score = ls_score + top_trader_score crowding_score = ls_score + top_trader_score
# 3) 环境层15分— OI变化率 # Funding Rate scoring (拥挤层加分)
oi_value = to_float(self.market_indicators.get("open_interest_hist")) # Read from market_indicators table
if oi_value is None or self.prev_oi_value == 0: funding_rate = to_float(self.market_indicators.get("funding_rate"))
environment_score = 10 fr_score = 0
oi_change = 0.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: else:
oi_change = (oi_value - self.prev_oi_value) / self.prev_oi_value if self.prev_oi_value > 0 else 0 fr_score = 5
if oi_change >= 0.03: elif fr_abs >= 0.0003: # moderate ±0.03%
environment_score = 15 # Moderate: reward going AGAINST the crowd
elif oi_change > 0: if (direction == "LONG" and funding_rate < -0.0003) or (direction == "SHORT" and funding_rate > 0.0003):
environment_score = 10 fr_score = 5
else: else:
environment_score = 5 fr_score = 0
if oi_value is not None and oi_value > 0:
self.prev_oi_value = oi_value
# 4) 确认层15分 # 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分 # 5) 辅助层5分
coinbase_premium = to_float(self.market_indicators.get("coinbase_premium")) coinbase_premium = to_float(self.market_indicators.get("coinbase_premium"))
@ -388,7 +547,7 @@ class SymbolState:
else: else:
aux_score = 0 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["score"] = total_score
result["direction"] = direction result["direction"] = direction
result["factors"] = { result["factors"] = {
@ -403,27 +562,31 @@ class SymbolState:
"environment": {"score": environment_score, "open_interest_hist": oi_change}, "environment": {"score": environment_score, "open_interest_hist": oi_change},
"confirmation": {"score": confirmation_score}, "confirmation": {"score": confirmation_score},
"auxiliary": {"score": aux_score, "coinbase_premium": coinbase_premium}, "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供反向平仓判断不受冷却限制 # 始终输出direction供反向平仓判断不受冷却限制
result["direction"] = direction if not no_direction else None 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["signal"] = direction
result["tier"] = "heavy" 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["signal"] = direction
result["tier"] = "standard" result["tier"] = "standard"
elif total_score >= 60 and not no_direction and not in_cooldown:
result["signal"] = direction
result["tier"] = "light"
else: else:
result["signal"] = None result["signal"] = None
result["tier"] = None result["tier"] = None
if result["signal"]: if result["signal"]:
self.last_signal_ts = now_ms self.last_signal_ts[strategy_name] = now_ms
self.last_signal_dir = direction self.last_signal_dir[strategy_name] = direction
return result 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 import json as _json3
risk_atr = 0.7 * atr risk_atr = 0.7 * atr
if risk_atr <= 0: if risk_atr <= 0:
return 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": if direction == "LONG":
sl = price - 2.0 * risk_atr sl = price - sl_multiplier * risk_atr
tp1 = price + 1.5 * risk_atr tp1 = price + tp1_multiplier * risk_atr
tp2 = price + 3.0 * risk_atr tp2 = price + tp2_multiplier * risk_atr
else: else:
sl = price + 2.0 * risk_atr sl = price + sl_multiplier * risk_atr
tp1 = price - 1.5 * risk_atr tp1 = price - tp1_multiplier * risk_atr
tp2 = price - 3.0 * risk_atr tp2 = price - tp2_multiplier * risk_atr
with get_sync_conn() as conn: with get_sync_conn() as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute( 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) " "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)", "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) symbol,
direction,
score,
tier,
price,
now_ms,
tp1,
tp2,
sl,
atr,
_json3.dumps(factors) if factors else None,
strategy,
),
) )
conn.commit() 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): def paper_check_positions(symbol: str, current_price: float, now_ms: int):
@ -620,31 +812,53 @@ def paper_check_positions(symbol: str, current_price: float, now_ms: int):
conn.commit() 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 get_sync_conn() as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
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,)) cur.execute("SELECT COUNT(*) FROM paper_trades WHERE symbol=%s AND status IN ('active','tp1_hit')", (symbol,))
return cur.fetchone()[0] > 0 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""" """获取该币种活跃持仓的方向无持仓返回None"""
with get_sync_conn() as conn: with get_sync_conn() as conn:
with conn.cursor() as cur: 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() row = cur.fetchone()
return row[0] if row else None 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 get_sync_conn() as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
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( cur.execute(
"SELECT id, direction, entry_price, tp1_hit, atr_at_entry " "SELECT id, direction, entry_price, tp1_hit, atr_at_entry "
"FROM paper_trades WHERE symbol=%s AND status IN ('active','tp1_hit')", "FROM paper_trades WHERE symbol=%s AND status IN ('active','tp1_hit')",
(symbol,) (symbol,),
) )
positions = cur.fetchall() positions = cur.fetchall()
for pos in positions: for pos in positions:
@ -661,14 +875,20 @@ 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", "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) (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() conn.commit()
def paper_active_count() -> int: def paper_active_count(strategy: Optional[str] = None) -> int:
"""当前所有币种活跃持仓总数""" """当前活跃持仓总数(按策略独立计数)"""
with get_sync_conn() as conn: with get_sync_conn() as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
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')") cur.execute("SELECT COUNT(*) FROM paper_trades WHERE status IN ('active','tp1_hit')")
return cur.fetchone()[0] return cur.fetchone()[0]
@ -677,6 +897,11 @@ def paper_active_count() -> int:
def main(): def main():
init_schema() 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} states = {sym: SymbolState(sym) for sym in SYMBOLS}
for sym, state in states.items(): for sym, state in states.items():
@ -699,35 +924,64 @@ def main():
state.process_trade(t["agg_id"], t["time_ms"], t["price"], t["qty"], t["is_buyer_maker"]) state.process_trade(t["agg_id"], t["time_ms"], t["price"], t["qty"], t["is_buyer_maker"])
state.market_indicators = fetch_market_indicators(sym) state.market_indicators = fetch_market_indicators(sym)
result = state.evaluate_signal(now_ms) snapshot = state.build_evaluation_snapshot(now_ms)
save_indicator(now_ms, sym, result) 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 bar_1m = (now_ms // 60000) * 60000
if last_1m_save.get(sym) != bar_1m: 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 last_1m_save[sym] = bar_1m
# 反向信号平仓基于direction不受冷却限制score>=60才触发 # 反向信号平仓按策略独立判断score>=75才触发
if PAPER_TRADING_ENABLED and warmup_cycles <= 0: 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") eval_dir = result.get("direction")
existing_dir = paper_get_active_direction(sym) existing_dir = paper_get_active_direction(sym, strategy_name)
if existing_dir and eval_dir and existing_dir != eval_dir and result["score"] >= 60: if existing_dir and eval_dir and existing_dir != eval_dir and result["score"] >= 75:
paper_close_by_signal(sym, result["price"], now_ms) paper_close_by_signal(sym, result["price"], now_ms, strategy_name)
logger.info(f"[{sym}] 📝 反向信号平仓: {existing_dir}{eval_dir} (score={result['score']})") logger.info(
f"[{sym}] 📝 反向信号平仓[{strategy_name}]: {existing_dir}{eval_dir} "
f"(score={result['score']})"
)
for strategy_cfg, result in strategy_results:
strategy_name = strategy_cfg.get("name", "v51_baseline")
if result.get("signal"): if result.get("signal"):
logger.info(f"[{sym}] 🚨 信号: {result['signal']} score={result['score']} price={result['price']:.1f}") logger.info(
# 模拟盘开仓(需开关开启 + 跳过冷启动) f"[{sym}] 🚨 信号[{strategy_name}]: {result['signal']} "
if PAPER_TRADING_ENABLED and warmup_cycles <= 0: f"score={result['score']} price={result['price']:.1f}"
if not paper_has_active_position(sym): )
active_count = paper_active_count() # 模拟盘开仓(需该策略启用 + 跳过冷启动)
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: if active_count < PAPER_MAX_POSITIONS:
tier = result.get("tier", "standard") tier = result.get("tier", "standard")
paper_open_trade( paper_open_trade(
sym, result["signal"], result["price"], sym,
result["score"], tier, result["signal"],
result["atr"], now_ms, result["price"],
factors=result.get("factors") 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实时处理这里不再检查 # 模拟盘持仓检查由paper_monitor.py通过WebSocket实时处理这里不再检查

View File

@ -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"]
}

View File

@ -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"]
}

View File

@ -9,6 +9,8 @@
--muted: #64748b; --muted: #64748b;
--primary: #2563eb; --primary: #2563eb;
--primary-foreground: #ffffff; --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 { @theme inline {

View File

@ -1,13 +1,9 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import Sidebar from "@/components/Sidebar"; import Sidebar from "@/components/Sidebar";
import { AuthProvider } from "@/lib/auth"; import { AuthProvider } from "@/lib/auth";
import AuthHeader from "@/components/AuthHeader"; 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 = { export const metadata: Metadata = {
title: "Arbitrage Engine", title: "Arbitrage Engine",
description: "Funding rate arbitrage monitoring system", description: "Funding rate arbitrage monitoring system",
@ -16,7 +12,7 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return ( return (
<html lang="zh"> <html lang="zh">
<body className={`${geistSans.variable} ${geistMono.variable} antialiased min-h-screen bg-slate-50 text-slate-900`}> <body className="antialiased min-h-screen bg-slate-50 text-slate-900">
<AuthProvider> <AuthProvider>
<div className="flex min-h-screen"> <div className="flex min-h-screen">
<Sidebar /> <Sidebar />

View File

@ -1,8 +1,10 @@
"use client"; "use client";
import { useState, useEffect } from "react";
import { useState, useEffect, Suspense } from "react";
import { useSearchParams } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { authFetch, useAuth } from "@/lib/auth"; import { authFetch, useAuth } from "@/lib/auth";
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, ReferenceLine, Area, AreaChart } from "recharts"; import { XAxis, YAxis, Tooltip, ResponsiveContainer, ReferenceLine, Area, AreaChart } from "recharts";
// ─── 工具函数 ──────────────────────────────────────────────────── // ─── 工具函数 ────────────────────────────────────────────────────
@ -15,6 +17,54 @@ function fmtPrice(p: number) {
return p < 100 ? p.toFixed(4) : p.toLocaleString("en-US", { minimumFractionDigits: 1, maximumFractionDigits: 1 }); return p < 100 ? p.toFixed(4) : p.toLocaleString("en-US", { minimumFractionDigits: 1, maximumFractionDigits: 1 });
} }
function parseFactors(raw: any) {
if (!raw) return null;
if (typeof raw === "string") {
try {
return JSON.parse(raw);
} catch {
return null;
}
}
return raw;
}
type StrategyFilter = "all" | "v51_baseline" | "v52_8signals";
const STRATEGY_TABS: { value: StrategyFilter; label: string; hint: string }[] = [
{ value: "all", label: "全部", hint: "总览" },
{ value: "v51_baseline", label: "V5.1 模拟盘", hint: "经典五层" },
{ value: "v52_8signals", label: "V5.2 模拟盘", hint: "8信号 + FR/Liq" },
];
function normalizeStrategy(strategy: string | null | undefined): StrategyFilter {
if (strategy === "v52_8signals") return "v52_8signals";
if (strategy === "v51_baseline") return "v51_baseline";
return "v51_baseline";
}
function strategyName(strategy: string | null | undefined) {
const normalized = normalizeStrategy(strategy);
if (normalized === "v52_8signals") return "V5.2";
return "V5.1";
}
function strategyBadgeClass(strategy: string | null | undefined) {
return normalizeStrategy(strategy) === "v52_8signals"
? "bg-emerald-100 text-emerald-700 border border-emerald-200"
: "bg-slate-200 text-slate-700 border border-slate-300";
}
function strategyBadgeText(strategy: string | null | undefined) {
return normalizeStrategy(strategy) === "v52_8signals" ? "✨ V5.2" : "V5.1";
}
function strategyTabDescription(strategy: StrategyFilter) {
if (strategy === "all") return "全部策略合并视图";
if (strategy === "v52_8signals") return "仅展示 V5.2 数据(含 FR / Liq";
return "仅展示 V5.1 数据";
}
// ─── 控制面板(开关+配置)────────────────────────────────────── // ─── 控制面板(开关+配置)──────────────────────────────────────
function ControlPanel() { function ControlPanel() {
@ -22,7 +72,12 @@ function ControlPanel() {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
useEffect(() => { useEffect(() => {
const f = async () => { try { const r = await authFetch("/api/paper/config"); if (r.ok) setConfig(await r.json()); } catch {} }; const f = async () => {
try {
const r = await authFetch("/api/paper/config");
if (r.ok) setConfig(await r.json());
} catch {}
};
f(); f();
}, []); }, []);
@ -34,8 +89,11 @@ function ControlPanel() {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: !config.enabled }), body: JSON.stringify({ enabled: !config.enabled }),
}); });
if (r.ok) setConfig(await r.json().then(j => j.config)); if (r.ok) setConfig(await r.json().then((j) => j.config));
} catch {} finally { setSaving(false); } } catch {
} finally {
setSaving(false);
}
}; };
if (!config) return null; if (!config) return null;
@ -43,12 +101,13 @@ function ControlPanel() {
return ( return (
<div className={`rounded-xl border-2 ${config.enabled ? "border-emerald-400 bg-emerald-50" : "border-slate-200 bg-white"} px-3 py-2 flex items-center justify-between`}> <div className={`rounded-xl border-2 ${config.enabled ? "border-emerald-400 bg-emerald-50" : "border-slate-200 bg-white"} px-3 py-2 flex items-center justify-between`}>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button onClick={toggle} disabled={saving} <button
onClick={toggle}
disabled={saving}
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${ className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
config.enabled config.enabled ? "bg-red-500 text-white hover:bg-red-600" : "bg-emerald-500 text-white hover:bg-emerald-600"
? "bg-red-500 text-white hover:bg-red-600" }`}
: "bg-emerald-500 text-white hover:bg-emerald-600" >
}`}>
{saving ? "..." : config.enabled ? "⏹ 停止模拟盘" : "▶️ 启动模拟盘"} {saving ? "..." : config.enabled ? "⏹ 停止模拟盘" : "▶️ 启动模拟盘"}
</button> </button>
<span className={`text-xs font-medium ${config.enabled ? "text-emerald-700" : "text-slate-500"}`}> <span className={`text-xs font-medium ${config.enabled ? "text-emerald-700" : "text-slate-500"}`}>
@ -66,23 +125,40 @@ function ControlPanel() {
// ─── 总览面板 ──────────────────────────────────────────────────── // ─── 总览面板 ────────────────────────────────────────────────────
function SummaryCards() { function SummaryCards({ strategy }: { strategy: StrategyFilter }) {
const [data, setData] = useState<any>(null); const [data, setData] = useState<any>(null);
useEffect(() => { useEffect(() => {
const f = async () => { try { const r = await authFetch("/api/paper/summary"); if (r.ok) setData(await r.json()); } catch {} }; const f = async () => {
f(); const iv = setInterval(f, 10000); return () => clearInterval(iv); try {
}, []); const r = await authFetch(`/api/paper/summary?strategy=${strategy}`);
if (r.ok) setData(await r.json());
} catch {}
};
f();
const iv = setInterval(f, 10000);
return () => clearInterval(iv);
}, [strategy]);
if (!data) return <div className="text-center text-slate-400 text-sm py-4">...</div>; if (!data) return <div className="text-center text-slate-400 text-sm py-4">...</div>;
return ( return (
<div className="grid grid-cols-3 lg:grid-cols-7 gap-1.5"> <div className="grid grid-cols-3 lg:grid-cols-7 gap-1.5">
<div className="bg-white rounded-lg border border-slate-200 px-2.5 py-2"> <div className="bg-white rounded-lg border border-slate-200 px-2.5 py-2">
<p className="text-[10px] text-slate-400"></p> <p className="text-[10px] text-slate-400"></p>
<p className={`font-mono font-bold text-base ${data.balance >= 10000 ? "text-emerald-600" : "text-red-500"}`}>${data.balance?.toLocaleString()}</p> <p className={`font-mono font-bold text-base ${data.balance >= 10000 ? "text-emerald-600" : "text-red-500"}`}>
${data.balance?.toLocaleString()}
</p>
</div> </div>
<div className="bg-white rounded-lg border border-slate-200 px-2.5 py-2"> <div className="bg-white rounded-lg border border-slate-200 px-2.5 py-2">
<p className="text-[10px] text-slate-400">(R)</p> <p className="text-[10px] text-slate-400">(R)</p>
<p className={`font-mono font-bold text-lg ${data.total_pnl >= 0 ? "text-emerald-600" : "text-red-500"}`}>{data.total_pnl >= 0 ? "+" : ""}{data.total_pnl}R</p> <p className={`font-mono font-bold text-lg ${data.total_pnl >= 0 ? "text-emerald-600" : "text-red-500"}`}>
<p className={`font-mono text-[10px] ${data.total_pnl_usdt >= 0 ? "text-emerald-500" : "text-red-400"}`}>{data.total_pnl_usdt >= 0 ? "+" : ""}${data.total_pnl_usdt}</p> {data.total_pnl >= 0 ? "+" : ""}
{data.total_pnl}R
</p>
<p className={`font-mono text-[10px] ${data.total_pnl_usdt >= 0 ? "text-emerald-500" : "text-red-400"}`}>
{data.total_pnl_usdt >= 0 ? "+" : ""}${data.total_pnl_usdt}
</p>
</div> </div>
<div className="bg-white rounded-lg border border-slate-200 px-2.5 py-2"> <div className="bg-white rounded-lg border border-slate-200 px-2.5 py-2">
<p className="text-[10px] text-slate-400"></p> <p className="text-[10px] text-slate-400"></p>
@ -118,17 +194,19 @@ function LatestSignals() {
const f = async () => { const f = async () => {
for (const sym of COINS) { for (const sym of COINS) {
try { 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) { if (r.ok) {
const j = await r.json(); const j = await r.json();
if (j.data && j.data.length > 0) { if (j.data && j.data.length > 0) {
setSignals(prev => ({ ...prev, [sym]: j.data[0] })); setSignals((prev) => ({ ...prev, [sym]: j.data[0] }));
} }
} }
} catch {} } catch {}
} }
}; };
f(); const iv = setInterval(f, 15000); return () => clearInterval(iv); f();
const iv = setInterval(f, 15000);
return () => clearInterval(iv);
}, []); }, []);
return ( return (
@ -137,7 +215,7 @@ function LatestSignals() {
<h3 className="font-semibold text-slate-800 text-xs"></h3> <h3 className="font-semibold text-slate-800 text-xs"></h3>
</div> </div>
<div className="divide-y divide-slate-50"> <div className="divide-y divide-slate-50">
{COINS.map(sym => { {COINS.map((sym) => {
const s = signals[sym]; const s = signals[sym];
const coin = sym.replace("USDT", ""); const coin = sym.replace("USDT", "");
const ago = s?.ts ? Math.round((Date.now() - s.ts) / 60000) : null; const ago = s?.ts ? Math.round((Date.now() - s.ts) / 60000) : null;
@ -156,7 +234,7 @@ function LatestSignals() {
<span className="text-[10px] text-slate-400"> </span> <span className="text-[10px] text-slate-400"> </span>
)} )}
</div> </div>
{ago !== null && <span className="text-[10px] text-slate-400">{ago < 60 ? `${ago}m前` : `${Math.round(ago/60)}h前`}</span>} {ago !== null && <span className="text-[10px] text-slate-400">{ago < 60 ? `${ago}m前` : `${Math.round(ago / 60)}h前`}</span>}
</div> </div>
); );
})} })}
@ -167,19 +245,27 @@ function LatestSignals() {
// ─── 当前持仓 ──────────────────────────────────────────────────── // ─── 当前持仓 ────────────────────────────────────────────────────
function ActivePositions() { function ActivePositions({ strategy }: { strategy: StrategyFilter }) {
const [positions, setPositions] = useState<any[]>([]); const [positions, setPositions] = useState<any[]>([]);
const [wsPrices, setWsPrices] = useState<Record<string, number>>({}); const [wsPrices, setWsPrices] = useState<Record<string, number>>({});
// 从API获取持仓列表10秒刷新
useEffect(() => { useEffect(() => {
const f = async () => { try { const r = await authFetch("/api/paper/positions"); if (r.ok) { const j = await r.json(); setPositions(j.data || []); } } catch {} }; const f = async () => {
f(); const iv = setInterval(f, 10000); return () => clearInterval(iv); try {
}, []); const r = await authFetch(`/api/paper/positions?strategy=${strategy}`);
if (r.ok) {
const j = await r.json();
setPositions(j.data || []);
}
} catch {}
};
f();
const iv = setInterval(f, 10000);
return () => clearInterval(iv);
}, [strategy]);
// WebSocket实时价格aggTrade逐笔成交
useEffect(() => { useEffect(() => {
const streams = ["btcusdt", "ethusdt", "xrpusdt", "solusdt"].map(s => `${s}@aggTrade`).join("/"); const streams = ["btcusdt", "ethusdt", "xrpusdt", "solusdt"].map((s) => `${s}@aggTrade`).join("/");
const ws = new WebSocket(`wss://fstream.binance.com/stream?streams=${streams}`); const ws = new WebSocket(`wss://fstream.binance.com/stream?streams=${streams}`);
ws.onmessage = (e) => { ws.onmessage = (e) => {
try { try {
@ -187,49 +273,62 @@ function ActivePositions() {
if (msg.data) { if (msg.data) {
const sym = msg.data.s; const sym = msg.data.s;
const price = parseFloat(msg.data.p); const price = parseFloat(msg.data.p);
if (sym && price > 0) setWsPrices(prev => ({ ...prev, [sym]: price })); if (sym && price > 0) setWsPrices((prev) => ({ ...prev, [sym]: price }));
} }
} catch {} } catch {}
}; };
return () => ws.close(); return () => ws.close();
}, []); }, []);
if (positions.length === 0) return ( if (positions.length === 0)
return (
<div className="rounded-xl border border-slate-200 bg-white px-3 py-4 text-center text-slate-400 text-sm"> <div className="rounded-xl border border-slate-200 bg-white px-3 py-4 text-center text-slate-400 text-sm">
{strategy === "all" ? "暂无活跃持仓" : `${strategyName(strategy)} 暂无活跃持仓`}
</div> </div>
); );
return ( return (
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden"> <div className="rounded-xl border border-slate-200 bg-white overflow-hidden">
<div className="px-3 py-2 border-b border-slate-100"> <div className="px-3 py-2 border-b border-slate-100">
<h3 className="font-semibold text-slate-800 text-xs"> <span className="text-[10px] text-emerald-500 font-normal"> </span></h3> <h3 className="font-semibold text-slate-800 text-xs">
<span className="text-[10px] text-emerald-500 font-normal"> </span>
</h3>
</div> </div>
<div className="divide-y divide-slate-100"> <div className="divide-y divide-slate-100">
{positions.map((p: any) => { {positions.map((p: any) => {
const sym = p.symbol?.replace("USDT", "") || ""; const sym = p.symbol?.replace("USDT", "") || "";
const holdMin = Math.round((Date.now() - p.entry_ts) / 60000); const holdMin = Math.round((Date.now() - p.entry_ts) / 60000);
const currentPrice = wsPrices[p.symbol] || p.current_price || 0; const currentPrice = wsPrices[p.symbol] || p.current_price || 0;
const factors = parseFactors(p.score_factors);
const frScore = factors?.funding_rate?.score ?? 0;
const liqScore = factors?.liquidation?.score ?? 0;
const entry = p.entry_price || 0; const entry = p.entry_price || 0;
const atr = p.atr_at_entry || 1; const atr = p.atr_at_entry || 1;
const riskDist = 2.0 * 0.7 * atr; const riskDist = 2.0 * 0.7 * atr;
// TP1触发后只剩半仓0.5×TP1锁定 + 0.5×当前浮盈
const fullR = riskDist > 0 ? (p.direction === "LONG" ? (currentPrice - entry) / riskDist : (entry - currentPrice) / riskDist) : 0; const fullR = riskDist > 0 ? (p.direction === "LONG" ? (currentPrice - entry) / riskDist : (entry - currentPrice) / riskDist) : 0;
const tp1R = riskDist > 0 ? (p.direction === "LONG" ? ((p.tp1_price || 0) - entry) / riskDist : (entry - (p.tp1_price || 0)) / riskDist) : 0; const tp1R = riskDist > 0 ? (p.direction === "LONG" ? ((p.tp1_price || 0) - entry) / riskDist : (entry - (p.tp1_price || 0)) / riskDist) : 0;
const unrealR = p.tp1_hit ? 0.5 * tp1R + 0.5 * fullR : fullR; const unrealR = p.tp1_hit ? 0.5 * tp1R + 0.5 * fullR : fullR;
const unrealUsdt = unrealR * 200; const unrealUsdt = unrealR * 200;
const isV52 = normalizeStrategy(p.strategy) === "v52_8signals";
return ( return (
<div key={p.id} className="px-3 py-2"> <div key={p.id} className={`px-3 py-2 ${isV52 ? "bg-emerald-50/60" : "bg-slate-50/70"}`}>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 flex-wrap">
<span className={`text-xs font-bold ${p.direction === "LONG" ? "text-emerald-600" : "text-red-500"}`}> <span className={`text-xs font-bold ${p.direction === "LONG" ? "text-emerald-600" : "text-red-500"}`}>
{p.direction === "LONG" ? "🟢" : "🔴"} {sym} {p.direction} {p.direction === "LONG" ? "🟢" : "🔴"} {sym} {p.direction}
</span> </span>
<span className="text-[10px] text-slate-400">{p.score} · {p.tier === "heavy" ? "加仓" : p.tier === "standard" ? "标准" : "轻仓"}</span> <span className={`px-1.5 py-0.5 rounded text-[10px] font-semibold ${strategyBadgeClass(p.strategy)}`}>
{strategyBadgeText(p.strategy)}
</span>
<span className="text-[10px] text-slate-500">{p.score} · {p.tier === "heavy" ? "加仓" : p.tier === "standard" ? "标准" : "轻仓"}</span>
{isV52 && (
<span className="text-[10px] font-semibold text-emerald-700">FR {frScore >= 0 ? "+" : ""}{frScore} · Liq {liqScore >= 0 ? "+" : ""}{liqScore}</span>
)}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className={`font-mono text-sm font-bold ${unrealR >= 0 ? "text-emerald-600" : "text-red-500"}`}> <span className={`font-mono text-sm font-bold ${unrealR >= 0 ? "text-emerald-600" : "text-red-500"}`}>
{unrealR >= 0 ? "+" : ""}{unrealR.toFixed(2)}R {unrealR >= 0 ? "+" : ""}
{unrealR.toFixed(2)}R
</span> </span>
<span className={`font-mono text-[10px] ${unrealUsdt >= 0 ? "text-emerald-500" : "text-red-400"}`}> <span className={`font-mono text-[10px] ${unrealUsdt >= 0 ? "text-emerald-500" : "text-red-400"}`}>
({unrealUsdt >= 0 ? "+" : ""}${unrealUsdt.toFixed(0)}) ({unrealUsdt >= 0 ? "+" : ""}${unrealUsdt.toFixed(0)})
@ -237,13 +336,20 @@ function ActivePositions() {
<span className="text-[10px] text-slate-400">{holdMin}m</span> <span className="text-[10px] text-slate-400">{holdMin}m</span>
</div> </div>
</div> </div>
<div className="flex gap-3 mt-1 text-[10px] font-mono text-slate-600"> <div className="flex gap-3 mt-1 text-[10px] font-mono text-slate-600 flex-wrap">
<span>入场: ${fmtPrice(p.entry_price)}</span> <span>入场: ${fmtPrice(p.entry_price)}</span>
<span className="text-blue-600">现价: ${currentPrice ? fmtPrice(currentPrice) : "-"}</span> <span className="text-blue-600">现价: ${currentPrice ? fmtPrice(currentPrice) : "-"}</span>
<span className="text-emerald-600">TP1: ${fmtPrice(p.tp1_price)}{p.tp1_hit ? " ✅" : ""}</span> <span className="text-emerald-600">TP1: ${fmtPrice(p.tp1_price)}{p.tp1_hit ? " ✅" : ""}</span>
<span className="text-emerald-600">TP2: ${fmtPrice(p.tp2_price)}</span> <span className="text-emerald-600">TP2: ${fmtPrice(p.tp2_price)}</span>
<span className="text-red-500">SL: ${fmtPrice(p.sl_price)}</span> <span className="text-red-500">SL: ${fmtPrice(p.sl_price)}</span>
{!isV52 && <span className="text-slate-400">FR/Liq V5.2 </span>}
</div> </div>
{isV52 && (
<div className="mt-1 grid grid-cols-2 gap-2 text-[10px] font-semibold">
<div className="rounded-md bg-emerald-100/70 text-emerald-800 px-2 py-1"> Funding Rate Score: {frScore >= 0 ? "+" : ""}{frScore}</div>
<div className="rounded-md bg-cyan-100/70 text-cyan-800 px-2 py-1"> Liquidation Score: {liqScore >= 0 ? "+" : ""}{liqScore}</div>
</div>
)}
</div> </div>
); );
})} })}
@ -254,20 +360,32 @@ function ActivePositions() {
// ─── 权益曲线 ──────────────────────────────────────────────────── // ─── 权益曲线 ────────────────────────────────────────────────────
function EquityCurve() { function EquityCurve({ strategy }: { strategy: StrategyFilter }) {
const [data, setData] = useState<any[]>([]); const [data, setData] = useState<any[]>([]);
useEffect(() => {
const f = async () => { try { const r = await authFetch("/api/paper/equity-curve"); if (r.ok) { const j = await r.json(); setData(j.data || []); } } catch {} };
f(); const iv = setInterval(f, 30000); return () => clearInterval(iv);
}, []);
if (data.length < 2) return null; useEffect(() => {
const f = async () => {
try {
const r = await authFetch(`/api/paper/equity-curve?strategy=${strategy}`);
if (r.ok) {
const j = await r.json();
setData(j.data || []);
}
} catch {}
};
f();
const iv = setInterval(f, 30000);
return () => clearInterval(iv);
}, [strategy]);
return ( return (
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden"> <div className="rounded-xl border border-slate-200 bg-white overflow-hidden">
<div className="px-3 py-2 border-b border-slate-100"> <div className="px-3 py-2 border-b border-slate-100">
<h3 className="font-semibold text-slate-800 text-xs">线 (PnL)</h3> <h3 className="font-semibold text-slate-800 text-xs">线 (PnL)</h3>
</div> </div>
{data.length < 2 ? (
<div className="px-3 py-6 text-center text-xs text-slate-400">{strategy === "all" ? "暂无足够历史数据" : `${strategyName(strategy)} 暂无足够历史数据`}</div>
) : (
<div className="p-2" style={{ height: 200 }}> <div className="p-2" style={{ height: 200 }}>
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<AreaChart data={data}> <AreaChart data={data}>
@ -279,6 +397,7 @@ function EquityCurve() {
</AreaChart> </AreaChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
)}
</div> </div>
); );
} }
@ -288,7 +407,7 @@ function EquityCurve() {
type FilterSymbol = "all" | "BTC" | "ETH" | "XRP" | "SOL"; type FilterSymbol = "all" | "BTC" | "ETH" | "XRP" | "SOL";
type FilterResult = "all" | "win" | "loss"; type FilterResult = "all" | "win" | "loss";
function TradeHistory() { function TradeHistory({ strategy }: { strategy: StrategyFilter }) {
const [trades, setTrades] = useState<any[]>([]); const [trades, setTrades] = useState<any[]>([]);
const [symbol, setSymbol] = useState<FilterSymbol>("all"); const [symbol, setSymbol] = useState<FilterSymbol>("all");
const [result, setResult] = useState<FilterResult>("all"); const [result, setResult] = useState<FilterResult>("all");
@ -296,28 +415,43 @@ function TradeHistory() {
useEffect(() => { useEffect(() => {
const f = async () => { const f = async () => {
try { try {
const r = await authFetch(`/api/paper/trades?symbol=${symbol}&result=${result}&limit=50`); const r = await authFetch(`/api/paper/trades?symbol=${symbol}&result=${result}&strategy=${strategy}&limit=50`);
if (r.ok) { const j = await r.json(); setTrades(j.data || []); } if (r.ok) {
const j = await r.json();
setTrades(j.data || []);
}
} catch {} } catch {}
}; };
f(); const iv = setInterval(f, 10000); return () => clearInterval(iv); f();
}, [symbol, result]); const iv = setInterval(f, 10000);
return () => clearInterval(iv);
}, [symbol, result, strategy]);
return ( return (
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden"> <div className="rounded-xl border border-slate-200 bg-white overflow-hidden">
<div className="px-3 py-2 border-b border-slate-100 flex items-center justify-between flex-wrap gap-1"> <div className="px-3 py-2 border-b border-slate-100 flex items-center justify-between flex-wrap gap-1">
<h3 className="font-semibold text-slate-800 text-xs"></h3> <h3 className="font-semibold text-slate-800 text-xs"></h3>
<div className="flex gap-1"> <div className="flex items-center gap-1 flex-wrap">
{(["all", "BTC", "ETH", "XRP", "SOL"] as FilterSymbol[]).map(s => ( <span className={`px-2 py-0.5 rounded text-[10px] font-semibold ${strategy === "all" ? "bg-slate-100 text-slate-600" : strategyBadgeClass(strategy)}`}>
<button key={s} onClick={() => setSymbol(s)} {strategy === "all" ? "全部策略" : `${strategyBadgeText(strategy)} 视图`}
className={`px-2 py-0.5 rounded text-[10px] ${symbol === s ? "bg-slate-800 text-white" : "text-slate-500 hover:bg-slate-100"}`}> </span>
<span className="text-slate-300">|</span>
{(["all", "BTC", "ETH", "XRP", "SOL"] as FilterSymbol[]).map((s) => (
<button
key={s}
onClick={() => setSymbol(s)}
className={`px-2 py-0.5 rounded text-[10px] ${symbol === s ? "bg-slate-800 text-white" : "text-slate-500 hover:bg-slate-100"}`}
>
{s === "all" ? "全部" : s} {s === "all" ? "全部" : s}
</button> </button>
))} ))}
<span className="text-slate-300">|</span> <span className="text-slate-300">|</span>
{(["all", "win", "loss"] as FilterResult[]).map(r => ( {(["all", "win", "loss"] as FilterResult[]).map((r) => (
<button key={r} onClick={() => setResult(r)} <button
className={`px-2 py-0.5 rounded text-[10px] ${result === r ? "bg-slate-800 text-white" : "text-slate-500 hover:bg-slate-100"}`}> key={r}
onClick={() => setResult(r)}
className={`px-2 py-0.5 rounded text-[10px] ${result === r ? "bg-slate-800 text-white" : "text-slate-500 hover:bg-slate-100"}`}
>
{r === "all" ? "全部" : r === "win" ? "盈利" : "亏损"} {r === "all" ? "全部" : r === "win" ? "盈利" : "亏损"}
</button> </button>
))} ))}
@ -331,6 +465,7 @@ function TradeHistory() {
<thead className="bg-slate-50 sticky top-0"> <thead className="bg-slate-50 sticky top-0">
<tr className="text-slate-500"> <tr className="text-slate-500">
<th className="px-2 py-1.5 text-left font-medium"></th> <th className="px-2 py-1.5 text-left font-medium"></th>
<th className="px-2 py-1.5 text-left font-medium"></th>
<th className="px-2 py-1.5 text-left font-medium"></th> <th className="px-2 py-1.5 text-left font-medium"></th>
<th className="px-2 py-1.5 text-right font-medium"></th> <th className="px-2 py-1.5 text-right font-medium"></th>
<th className="px-2 py-1.5 text-right font-medium"></th> <th className="px-2 py-1.5 text-right font-medium"></th>
@ -343,29 +478,58 @@ function TradeHistory() {
<tbody className="divide-y divide-slate-50"> <tbody className="divide-y divide-slate-50">
{trades.map((t: any) => { {trades.map((t: any) => {
const holdMin = t.exit_ts && t.entry_ts ? Math.round((t.exit_ts - t.entry_ts) / 60000) : 0; const holdMin = t.exit_ts && t.entry_ts ? Math.round((t.exit_ts - t.entry_ts) / 60000) : 0;
const factors = parseFactors(t.score_factors);
const frScore = factors?.funding_rate?.score ?? 0;
const liqScore = factors?.liquidation?.score ?? 0;
const isV52 = normalizeStrategy(t.strategy) === "v52_8signals";
return ( return (
<tr key={t.id} className="hover:bg-slate-50"> <tr key={t.id} className="hover:bg-slate-50">
<td className="px-2 py-1.5 font-mono">{t.symbol?.replace("USDT", "")}</td> <td className="px-2 py-1.5 font-mono">{t.symbol?.replace("USDT", "")}</td>
<td className="px-2 py-1.5 text-[10px]">
<span className={`px-1.5 py-0.5 rounded font-semibold ${strategyBadgeClass(t.strategy)}`}>{strategyBadgeText(t.strategy)}</span>
</td>
<td className={`px-2 py-1.5 font-bold ${t.direction === "LONG" ? "text-emerald-600" : "text-red-500"}`}> <td className={`px-2 py-1.5 font-bold ${t.direction === "LONG" ? "text-emerald-600" : "text-red-500"}`}>
{t.direction === "LONG" ? "🟢" : "🔴"} {t.direction} {t.direction === "LONG" ? "🟢" : "🔴"} {t.direction}
</td> </td>
<td className="px-2 py-1.5 text-right font-mono">{fmtPrice(t.entry_price)}</td> <td className="px-2 py-1.5 text-right font-mono">{fmtPrice(t.entry_price)}</td>
<td className="px-2 py-1.5 text-right font-mono">{t.exit_price ? fmtPrice(t.exit_price) : "-"}</td> <td className="px-2 py-1.5 text-right font-mono">{t.exit_price ? fmtPrice(t.exit_price) : "-"}</td>
<td className={`px-2 py-1.5 text-right font-mono font-bold ${t.pnl_r > 0 ? "text-emerald-600" : t.pnl_r < 0 ? "text-red-500" : "text-slate-500"}`}> <td className={`px-2 py-1.5 text-right font-mono font-bold ${t.pnl_r > 0 ? "text-emerald-600" : t.pnl_r < 0 ? "text-red-500" : "text-slate-500"}`}>
{t.pnl_r > 0 ? "+" : ""}{t.pnl_r?.toFixed(2)} {t.pnl_r > 0 ? "+" : ""}
{t.pnl_r?.toFixed(2)}
</td> </td>
<td className="px-2 py-1.5 text-center"> <td className="px-2 py-1.5 text-center">
<span className={`px-1 py-0.5 rounded text-[9px] ${ <span
t.status === "tp" ? "bg-emerald-100 text-emerald-700" : className={`px-1 py-0.5 rounded text-[9px] ${
t.status === "sl" ? "bg-red-100 text-red-700" : t.status === "tp"
t.status === "sl_be" ? "bg-amber-100 text-amber-700" : ? "bg-emerald-100 text-emerald-700"
t.status === "signal_flip" ? "bg-purple-100 text-purple-700" : : t.status === "sl"
"bg-slate-100 text-slate-600" ? "bg-red-100 text-red-700"
}`}> : t.status === "sl_be"
{t.status === "tp" ? "止盈" : t.status === "sl" ? "止损" : t.status === "sl_be" ? "保本" : t.status === "timeout" ? "超时" : t.status === "signal_flip" ? "翻转" : t.status} ? "bg-amber-100 text-amber-700"
: t.status === "signal_flip"
? "bg-purple-100 text-purple-700"
: "bg-slate-100 text-slate-600"
}`}
>
{t.status === "tp"
? "止盈"
: t.status === "sl"
? "止损"
: t.status === "sl_be"
? "保本"
: t.status === "timeout"
? "超时"
: t.status === "signal_flip"
? "翻转"
: t.status}
</span> </span>
</td> </td>
<td className="px-2 py-1.5 text-right font-mono">{t.score}</td> <td className="px-2 py-1.5 text-right font-mono">
<div>{t.score}</div>
<div className={`text-[9px] ${isV52 ? "text-emerald-600 font-semibold" : "text-slate-400"}`}>
{isV52 ? `✨ FR ${frScore >= 0 ? "+" : ""}${frScore} · Liq ${liqScore >= 0 ? "+" : ""}${liqScore}` : "FR/Liq 仅V5.2"}
</div>
</td>
<td className="px-2 py-1.5 text-right text-slate-400">{holdMin}m</td> <td className="px-2 py-1.5 text-right text-slate-400">{holdMin}m</td>
</tr> </tr>
); );
@ -380,15 +544,36 @@ function TradeHistory() {
// ─── 统计面板 ──────────────────────────────────────────────────── // ─── 统计面板 ────────────────────────────────────────────────────
function StatsPanel() { function StatsPanel({ strategy }: { strategy: StrategyFilter }) {
const [data, setData] = useState<any>(null); const [data, setData] = useState<any>(null);
const [tab, setTab] = useState("ALL"); const [tab, setTab] = useState("ALL");
useEffect(() => {
const f = async () => { try { const r = await authFetch("/api/paper/stats"); if (r.ok) setData(await r.json()); } catch {} };
f(); const iv = setInterval(f, 30000); return () => clearInterval(iv);
}, []);
if (!data || data.error) return null; useEffect(() => {
const f = async () => {
try {
const r = await authFetch(`/api/paper/stats?strategy=${strategy}`);
if (r.ok) setData(await r.json());
} catch {}
};
f();
const iv = setInterval(f, 30000);
return () => clearInterval(iv);
}, [strategy]);
useEffect(() => {
setTab("ALL");
}, [strategy]);
if (!data || data.error) {
return (
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden">
<div className="px-3 py-2 border-b border-slate-100">
<h3 className="font-semibold text-slate-800 text-xs"></h3>
</div>
<div className="p-3 text-xs text-slate-400"></div>
</div>
);
}
const tabs = ["ALL", "BTC", "ETH", "XRP", "SOL"]; const tabs = ["ALL", "BTC", "ETH", "XRP", "SOL"];
const st = tab === "ALL" ? data : (data.by_symbol?.[tab] || null); const st = tab === "ALL" ? data : (data.by_symbol?.[tab] || null);
@ -397,30 +582,73 @@ function StatsPanel() {
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden"> <div className="rounded-xl border border-slate-200 bg-white overflow-hidden">
<div className="px-3 py-2 border-b border-slate-100 flex items-center justify-between"> <div className="px-3 py-2 border-b border-slate-100 flex items-center justify-between">
<h3 className="font-semibold text-slate-800 text-xs"></h3> <h3 className="font-semibold text-slate-800 text-xs"></h3>
<div className="flex gap-1"> <div className="flex items-center gap-1">
{tabs.map(t => ( {strategy !== "all" && <span className={`px-2 py-0.5 rounded text-[10px] font-semibold ${strategyBadgeClass(strategy)}`}>{strategyBadgeText(strategy)}</span>}
<button key={t} onClick={() => setTab(t)} {tabs.map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`px-2 py-0.5 rounded text-[10px] font-medium transition-colors ${tab === t ? "bg-slate-800 text-white" : "bg-slate-100 text-slate-500 hover:bg-slate-200"}`} className={`px-2 py-0.5 rounded text-[10px] font-medium transition-colors ${tab === t ? "bg-slate-800 text-white" : "bg-slate-100 text-slate-500 hover:bg-slate-200"}`}
>{t === "ALL" ? "总计" : t}</button> >
{t === "ALL" ? "总计" : t}
</button>
))} ))}
</div> </div>
</div> </div>
{st ? ( {st ? (
<div className="p-3 grid grid-cols-2 lg:grid-cols-4 gap-2 text-xs"> <div className="p-3 space-y-3">
<div><span className="text-slate-400"></span><p className="font-mono font-bold">{st.win_rate}%</p></div> <div className="grid grid-cols-2 lg:grid-cols-4 gap-2 text-xs">
<div><span className="text-slate-400"></span><p className="font-mono font-bold">{st.win_loss_ratio}</p></div> <div>
<div><span className="text-slate-400"></span><p className="font-mono font-bold text-emerald-600">+{st.avg_win}R</p></div> <span className="text-slate-400"></span>
<div><span className="text-slate-400"></span><p className="font-mono font-bold text-red-500">-{st.avg_loss}R</p></div> <p className="font-mono font-bold">{st.win_rate}%</p>
<div><span className="text-slate-400"></span><p className="font-mono font-bold">{st.mdd}R</p></div> </div>
<div><span className="text-slate-400"></span><p className="font-mono font-bold">{st.sharpe}</p></div> <div>
<div><span className="text-slate-400"></span><p className={`font-mono font-bold ${(st.total_pnl ?? 0) >= 0 ? "text-emerald-600" : "text-red-500"}`}>{(st.total_pnl ?? 0) >= 0 ? "+" : ""}{st.total_pnl ?? "-"}R</p></div> <span className="text-slate-400"></span>
<div><span className="text-slate-400"></span><p className="font-mono font-bold">{st.total ?? data.total}</p></div> <p className="font-mono font-bold">{st.win_loss_ratio}</p>
<div><span className="text-slate-400"></span><p className="font-mono">{st.long_win_rate}% ({st.long_count})</p></div> </div>
<div><span className="text-slate-400"></span><p className="font-mono">{st.short_win_rate}% ({st.short_count})</p></div> <div>
<span className="text-slate-400"></span>
<p className="font-mono font-bold text-emerald-600">+{st.avg_win}R</p>
</div>
<div>
<span className="text-slate-400"></span>
<p className="font-mono font-bold text-red-500">-{st.avg_loss}R</p>
</div>
<div>
<span className="text-slate-400"></span>
<p className="font-mono font-bold">{st.mdd}R</p>
</div>
<div>
<span className="text-slate-400"></span>
<p className="font-mono font-bold">{st.sharpe}</p>
</div>
<div>
<span className="text-slate-400"></span>
<p className={`font-mono font-bold ${(st.total_pnl ?? 0) >= 0 ? "text-emerald-600" : "text-red-500"}`}>
{(st.total_pnl ?? 0) >= 0 ? "+" : ""}
{st.total_pnl ?? "-"}R
</p>
</div>
<div>
<span className="text-slate-400"></span>
<p className="font-mono font-bold">{st.total ?? data.total}</p>
</div>
<div>
<span className="text-slate-400"></span>
<p className="font-mono">{st.long_win_rate}% ({st.long_count})</p>
</div>
<div>
<span className="text-slate-400"></span>
<p className="font-mono">{st.short_win_rate}% ({st.short_count})</p>
</div>
{tab === "ALL" && data.by_tier && Object.entries(data.by_tier).map(([t, v]: [string, any]) => ( {tab === "ALL" && data.by_tier && Object.entries(data.by_tier).map(([t, v]: [string, any]) => (
<div key={t}><span className="text-slate-400">{t === "heavy" ? "加仓档" : t === "standard" ? "标准档" : "轻仓档"}</span><p className="font-mono">{v.win_rate}% ({v.total})</p></div> <div key={t}>
<span className="text-slate-400">{t === "heavy" ? "加仓档" : t === "standard" ? "标准档" : "轻仓档"}</span>
<p className="font-mono">{v.win_rate}% ({v.total})</p>
</div>
))} ))}
</div> </div>
</div>
) : ( ) : (
<div className="p-3 text-xs text-slate-400"></div> <div className="p-3 text-xs text-slate-400"></div>
)} )}
@ -430,33 +658,74 @@ function StatsPanel() {
// ─── 主页面 ────────────────────────────────────────────────────── // ─── 主页面 ──────────────────────────────────────────────────────
export default function PaperTradingPage() { function PaperTradingPageInner() {
const { isLoggedIn, loading } = useAuth(); const { isLoggedIn, loading } = useAuth();
const searchParams = useSearchParams();
const urlStrategy = searchParams.get("strategy");
const [strategyTab, setStrategyTab] = useState<StrategyFilter>(() => normalizeStrategy(urlStrategy));
// URL参数变化时同步
useEffect(() => {
if (urlStrategy) {
setStrategyTab(normalizeStrategy(urlStrategy));
}
}, [urlStrategy]);
if (loading) return <div className="text-center text-slate-400 py-8">...</div>; if (loading) return <div className="text-center text-slate-400 py-8">...</div>;
if (!isLoggedIn) return ( if (!isLoggedIn)
return (
<div className="flex flex-col items-center justify-center h-64 gap-4"> <div className="flex flex-col items-center justify-center h-64 gap-4">
<div className="text-5xl">🔒</div> <div className="text-5xl">🔒</div>
<p className="text-slate-600 font-medium"></p> <p className="text-slate-600 font-medium"></p>
<Link href="/login" className="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm"></Link> <Link href="/login" className="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm">
</Link>
</div> </div>
); );
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="rounded-2xl border-2 border-slate-200 bg-white p-2.5 shadow-sm">
<p className="text-[11px] font-semibold text-slate-500 mb-2"></p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
{STRATEGY_TABS.map((tab) => (
<button
key={tab.value}
onClick={() => setStrategyTab(tab.value)}
className={`rounded-xl border-2 px-4 py-3 text-left transition-all ${
strategyTab === tab.value
? "border-slate-800 bg-slate-800 text-white shadow"
: "border-slate-100 bg-slate-100 text-slate-600 hover:bg-slate-200"
}`}
>
<p className="text-sm font-bold">{tab.label}</p>
<p className={`text-[10px] mt-0.5 ${strategyTab === tab.value ? "text-slate-200" : "text-slate-500"}`}>{tab.hint}</p>
</button>
))}
</div>
</div>
<div> <div>
<h1 className="text-lg font-bold text-slate-900">📊 </h1> <h1 className="text-lg font-bold text-slate-900">📊 </h1>
<p className="text-[10px] text-slate-500">V5.1 · · </p> <p className="text-[10px] text-slate-500">V5.2AB测试 · · · {strategyTabDescription(strategyTab)}</p>
</div> </div>
<ControlPanel /> <ControlPanel />
<SummaryCards /> <SummaryCards strategy={strategyTab} />
<LatestSignals /> <LatestSignals />
<ActivePositions /> <ActivePositions strategy={strategyTab} />
<EquityCurve /> <EquityCurve strategy={strategyTab} />
<TradeHistory /> <TradeHistory strategy={strategyTab} />
<StatsPanel /> <StatsPanel strategy={strategyTab} />
</div> </div>
); );
} }
export default function PaperTradingPage() {
return (
<Suspense fallback={<div className="text-center text-slate-400 py-8">...</div>}>
<PaperTradingPageInner />
</Suspense>
);
}

View File

@ -47,6 +47,23 @@ interface LatestIndicator {
} | null; } | null;
} }
interface StrategyScoreSnapshot {
score: number | null;
signal: string | null;
ts: number | null;
source?: string;
funding_rate_score?: number | null;
liquidation_score?: number | null;
}
interface StrategyLatestRow {
primary_strategy?: "v51_baseline" | "v52_8signals";
latest_signal?: string | null;
latest_ts?: number | null;
v51?: StrategyScoreSnapshot;
v52?: StrategyScoreSnapshot;
}
interface MarketIndicatorValue { interface MarketIndicatorValue {
value: Record<string, unknown>; value: Record<string, unknown>;
ts: number; ts: number;
@ -81,6 +98,14 @@ function pct(v: number, digits = 1): string {
return `${(v * 100).toFixed(digits)}%`; return `${(v * 100).toFixed(digits)}%`;
} }
function agoLabel(ts: number | null | undefined): string {
if (!ts) return "--";
const minutes = Math.round((Date.now() - ts) / 60000);
if (minutes < 1) return "刚刚";
if (minutes < 60) return `${minutes}m前`;
return `${Math.round(minutes / 60)}h前`;
}
function LayerScore({ label, score, max, colorClass }: { label: string; score: number; max: number; colorClass: string }) { function LayerScore({ label, score, max, colorClass }: { label: string; score: number; max: number; colorClass: string }) {
const ratio = Math.max(0, Math.min((score / max) * 100, 100)); const ratio = Math.max(0, Math.min((score / max) * 100, 100));
return ( return (
@ -94,6 +119,73 @@ function LayerScore({ label, score, max, colorClass }: { label: string; score: n
); );
} }
function LatestStrategyComparison() {
const [rows, setRows] = useState<Record<Symbol, StrategyLatestRow | undefined>>({
BTC: undefined,
ETH: undefined,
XRP: undefined,
SOL: undefined,
});
useEffect(() => {
const fetch = async () => {
try {
const res = await authFetch("/api/signals/latest-v52");
if (!res.ok) return;
const json = await res.json();
setRows({
BTC: json.BTC,
ETH: json.ETH,
XRP: json.XRP,
SOL: json.SOL,
});
} catch {}
};
fetch();
const iv = setInterval(fetch, 10000);
return () => clearInterval(iv);
}, []);
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm overflow-hidden">
<div className="px-3 py-2 border-b border-slate-100">
<h3 className="font-semibold text-slate-800 text-xs">V5.1 vs V5.2</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2 p-2">
{(["BTC", "ETH", "XRP", "SOL"] as Symbol[]).map((sym) => {
const row = rows[sym];
const latestSignal = row?.latest_signal;
const v51 = row?.v51;
const v52 = row?.v52;
const v52Fr = v52?.funding_rate_score;
const v52Liq = v52?.liquidation_score;
return (
<div key={sym} className="rounded-lg border border-slate-200 bg-slate-50 px-2.5 py-2">
<div className="flex items-center justify-between">
<p className="font-mono text-sm font-bold text-slate-800">{sym}</p>
<span className="text-[10px] text-slate-400">{agoLabel(row?.latest_ts ?? null)}</span>
</div>
<p className={`text-[11px] mt-0.5 font-semibold ${latestSignal === "LONG" ? "text-emerald-600" : latestSignal === "SHORT" ? "text-red-500" : "text-slate-400"}`}>
{latestSignal === "LONG" ? "🟢 LONG" : latestSignal === "SHORT" ? "🔴 SHORT" : "⚪ 无信号"}
</p>
<div className="mt-1 text-[11px] text-slate-700 flex items-center gap-1.5 flex-wrap">
<span className="rounded bg-slate-200 text-slate-700 px-1.5 py-0.5 font-mono">V5.1: {v51?.score ?? "--"}</span>
<span className="rounded bg-emerald-100 text-emerald-700 px-1.5 py-0.5 font-mono"> V5.2: {v52?.score ?? "--"}</span>
</div>
<div className="mt-1 text-[10px] text-slate-500">
{v52Fr === null || v52Fr === undefined ? "FR --" : `FR ${v52Fr >= 0 ? "+" : ""}${v52Fr}`} · {v52Liq === null || v52Liq === undefined ? "Liq --" : `Liq ${v52Liq >= 0 ? "+" : ""}${v52Liq}`}
</div>
<div className="mt-1 text-[9px] text-slate-400">
来源: V5.1 {v51?.source || "--"} | V5.2 {v52?.source || "--"}
</div>
</div>
);
})}
</div>
</div>
);
}
function MarketIndicatorsCards({ symbol }: { symbol: Symbol }) { function MarketIndicatorsCards({ symbol }: { symbol: Symbol }) {
const [data, setData] = useState<MarketIndicatorSet | null>(null); const [data, setData] = useState<MarketIndicatorSet | null>(null);
@ -436,8 +528,8 @@ export default function SignalsPage() {
{/* 标题 */} {/* 标题 */}
<div className="flex items-center justify-between flex-wrap gap-2"> <div className="flex items-center justify-between flex-wrap gap-2">
<div> <div>
<h1 className="text-lg font-bold text-slate-900"> V5.1</h1> <h1 className="text-lg font-bold text-slate-900"> V5.1 vs V5.2</h1>
<p className="text-slate-500 text-[10px]">100 · · </p> <p className="text-slate-500 text-[10px]"> · V5.2 Funding Rate / Liquidation </p>
</div> </div>
<div className="flex gap-1"> <div className="flex gap-1">
{(["BTC", "ETH", "XRP", "SOL"] as Symbol[]).map(s => ( {(["BTC", "ETH", "XRP", "SOL"] as Symbol[]).map(s => (
@ -449,6 +541,8 @@ export default function SignalsPage() {
</div> </div>
</div> </div>
<LatestStrategyComparison />
{/* 实时指标卡片 */} {/* 实时指标卡片 */}
<IndicatorCards symbol={symbol} /> <IndicatorCards symbol={symbol} />

View File

@ -7,14 +7,16 @@ import { useAuth } from "@/lib/auth";
import { import {
LayoutDashboard, Info, LayoutDashboard, Info,
Menu, X, Zap, LogIn, UserPlus, Menu, X, Zap, LogIn, UserPlus,
ChevronLeft, ChevronRight, Activity, LogOut, Crosshair, Monitor, LineChart ChevronLeft, ChevronRight, Activity, LogOut, Crosshair, Monitor, LineChart, Sparkles, FlaskConical
} from "lucide-react"; } from "lucide-react";
const navItems = [ const navItems = [
{ href: "/", label: "仪表盘", icon: LayoutDashboard }, { href: "/", label: "仪表盘", icon: LayoutDashboard },
{ href: "/trades", label: "成交流", icon: Activity }, { href: "/trades", label: "成交流", icon: Activity },
{ href: "/signals", label: "信号引擎 V5.1", icon: Crosshair }, { href: "/signals", label: "信号引擎", icon: Crosshair, section: "信号" },
{ href: "/paper", label: "模拟盘", icon: LineChart }, { href: "/paper?strategy=all", label: "全部持仓", icon: LineChart, section: "模拟盘" },
{ href: "/paper?strategy=v51_baseline", label: "V5.1 模拟盘", icon: FlaskConical },
{ href: "/paper?strategy=v52_8signals", label: "V5.2 模拟盘", icon: Sparkles, badge: "NEW" },
{ href: "/server", label: "服务器", icon: Monitor }, { href: "/server", label: "服务器", icon: Monitor },
{ href: "/about", label: "说明", icon: Info }, { href: "/about", label: "说明", icon: Info },
]; ];
@ -37,17 +39,29 @@ export default function Sidebar() {
{/* Nav */} {/* Nav */}
<nav className="flex-1 py-4 space-y-1 px-2"> <nav className="flex-1 py-4 space-y-1 px-2">
{navItems.map(({ href, label, icon: Icon }) => { {navItems.map(({ href, label, icon: Icon, section, badge }, idx) => {
const active = pathname === href; const active = pathname === href || (href.includes("?") && pathname === href.split("?")[0] && typeof window !== "undefined" && window.location.search === "?" + href.split("?")[1]);
return ( return (
<Link key={href} href={href} <div key={href}>
{section && (
<div className={`px-3 pt-3 pb-1 text-[10px] font-semibold text-slate-400 uppercase tracking-wider ${idx > 0 ? "mt-2 border-t border-slate-100 pt-4" : ""}`}>
{section}
</div>
)}
<Link href={href}
onClick={() => setMobileOpen(false)} onClick={() => setMobileOpen(false)}
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors
${active ? "bg-blue-50 text-blue-700 font-medium" : "text-slate-600 hover:bg-slate-100 hover:text-slate-900"} ${active ? "bg-blue-50 text-blue-700 font-medium" : "text-slate-600 hover:bg-slate-100 hover:text-slate-900"}
${collapsed && !mobile ? "justify-center" : ""}`}> ${collapsed && !mobile ? "justify-center" : ""}`}>
<Icon className={`shrink-0 ${active ? "text-blue-600" : "text-slate-400"}`} size={18} /> <Icon className={`shrink-0 ${active ? "text-blue-600" : "text-slate-400"}`} size={18} />
{(!collapsed || mobile) && <span>{label}</span>} {(!collapsed || mobile) && (
<span className="flex items-center gap-1.5">
{label}
{badge && <span className="text-[9px] bg-emerald-500 text-white px-1 py-0.5 rounded font-bold">{badge}</span>}
</span>
)}
</Link> </Link>
</div>
); );
})} })}
{/* 手机端:登录/登出放在菜单里 */} {/* 手机端:登录/登出放在菜单里 */}

View File

@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lightweight-charts": "^5.0.0",
"lucide-react": "^0.575.0", "lucide-react": "^0.575.0",
"next": "16.1.6", "next": "16.1.6",
"react": "19.2.3", "react": "19.2.3",
@ -3765,6 +3766,12 @@
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/fancy-canvas": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fancy-canvas/-/fancy-canvas-2.1.0.tgz",
"integrity": "sha512-nifxXJ95JNLFR2NgRV4/MxVP45G9909wJTEKz5fg/TZS20JJZA6hfgRVh/bC9bwl2zBtBNcYPjiBE4njQHVBwQ==",
"license": "MIT"
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -5127,6 +5134,15 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
} }
}, },
"node_modules/lightweight-charts": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/lightweight-charts/-/lightweight-charts-5.1.0.tgz",
"integrity": "sha512-jEAYR4ODYeyNZcWUigsoLTl52rbPmgXnvd5FLIv/ZoA/2sSDw63YKnef8n4yhzum7W926yHeFwlm7ididKb7YQ==",
"license": "Apache-2.0",
"dependencies": {
"fancy-canvas": "2.1.0"
}
},
"node_modules/locate-path": { "node_modules/locate-path": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",

0
signal-engine.log Normal file
View File