Compare commits
No commits in common. "87f19cb7d885dae8473feb713342ba720187319f" and "4b841bc5f4c9e9abe031e7c2dd5c04021b76f3ae" have entirely different histories.
87f19cb7d8
...
4b841bc5f4
@ -1,63 +0,0 @@
|
||||
# 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
224
V52_TASK.md
@ -1,224 +0,0 @@
|
||||
# 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
|
||||
@ -355,9 +355,5 @@ def init_schema():
|
||||
conn.rollback()
|
||||
# 忽略已存在错误
|
||||
continue
|
||||
cur.execute(
|
||||
"ALTER TABLE paper_trades "
|
||||
"ADD COLUMN IF NOT EXISTS strategy VARCHAR(32) DEFAULT 'v51_baseline'"
|
||||
)
|
||||
conn.commit()
|
||||
ensure_partitions()
|
||||
|
||||
276
backend/main.py
276
backend/main.py
@ -2,7 +2,7 @@ from fastapi import FastAPI, HTTPException, Depends, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
import httpx
|
||||
from datetime import datetime, timedelta
|
||||
import asyncio, time, os, json
|
||||
import asyncio, time, os
|
||||
|
||||
from auth import router as auth_router, get_current_user, ensure_tables as ensure_auth_tables
|
||||
from db import (
|
||||
@ -436,109 +436,13 @@ async def get_signal_latest(user: dict = Depends(get_current_user)):
|
||||
return result
|
||||
|
||||
|
||||
def _primary_signal_strategy() -> str:
|
||||
strategy_dir = os.path.join(os.path.dirname(__file__), "strategies")
|
||||
try:
|
||||
names = []
|
||||
for fn in os.listdir(strategy_dir):
|
||||
if not fn.endswith(".json"):
|
||||
continue
|
||||
with open(os.path.join(strategy_dir, fn), "r", encoding="utf-8") as f:
|
||||
cfg = json.load(f)
|
||||
if cfg.get("name"):
|
||||
names.append(cfg["name"])
|
||||
if "v52_8signals" in names:
|
||||
return "v52_8signals"
|
||||
if "v51_baseline" in names:
|
||||
return "v51_baseline"
|
||||
except Exception:
|
||||
pass
|
||||
return "v51_baseline"
|
||||
|
||||
|
||||
def _normalize_factors(raw):
|
||||
if not raw:
|
||||
return {}
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except Exception:
|
||||
return {}
|
||||
if isinstance(raw, dict):
|
||||
return raw
|
||||
return {}
|
||||
|
||||
|
||||
@app.get("/api/signals/latest-v52")
|
||||
async def get_signal_latest_v52(user: dict = Depends(get_current_user)):
|
||||
"""返回V5.1/V5.2并排展示所需的最新信号信息。"""
|
||||
primary_strategy = _primary_signal_strategy()
|
||||
result = {}
|
||||
for sym in SYMBOLS:
|
||||
base_row = await async_fetchrow(
|
||||
"SELECT ts, score, signal FROM signal_indicators WHERE symbol = $1 ORDER BY ts DESC LIMIT 1",
|
||||
sym,
|
||||
)
|
||||
strategy_rows = await async_fetch(
|
||||
"SELECT strategy, score, direction, entry_ts, score_factors "
|
||||
"FROM paper_trades WHERE symbol = $1 AND strategy IN ('v51_baseline','v52_8signals') "
|
||||
"ORDER BY entry_ts DESC",
|
||||
sym,
|
||||
)
|
||||
latest_by_strategy: dict[str, dict] = {}
|
||||
for row in strategy_rows:
|
||||
st = (row.get("strategy") or "v51_baseline")
|
||||
if st not in latest_by_strategy:
|
||||
latest_by_strategy[st] = row
|
||||
if "v51_baseline" in latest_by_strategy and "v52_8signals" in latest_by_strategy:
|
||||
break
|
||||
|
||||
def build_strategy_payload(strategy_name: str):
|
||||
trade_row = latest_by_strategy.get(strategy_name)
|
||||
if trade_row:
|
||||
payload = {
|
||||
"score": trade_row.get("score"),
|
||||
"signal": trade_row.get("direction"),
|
||||
"ts": trade_row.get("entry_ts"),
|
||||
"source": "paper_trade",
|
||||
}
|
||||
elif base_row and primary_strategy == strategy_name:
|
||||
payload = {
|
||||
"score": base_row.get("score"),
|
||||
"signal": base_row.get("signal"),
|
||||
"ts": base_row.get("ts"),
|
||||
"source": "signal_indicators",
|
||||
}
|
||||
else:
|
||||
payload = {
|
||||
"score": None,
|
||||
"signal": None,
|
||||
"ts": None,
|
||||
"source": "unavailable",
|
||||
}
|
||||
|
||||
factors = _normalize_factors(trade_row.get("score_factors") if trade_row else None)
|
||||
payload["funding_rate_score"] = factors.get("funding_rate", {}).get("score")
|
||||
payload["liquidation_score"] = factors.get("liquidation", {}).get("score")
|
||||
return payload
|
||||
|
||||
result[sym.replace("USDT", "")] = {
|
||||
"primary_strategy": primary_strategy,
|
||||
"latest_signal": base_row.get("signal") if base_row else None,
|
||||
"latest_ts": base_row.get("ts") if base_row else None,
|
||||
"v51": build_strategy_payload("v51_baseline"),
|
||||
"v52": build_strategy_payload("v52_8signals"),
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/signals/market-indicators")
|
||||
async def get_market_indicators(user: dict = Depends(get_current_user)):
|
||||
"""返回最新的market_indicators数据(V5.1新增4个数据源)"""
|
||||
result = {}
|
||||
for sym in SYMBOLS:
|
||||
indicators = {}
|
||||
for ind_type in ["long_short_ratio", "top_trader_position", "open_interest_hist", "coinbase_premium", "funding_rate"]:
|
||||
for ind_type in ["long_short_ratio", "top_trader_position", "open_interest_hist", "coinbase_premium"]:
|
||||
row = await async_fetchrow(
|
||||
"SELECT value, timestamp_ms FROM market_indicators WHERE symbol = $1 AND indicator_type = $2 ORDER BY timestamp_ms DESC LIMIT 1",
|
||||
sym,
|
||||
@ -597,23 +501,12 @@ async def get_signal_trades(
|
||||
# 模拟盘配置状态(与signal_engine共享的运行时状态)
|
||||
paper_config = {
|
||||
"enabled": False,
|
||||
"enabled_strategies": [], # 分策略开关: ["v51_baseline", "v52_8signals"]
|
||||
"initial_balance": 10000,
|
||||
"risk_per_trade": 0.02,
|
||||
"max_positions": 4,
|
||||
"tier_multiplier": {"light": 0.5, "standard": 1.0, "heavy": 1.5},
|
||||
}
|
||||
|
||||
# 启动时加载已有配置
|
||||
_config_path = os.path.join(os.path.dirname(__file__), "paper_config.json")
|
||||
if os.path.exists(_config_path):
|
||||
try:
|
||||
with open(_config_path, "r") as _f:
|
||||
_saved = json.load(_f)
|
||||
paper_config.update(_saved)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@app.get("/api/paper/config")
|
||||
async def paper_get_config(user: dict = Depends(get_current_user)):
|
||||
@ -627,10 +520,11 @@ async def paper_set_config(request: Request, user: dict = Depends(get_current_us
|
||||
if user.get("role") != "admin":
|
||||
raise HTTPException(status_code=403, detail="仅管理员可修改")
|
||||
body = await request.json()
|
||||
for k in ["enabled", "enabled_strategies", "initial_balance", "risk_per_trade", "max_positions"]:
|
||||
for k in ["enabled", "initial_balance", "risk_per_trade", "max_positions"]:
|
||||
if k in body:
|
||||
paper_config[k] = body[k]
|
||||
# 写入配置文件让signal_engine也能读到
|
||||
import json
|
||||
config_path = os.path.join(os.path.dirname(__file__), "paper_config.json")
|
||||
with open(config_path, "w") as f:
|
||||
json.dump(paper_config, f, indent=2)
|
||||
@ -638,33 +532,15 @@ async def paper_set_config(request: Request, user: dict = Depends(get_current_us
|
||||
|
||||
|
||||
@app.get("/api/paper/summary")
|
||||
async def paper_summary(
|
||||
strategy: str = "all",
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
async def paper_summary(user: dict = Depends(get_current_user)):
|
||||
"""模拟盘总览"""
|
||||
if strategy == "all":
|
||||
closed = await async_fetch(
|
||||
"SELECT pnl_r, direction FROM paper_trades WHERE status NOT IN ('active','tp1_hit')"
|
||||
)
|
||||
active = await async_fetch(
|
||||
"SELECT id FROM paper_trades WHERE status IN ('active','tp1_hit')"
|
||||
)
|
||||
first = await async_fetchrow("SELECT MIN(created_at) as start FROM paper_trades")
|
||||
else:
|
||||
closed = await async_fetch(
|
||||
"SELECT pnl_r, direction FROM paper_trades "
|
||||
"WHERE status NOT IN ('active','tp1_hit') AND strategy = $1",
|
||||
strategy,
|
||||
)
|
||||
active = await async_fetch(
|
||||
"SELECT id FROM paper_trades WHERE status IN ('active','tp1_hit') AND strategy = $1",
|
||||
strategy,
|
||||
)
|
||||
first = await async_fetchrow(
|
||||
"SELECT MIN(created_at) as start FROM paper_trades WHERE strategy = $1",
|
||||
strategy,
|
||||
)
|
||||
closed = await async_fetch(
|
||||
"SELECT pnl_r, direction FROM paper_trades WHERE status NOT IN ('active','tp1_hit')"
|
||||
)
|
||||
active = await async_fetch(
|
||||
"SELECT id FROM paper_trades WHERE status IN ('active','tp1_hit')"
|
||||
)
|
||||
first = await async_fetchrow("SELECT MIN(created_at) as start FROM paper_trades")
|
||||
|
||||
total = len(closed)
|
||||
wins = len([r for r in closed if r["pnl_r"] > 0])
|
||||
@ -689,24 +565,13 @@ async def paper_summary(
|
||||
|
||||
|
||||
@app.get("/api/paper/positions")
|
||||
async def paper_positions(
|
||||
strategy: str = "all",
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
async def paper_positions(user: dict = Depends(get_current_user)):
|
||||
"""当前活跃持仓(含实时价格和浮动盈亏)"""
|
||||
if strategy == "all":
|
||||
rows = await async_fetch(
|
||||
"SELECT id, symbol, direction, score, tier, strategy, entry_price, entry_ts, "
|
||||
"tp1_price, tp2_price, sl_price, tp1_hit, status, atr_at_entry, score_factors "
|
||||
"FROM paper_trades WHERE status IN ('active','tp1_hit') ORDER BY entry_ts DESC"
|
||||
)
|
||||
else:
|
||||
rows = await async_fetch(
|
||||
"SELECT id, symbol, direction, score, tier, strategy, entry_price, entry_ts, "
|
||||
"tp1_price, tp2_price, sl_price, tp1_hit, status, atr_at_entry, score_factors "
|
||||
"FROM paper_trades WHERE status IN ('active','tp1_hit') AND strategy = $1 ORDER BY entry_ts DESC",
|
||||
strategy,
|
||||
)
|
||||
rows = await async_fetch(
|
||||
"SELECT id, symbol, direction, score, tier, entry_price, entry_ts, "
|
||||
"tp1_price, tp2_price, sl_price, tp1_hit, status, atr_at_entry "
|
||||
"FROM paper_trades WHERE status IN ('active','tp1_hit') ORDER BY entry_ts DESC"
|
||||
)
|
||||
# 从币安API获取实时价格
|
||||
prices = {}
|
||||
symbols_needed = list(set(r["symbol"] for r in rows))
|
||||
@ -759,7 +624,6 @@ async def paper_positions(
|
||||
async def paper_trades(
|
||||
symbol: str = "all",
|
||||
result: str = "all",
|
||||
strategy: str = "all",
|
||||
limit: int = 100,
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
@ -778,16 +642,11 @@ async def paper_trades(
|
||||
elif result == "loss":
|
||||
conditions.append("pnl_r <= 0")
|
||||
|
||||
if strategy != "all":
|
||||
conditions.append(f"strategy = ${idx}")
|
||||
params.append(strategy)
|
||||
idx += 1
|
||||
|
||||
where = " AND ".join(conditions)
|
||||
params.append(limit)
|
||||
rows = await async_fetch(
|
||||
f"SELECT id, symbol, direction, score, tier, strategy, entry_price, exit_price, "
|
||||
f"entry_ts, exit_ts, pnl_r, status, tp1_hit, score_factors "
|
||||
f"SELECT id, symbol, direction, score, tier, entry_price, exit_price, "
|
||||
f"entry_ts, exit_ts, pnl_r, status, tp1_hit "
|
||||
f"FROM paper_trades WHERE {where} ORDER BY exit_ts DESC LIMIT ${idx}",
|
||||
*params
|
||||
)
|
||||
@ -795,22 +654,11 @@ async def paper_trades(
|
||||
|
||||
|
||||
@app.get("/api/paper/equity-curve")
|
||||
async def paper_equity_curve(
|
||||
strategy: str = "all",
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
async def paper_equity_curve(user: dict = Depends(get_current_user)):
|
||||
"""权益曲线"""
|
||||
if strategy == "all":
|
||||
rows = await async_fetch(
|
||||
"SELECT exit_ts, pnl_r FROM paper_trades "
|
||||
"WHERE status NOT IN ('active','tp1_hit') ORDER BY exit_ts ASC"
|
||||
)
|
||||
else:
|
||||
rows = await async_fetch(
|
||||
"SELECT exit_ts, pnl_r FROM paper_trades "
|
||||
"WHERE status NOT IN ('active','tp1_hit') AND strategy = $1 ORDER BY exit_ts ASC",
|
||||
strategy,
|
||||
)
|
||||
rows = await async_fetch(
|
||||
"SELECT exit_ts, pnl_r FROM paper_trades WHERE status NOT IN ('active','tp1_hit') ORDER BY exit_ts ASC"
|
||||
)
|
||||
cumulative = 0.0
|
||||
curve = []
|
||||
for r in rows:
|
||||
@ -820,22 +668,12 @@ async def paper_equity_curve(
|
||||
|
||||
|
||||
@app.get("/api/paper/stats")
|
||||
async def paper_stats(
|
||||
strategy: str = "all",
|
||||
user: dict = Depends(get_current_user),
|
||||
):
|
||||
async def paper_stats(user: dict = Depends(get_current_user)):
|
||||
"""详细统计"""
|
||||
if strategy == "all":
|
||||
rows = await async_fetch(
|
||||
"SELECT symbol, direction, pnl_r, tier, entry_ts, exit_ts "
|
||||
"FROM paper_trades WHERE status NOT IN ('active','tp1_hit')"
|
||||
)
|
||||
else:
|
||||
rows = await async_fetch(
|
||||
"SELECT symbol, direction, pnl_r, tier, entry_ts, exit_ts "
|
||||
"FROM paper_trades WHERE status NOT IN ('active','tp1_hit') AND strategy = $1",
|
||||
strategy,
|
||||
)
|
||||
rows = await async_fetch(
|
||||
"SELECT symbol, direction, pnl_r, tier, entry_ts, exit_ts "
|
||||
"FROM paper_trades WHERE status NOT IN ('active','tp1_hit')"
|
||||
)
|
||||
if not rows:
|
||||
return {"error": "暂无数据"}
|
||||
|
||||
@ -950,62 +788,6 @@ async def paper_stats(
|
||||
}
|
||||
|
||||
|
||||
@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
|
||||
|
||||
@ -18,7 +18,6 @@ signal_engine.py — V5 短线交易信号引擎(PostgreSQL版)
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
from collections import deque
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
@ -37,41 +36,12 @@ logger = logging.getLogger("signal-engine")
|
||||
|
||||
SYMBOLS = ["BTCUSDT", "ETHUSDT", "XRPUSDT", "SOLUSDT"]
|
||||
LOOP_INTERVAL = 15 # 秒(从5改15,CPU降60%,信号质量无影响)
|
||||
STRATEGY_DIR = os.path.join(os.path.dirname(__file__), "strategies")
|
||||
DEFAULT_STRATEGY_FILES = ["v51_baseline.json", "v52_8signals.json"]
|
||||
|
||||
|
||||
def load_strategy_configs() -> list[dict]:
|
||||
configs = []
|
||||
for filename in DEFAULT_STRATEGY_FILES:
|
||||
path = os.path.join(STRATEGY_DIR, filename)
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
cfg = json.load(f)
|
||||
if isinstance(cfg, dict) and cfg.get("name"):
|
||||
configs.append(cfg)
|
||||
except FileNotFoundError:
|
||||
logger.warning(f"策略配置缺失: {path}")
|
||||
except Exception as e:
|
||||
logger.error(f"策略配置加载失败 {path}: {e}")
|
||||
if not configs:
|
||||
logger.warning("未加载到策略配置,回退到v51_baseline默认配置")
|
||||
configs.append(
|
||||
{
|
||||
"name": "v51_baseline",
|
||||
"threshold": 75,
|
||||
"signals": ["cvd", "p99", "accel", "ls_ratio", "oi", "coinbase_premium"],
|
||||
"tp_sl": {"sl_multiplier": 2.0, "tp1_multiplier": 1.5, "tp2_multiplier": 3.0},
|
||||
}
|
||||
)
|
||||
return configs
|
||||
|
||||
# ─── 模拟盘配置 ───────────────────────────────────────────────────
|
||||
PAPER_TRADING_ENABLED = False # 总开关(兼容旧逻辑)
|
||||
PAPER_ENABLED_STRATEGIES = [] # 分策略开关: ["v51_baseline", "v52_8signals"]
|
||||
PAPER_TRADING_ENABLED = False # 开关(范总确认后通过API开启)
|
||||
PAPER_INITIAL_BALANCE = 10000 # 虚拟初始资金 USDT
|
||||
PAPER_RISK_PER_TRADE = 0.02 # 单笔风险 2%(即200U)
|
||||
PAPER_MAX_POSITIONS = 4 # 每套策略最大同时持仓数
|
||||
PAPER_MAX_POSITIONS = 4 # 最大同时持仓数
|
||||
PAPER_TIER_MULTIPLIER = { # 档位仓位倍数
|
||||
"light": 0.5, # 轻仓: 1%
|
||||
"standard": 1.0, # 标准: 2%
|
||||
@ -81,29 +51,18 @@ PAPER_FEE_RATE = 0.0005 # Taker手续费 0.05%(开仓+平仓各一
|
||||
|
||||
def load_paper_config():
|
||||
"""从配置文件加载模拟盘开关和参数"""
|
||||
global PAPER_TRADING_ENABLED, PAPER_ENABLED_STRATEGIES, PAPER_INITIAL_BALANCE, PAPER_RISK_PER_TRADE, PAPER_MAX_POSITIONS
|
||||
global PAPER_TRADING_ENABLED, PAPER_INITIAL_BALANCE, PAPER_RISK_PER_TRADE, PAPER_MAX_POSITIONS
|
||||
config_path = os.path.join(os.path.dirname(__file__), "paper_config.json")
|
||||
try:
|
||||
with open(config_path, "r") as f:
|
||||
import json as _json2
|
||||
cfg = _json2.load(f)
|
||||
PAPER_TRADING_ENABLED = cfg.get("enabled", False)
|
||||
PAPER_ENABLED_STRATEGIES = cfg.get("enabled_strategies", [])
|
||||
PAPER_INITIAL_BALANCE = cfg.get("initial_balance", 10000)
|
||||
PAPER_RISK_PER_TRADE = cfg.get("risk_per_trade", 0.02)
|
||||
PAPER_MAX_POSITIONS = cfg.get("max_positions", 4)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def is_strategy_enabled(strategy_name: str) -> bool:
|
||||
"""检查某策略是否启用模拟盘"""
|
||||
if not PAPER_TRADING_ENABLED:
|
||||
return False
|
||||
# 如果enabled_strategies为空,走旧逻辑(全部启用)
|
||||
if not PAPER_ENABLED_STRATEGIES:
|
||||
return True
|
||||
return strategy_name in PAPER_ENABLED_STRATEGIES
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
# 窗口大小(毫秒)
|
||||
@ -126,7 +85,7 @@ def fetch_market_indicators(symbol: str) -> dict:
|
||||
with get_sync_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
indicators = {}
|
||||
for ind_type in ["long_short_ratio", "top_trader_position", "open_interest_hist", "coinbase_premium", "funding_rate"]:
|
||||
for ind_type in ["long_short_ratio", "top_trader_position", "open_interest_hist", "coinbase_premium"]:
|
||||
cur.execute(
|
||||
"SELECT value FROM market_indicators WHERE symbol=%s AND indicator_type=%s ORDER BY timestamp_ms DESC LIMIT 1",
|
||||
(symbol, ind_type),
|
||||
@ -152,8 +111,6 @@ def fetch_market_indicators(symbol: str) -> dict:
|
||||
indicators[ind_type] = float(val.get("sumOpenInterestValue", 0))
|
||||
elif ind_type == "coinbase_premium":
|
||||
indicators[ind_type] = float(val.get("premium_pct", 0))
|
||||
elif ind_type == "funding_rate":
|
||||
indicators[ind_type] = float(val.get("lastFundingRate", 0))
|
||||
return indicators
|
||||
|
||||
|
||||
@ -270,8 +227,8 @@ class SymbolState:
|
||||
self.prev_cvd_fast_slope = 0.0
|
||||
self.prev_oi_value = 0.0
|
||||
self.market_indicators = fetch_market_indicators(symbol)
|
||||
self.last_signal_ts: dict[str, int] = {}
|
||||
self.last_signal_dir: dict[str, str] = {}
|
||||
self.last_signal_ts = 0
|
||||
self.last_signal_dir = ""
|
||||
self.recent_large_trades: deque = deque()
|
||||
|
||||
def process_trade(self, agg_id: int, time_ms: int, price: float, qty: float, is_buyer_maker: int):
|
||||
@ -311,10 +268,9 @@ class SymbolState:
|
||||
self.recent_large_trades.append((t[0], t[1], t[3]))
|
||||
seen.add(t[0])
|
||||
|
||||
def build_evaluation_snapshot(self, now_ms: int) -> dict:
|
||||
def evaluate_signal(self, now_ms: int) -> dict:
|
||||
cvd_fast = self.win_fast.cvd
|
||||
cvd_mid = self.win_mid.cvd
|
||||
cvd_day = self.win_day.cvd
|
||||
vwap = self.win_vwap.vwap
|
||||
atr = self.atr_calc.atr
|
||||
atr_pct = self.atr_calc.atr_percentile
|
||||
@ -326,94 +282,11 @@ class SymbolState:
|
||||
self.prev_cvd_fast = cvd_fast
|
||||
self.prev_cvd_fast_slope = cvd_fast_slope
|
||||
|
||||
oi_value = to_float(self.market_indicators.get("open_interest_hist"))
|
||||
if oi_value is None or self.prev_oi_value == 0:
|
||||
oi_change = 0.0
|
||||
environment_score = 10
|
||||
else:
|
||||
oi_change = (oi_value - self.prev_oi_value) / self.prev_oi_value if self.prev_oi_value > 0 else 0.0
|
||||
if oi_change >= 0.03:
|
||||
environment_score = 15
|
||||
elif oi_change > 0:
|
||||
environment_score = 10
|
||||
else:
|
||||
environment_score = 5
|
||||
if oi_value is not None and oi_value > 0:
|
||||
self.prev_oi_value = oi_value
|
||||
|
||||
return {
|
||||
"cvd_fast": cvd_fast,
|
||||
"cvd_mid": cvd_mid,
|
||||
"cvd_day": cvd_day,
|
||||
"vwap": vwap,
|
||||
"atr": atr,
|
||||
"atr_pct": atr_pct,
|
||||
"p95": p95,
|
||||
"p99": p99,
|
||||
"price": price,
|
||||
"cvd_fast_slope": cvd_fast_slope,
|
||||
"cvd_fast_accel": cvd_fast_accel,
|
||||
"oi_change": oi_change,
|
||||
"environment_score": environment_score,
|
||||
"oi_value": oi_value,
|
||||
}
|
||||
|
||||
def fetch_recent_liquidations(self, window_ms: int = 300000):
|
||||
"""Fetch last 5min liquidation totals from liquidations table"""
|
||||
now_ms = int(time.time() * 1000)
|
||||
cutoff = now_ms - window_ms
|
||||
with get_sync_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN side='SELL' THEN usd_value ELSE 0 END), 0) as long_liq,
|
||||
COALESCE(SUM(CASE WHEN side='BUY' THEN usd_value ELSE 0 END), 0) as short_liq
|
||||
FROM liquidations
|
||||
WHERE symbol=%s AND trade_time >= %s
|
||||
""",
|
||||
(self.symbol, cutoff),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
return {"long_usd": row[0], "short_usd": row[1]}
|
||||
return None
|
||||
|
||||
def evaluate_signal(self, now_ms: int, strategy_cfg: Optional[dict] = None, snapshot: Optional[dict] = None) -> dict:
|
||||
strategy_cfg = strategy_cfg or {}
|
||||
strategy_name = strategy_cfg.get("name", "v51_baseline")
|
||||
strategy_threshold = int(strategy_cfg.get("threshold", 75))
|
||||
enabled_signals = set(strategy_cfg.get("signals", []))
|
||||
|
||||
snap = snapshot or self.build_evaluation_snapshot(now_ms)
|
||||
cvd_fast = snap["cvd_fast"]
|
||||
cvd_mid = snap["cvd_mid"]
|
||||
vwap = snap["vwap"]
|
||||
atr = snap["atr"]
|
||||
atr_pct = snap["atr_pct"]
|
||||
p95 = snap["p95"]
|
||||
p99 = snap["p99"]
|
||||
price = snap["price"]
|
||||
cvd_fast_slope = snap["cvd_fast_slope"]
|
||||
cvd_fast_accel = snap["cvd_fast_accel"]
|
||||
oi_change = snap["oi_change"]
|
||||
environment_score = snap["environment_score"]
|
||||
|
||||
result = {
|
||||
"strategy": strategy_name,
|
||||
"cvd_fast": cvd_fast,
|
||||
"cvd_mid": cvd_mid,
|
||||
"cvd_day": snap["cvd_day"],
|
||||
"cvd_fast": cvd_fast, "cvd_mid": cvd_mid, "cvd_day": self.win_day.cvd,
|
||||
"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,
|
||||
"atr": atr, "atr_pct": atr_pct, "vwap": vwap, "price": price,
|
||||
"p95": p95, "p99": p99, "signal": None, "direction": None, "score": 0,
|
||||
"tier": None,
|
||||
"factors": {},
|
||||
}
|
||||
@ -423,8 +296,7 @@ class SymbolState:
|
||||
|
||||
# 判断倾向方向(用于评分展示,即使冷却或方向不一致也计算)
|
||||
no_direction = False
|
||||
last_signal_ts = self.last_signal_ts.get(strategy_name, 0)
|
||||
in_cooldown = now_ms - last_signal_ts < COOLDOWN_MS
|
||||
in_cooldown = (now_ms - self.last_signal_ts < COOLDOWN_MS)
|
||||
|
||||
if cvd_fast > 0 and cvd_mid > 0:
|
||||
direction = "LONG"
|
||||
@ -454,10 +326,8 @@ class SymbolState:
|
||||
elif not has_adverse_p99:
|
||||
direction_score += 10
|
||||
accel_bonus = 0
|
||||
if "accel" in enabled_signals and (
|
||||
(direction == "LONG" and cvd_fast_accel > 0) or (direction == "SHORT" and cvd_fast_accel < 0)
|
||||
):
|
||||
accel_bonus = int(strategy_cfg.get("accel_bonus", 5))
|
||||
if (direction == "LONG" and cvd_fast_accel > 0) or (direction == "SHORT" and cvd_fast_accel < 0):
|
||||
accel_bonus = 5
|
||||
|
||||
# 2) 拥挤层(20分)- market_indicators缺失时给中间分
|
||||
long_short_ratio = to_float(self.market_indicators.get("long_short_ratio"))
|
||||
@ -488,53 +358,24 @@ class SymbolState:
|
||||
top_trader_score = 5
|
||||
crowding_score = ls_score + top_trader_score
|
||||
|
||||
# Funding Rate scoring (拥挤层加分)
|
||||
# Read from market_indicators table
|
||||
funding_rate = to_float(self.market_indicators.get("funding_rate"))
|
||||
fr_score = 0
|
||||
if "funding_rate" in enabled_signals and funding_rate is not None:
|
||||
fr_abs = abs(funding_rate)
|
||||
if fr_abs >= 0.001: # extreme ±0.1%
|
||||
# Extreme: penalize if going WITH the crowd
|
||||
if (direction == "LONG" and funding_rate > 0.001) or (direction == "SHORT" and funding_rate < -0.001):
|
||||
fr_score = -5
|
||||
else:
|
||||
fr_score = 5
|
||||
elif fr_abs >= 0.0003: # moderate ±0.03%
|
||||
# Moderate: reward going AGAINST the crowd
|
||||
if (direction == "LONG" and funding_rate < -0.0003) or (direction == "SHORT" and funding_rate > 0.0003):
|
||||
fr_score = 5
|
||||
else:
|
||||
fr_score = 0
|
||||
# 3) 环境层(15分)— OI变化率
|
||||
oi_value = to_float(self.market_indicators.get("open_interest_hist"))
|
||||
if oi_value is None or self.prev_oi_value == 0:
|
||||
environment_score = 10
|
||||
oi_change = 0.0
|
||||
else:
|
||||
oi_change = (oi_value - self.prev_oi_value) / self.prev_oi_value if self.prev_oi_value > 0 else 0
|
||||
if oi_change >= 0.03:
|
||||
environment_score = 15
|
||||
elif oi_change > 0:
|
||||
environment_score = 10
|
||||
else:
|
||||
environment_score = 5
|
||||
if oi_value is not None and oi_value > 0:
|
||||
self.prev_oi_value = oi_value
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
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
|
||||
|
||||
# 5) 辅助层(5分)
|
||||
coinbase_premium = to_float(self.market_indicators.get("coinbase_premium"))
|
||||
@ -547,7 +388,7 @@ class SymbolState:
|
||||
else:
|
||||
aux_score = 0
|
||||
|
||||
total_score = direction_score + accel_bonus + crowding_score + fr_score + environment_score + confirmation_score + liq_score + aux_score
|
||||
total_score = direction_score + accel_bonus + crowding_score + environment_score + confirmation_score + aux_score
|
||||
result["score"] = total_score
|
||||
result["direction"] = direction
|
||||
result["factors"] = {
|
||||
@ -562,31 +403,27 @@ class SymbolState:
|
||||
"environment": {"score": environment_score, "open_interest_hist": oi_change},
|
||||
"confirmation": {"score": confirmation_score},
|
||||
"auxiliary": {"score": aux_score, "coinbase_premium": coinbase_premium},
|
||||
"funding_rate": {"score": fr_score, "value": funding_rate},
|
||||
"liquidation": {
|
||||
"score": liq_score,
|
||||
"long_usd": liq_data.get("long_usd", 0) if liq_data else 0,
|
||||
"short_usd": liq_data.get("short_usd", 0) if liq_data else 0,
|
||||
},
|
||||
}
|
||||
|
||||
# 始终输出direction供反向平仓判断(不受冷却限制)
|
||||
result["direction"] = direction if not no_direction else None
|
||||
|
||||
heavy_threshold = max(strategy_threshold + 10, 85)
|
||||
if total_score >= heavy_threshold and not no_direction and not in_cooldown:
|
||||
if total_score >= 85 and not no_direction and not in_cooldown:
|
||||
result["signal"] = direction
|
||||
result["tier"] = "heavy"
|
||||
elif total_score >= strategy_threshold and not no_direction and not in_cooldown:
|
||||
elif total_score >= 75 and not no_direction and not in_cooldown:
|
||||
result["signal"] = direction
|
||||
result["tier"] = "standard"
|
||||
elif total_score >= 60 and not no_direction and not in_cooldown:
|
||||
result["signal"] = direction
|
||||
result["tier"] = "light"
|
||||
else:
|
||||
result["signal"] = None
|
||||
result["tier"] = None
|
||||
|
||||
if result["signal"]:
|
||||
self.last_signal_ts[strategy_name] = now_ms
|
||||
self.last_signal_dir[strategy_name] = direction
|
||||
self.last_signal_ts = now_ms
|
||||
self.last_signal_dir = direction
|
||||
return result
|
||||
|
||||
|
||||
@ -662,60 +499,31 @@ 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,
|
||||
strategy: str = "v51_baseline",
|
||||
tp_sl: Optional[dict] = None,
|
||||
):
|
||||
def paper_open_trade(symbol: str, direction: str, price: float, score: int, tier: str, atr: float, now_ms: int, factors: dict = None):
|
||||
"""模拟开仓"""
|
||||
import json as _json3
|
||||
risk_atr = 0.7 * atr
|
||||
if risk_atr <= 0:
|
||||
return
|
||||
sl_multiplier = float((tp_sl or {}).get("sl_multiplier", 2.0))
|
||||
tp1_multiplier = float((tp_sl or {}).get("tp1_multiplier", 1.5))
|
||||
tp2_multiplier = float((tp_sl or {}).get("tp2_multiplier", 3.0))
|
||||
if direction == "LONG":
|
||||
sl = price - sl_multiplier * risk_atr
|
||||
tp1 = price + tp1_multiplier * risk_atr
|
||||
tp2 = price + tp2_multiplier * risk_atr
|
||||
sl = price - 2.0 * risk_atr
|
||||
tp1 = price + 1.5 * risk_atr
|
||||
tp2 = price + 3.0 * risk_atr
|
||||
else:
|
||||
sl = price + sl_multiplier * risk_atr
|
||||
tp1 = price - tp1_multiplier * risk_atr
|
||||
tp2 = price - tp2_multiplier * risk_atr
|
||||
sl = price + 2.0 * risk_atr
|
||||
tp1 = price - 1.5 * risk_atr
|
||||
tp2 = price - 3.0 * risk_atr
|
||||
|
||||
with get_sync_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO paper_trades (symbol,direction,score,tier,entry_price,entry_ts,tp1_price,tp2_price,sl_price,atr_at_entry,score_factors,strategy) "
|
||||
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)",
|
||||
(
|
||||
symbol,
|
||||
direction,
|
||||
score,
|
||||
tier,
|
||||
price,
|
||||
now_ms,
|
||||
tp1,
|
||||
tp2,
|
||||
sl,
|
||||
atr,
|
||||
_json3.dumps(factors) if factors else None,
|
||||
strategy,
|
||||
),
|
||||
"INSERT INTO paper_trades (symbol,direction,score,tier,entry_price,entry_ts,tp1_price,tp2_price,sl_price,atr_at_entry,score_factors) "
|
||||
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)",
|
||||
(symbol, direction, score, tier, price, now_ms, tp1, tp2, sl, atr,
|
||||
_json3.dumps(factors) if factors else None)
|
||||
)
|
||||
conn.commit()
|
||||
logger.info(
|
||||
f"[{symbol}] 📝 模拟开仓: {direction} @ {price:.2f} score={score} tier={tier} strategy={strategy} "
|
||||
f"TP1={tp1:.2f} TP2={tp2:.2f} SL={sl:.2f}"
|
||||
)
|
||||
logger.info(f"[{symbol}] 📝 模拟开仓: {direction} @ {price:.2f} score={score} tier={tier} TP1={tp1:.2f} TP2={tp2:.2f} SL={sl:.2f}")
|
||||
|
||||
|
||||
def paper_check_positions(symbol: str, current_price: float, now_ms: int):
|
||||
@ -812,54 +620,32 @@ def paper_check_positions(symbol: str, current_price: float, now_ms: int):
|
||||
conn.commit()
|
||||
|
||||
|
||||
def paper_has_active_position(symbol: str, strategy: Optional[str] = None) -> bool:
|
||||
def paper_has_active_position(symbol: str) -> bool:
|
||||
"""检查该币种是否有活跃持仓"""
|
||||
with get_sync_conn() as conn:
|
||||
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
|
||||
|
||||
|
||||
def paper_get_active_direction(symbol: str, strategy: Optional[str] = None) -> str | None:
|
||||
def paper_get_active_direction(symbol: str) -> str | None:
|
||||
"""获取该币种活跃持仓的方向,无持仓返回None"""
|
||||
with get_sync_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
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,),
|
||||
)
|
||||
cur.execute("SELECT direction FROM paper_trades WHERE symbol=%s AND status IN ('active','tp1_hit') LIMIT 1", (symbol,))
|
||||
row = cur.fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
def paper_close_by_signal(symbol: str, current_price: float, now_ms: int, strategy: Optional[str] = None):
|
||||
def paper_close_by_signal(symbol: str, current_price: float, now_ms: int):
|
||||
"""反向信号平仓:按当前价平掉该币种所有活跃仓位"""
|
||||
with get_sync_conn() as conn:
|
||||
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(
|
||||
"SELECT id, direction, entry_price, tp1_hit, atr_at_entry "
|
||||
"FROM paper_trades WHERE symbol=%s AND status IN ('active','tp1_hit')",
|
||||
(symbol,),
|
||||
)
|
||||
cur.execute(
|
||||
"SELECT id, direction, entry_price, tp1_hit, atr_at_entry "
|
||||
"FROM paper_trades WHERE symbol=%s AND status IN ('active','tp1_hit')",
|
||||
(symbol,)
|
||||
)
|
||||
positions = cur.fetchall()
|
||||
for pos in positions:
|
||||
pid, direction, entry_price, tp1_hit, atr_entry = pos
|
||||
@ -875,21 +661,15 @@ def paper_close_by_signal(symbol: str, current_price: float, now_ms: int, strate
|
||||
"UPDATE paper_trades SET status='signal_flip', exit_price=%s, exit_ts=%s, pnl_r=%s WHERE id=%s",
|
||||
(current_price, now_ms, round(pnl_r, 4), pid)
|
||||
)
|
||||
logger.info(
|
||||
f"[{symbol}] 📝 反向信号平仓: {direction} @ {current_price:.2f} pnl={pnl_r:+.2f}R"
|
||||
f"{f' strategy={strategy}' if strategy else ''}"
|
||||
)
|
||||
logger.info(f"[{symbol}] 📝 反向信号平仓: {direction} @ {current_price:.2f} pnl={pnl_r:+.2f}R")
|
||||
conn.commit()
|
||||
|
||||
|
||||
def paper_active_count(strategy: Optional[str] = None) -> int:
|
||||
"""当前活跃持仓总数(按策略独立计数)"""
|
||||
def paper_active_count() -> int:
|
||||
"""当前所有币种活跃持仓总数"""
|
||||
with get_sync_conn() as conn:
|
||||
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]
|
||||
|
||||
|
||||
@ -897,11 +677,6 @@ def paper_active_count(strategy: Optional[str] = None) -> int:
|
||||
|
||||
def main():
|
||||
init_schema()
|
||||
strategy_configs = load_strategy_configs()
|
||||
strategy_names = [cfg.get("name", "unknown") for cfg in strategy_configs]
|
||||
logger.info(f"已加载策略配置: {', '.join(strategy_names)}")
|
||||
primary_strategy_name = "v52_8signals" if any(cfg.get("name") == "v52_8signals" for cfg in strategy_configs) else strategy_names[0]
|
||||
|
||||
states = {sym: SymbolState(sym) for sym in SYMBOLS}
|
||||
|
||||
for sym, state in states.items():
|
||||
@ -924,65 +699,36 @@ def main():
|
||||
state.process_trade(t["agg_id"], t["time_ms"], t["price"], t["qty"], t["is_buyer_maker"])
|
||||
|
||||
state.market_indicators = fetch_market_indicators(sym)
|
||||
snapshot = state.build_evaluation_snapshot(now_ms)
|
||||
strategy_results: list[tuple[dict, dict]] = []
|
||||
for strategy_cfg in strategy_configs:
|
||||
strategy_result = state.evaluate_signal(now_ms, strategy_cfg=strategy_cfg, snapshot=snapshot)
|
||||
strategy_results.append((strategy_cfg, strategy_result))
|
||||
|
||||
primary_result = strategy_results[0][1]
|
||||
for strategy_cfg, strategy_result in strategy_results:
|
||||
if strategy_cfg.get("name") == primary_strategy_name:
|
||||
primary_result = strategy_result
|
||||
break
|
||||
|
||||
save_indicator(now_ms, sym, primary_result)
|
||||
result = state.evaluate_signal(now_ms)
|
||||
save_indicator(now_ms, sym, result)
|
||||
|
||||
bar_1m = (now_ms // 60000) * 60000
|
||||
if last_1m_save.get(sym) != bar_1m:
|
||||
save_indicator_1m(now_ms, sym, primary_result)
|
||||
save_indicator_1m(now_ms, sym, result)
|
||||
last_1m_save[sym] = bar_1m
|
||||
|
||||
# 反向信号平仓:按策略独立判断,score>=75才触发
|
||||
if warmup_cycles <= 0:
|
||||
for strategy_cfg, result in strategy_results:
|
||||
strategy_name = strategy_cfg.get("name", "v51_baseline")
|
||||
if not is_strategy_enabled(strategy_name):
|
||||
continue
|
||||
eval_dir = result.get("direction")
|
||||
existing_dir = paper_get_active_direction(sym, strategy_name)
|
||||
if existing_dir and eval_dir and existing_dir != eval_dir and result["score"] >= 75:
|
||||
paper_close_by_signal(sym, result["price"], now_ms, strategy_name)
|
||||
logger.info(
|
||||
f"[{sym}] 📝 反向信号平仓[{strategy_name}]: {existing_dir} → {eval_dir} "
|
||||
f"(score={result['score']})"
|
||||
)
|
||||
# 反向信号平仓:基于direction(不受冷却限制),score>=60才触发
|
||||
if PAPER_TRADING_ENABLED and warmup_cycles <= 0:
|
||||
eval_dir = result.get("direction")
|
||||
existing_dir = paper_get_active_direction(sym)
|
||||
if existing_dir and eval_dir and existing_dir != eval_dir and result["score"] >= 60:
|
||||
paper_close_by_signal(sym, result["price"], now_ms)
|
||||
logger.info(f"[{sym}] 📝 反向信号平仓: {existing_dir} → {eval_dir} (score={result['score']})")
|
||||
|
||||
for strategy_cfg, result in strategy_results:
|
||||
strategy_name = strategy_cfg.get("name", "v51_baseline")
|
||||
if result.get("signal"):
|
||||
logger.info(
|
||||
f"[{sym}] 🚨 信号[{strategy_name}]: {result['signal']} "
|
||||
f"score={result['score']} price={result['price']:.1f}"
|
||||
)
|
||||
# 模拟盘开仓(需该策略启用 + 跳过冷启动)
|
||||
if is_strategy_enabled(strategy_name) and warmup_cycles <= 0:
|
||||
if not paper_has_active_position(sym, strategy_name):
|
||||
active_count = paper_active_count(strategy_name)
|
||||
if active_count < PAPER_MAX_POSITIONS:
|
||||
tier = result.get("tier", "standard")
|
||||
paper_open_trade(
|
||||
sym,
|
||||
result["signal"],
|
||||
result["price"],
|
||||
result["score"],
|
||||
tier,
|
||||
result["atr"],
|
||||
now_ms,
|
||||
factors=result.get("factors"),
|
||||
strategy=strategy_name,
|
||||
tp_sl=strategy_cfg.get("tp_sl"),
|
||||
)
|
||||
if result.get("signal"):
|
||||
logger.info(f"[{sym}] 🚨 信号: {result['signal']} score={result['score']} price={result['price']:.1f}")
|
||||
# 模拟盘开仓(需开关开启 + 跳过冷启动)
|
||||
if PAPER_TRADING_ENABLED and warmup_cycles <= 0:
|
||||
if not paper_has_active_position(sym):
|
||||
active_count = paper_active_count()
|
||||
if active_count < PAPER_MAX_POSITIONS:
|
||||
tier = result.get("tier", "standard")
|
||||
paper_open_trade(
|
||||
sym, result["signal"], result["price"],
|
||||
result["score"], tier,
|
||||
result["atr"], now_ms,
|
||||
factors=result.get("factors")
|
||||
)
|
||||
|
||||
# 模拟盘持仓检查由paper_monitor.py通过WebSocket实时处理,这里不再检查
|
||||
|
||||
|
||||
@ -1,19 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@ -1,19 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@ -9,8 +9,6 @@
|
||||
--muted: #64748b;
|
||||
--primary: #2563eb;
|
||||
--primary-foreground: #ffffff;
|
||||
--font-geist-sans: "Segoe UI", "PingFang SC", "Noto Sans", sans-serif;
|
||||
--font-geist-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
|
||||
@ -1,9 +1,13 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import { AuthProvider } from "@/lib/auth";
|
||||
import AuthHeader from "@/components/AuthHeader";
|
||||
|
||||
const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] });
|
||||
const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Arbitrage Engine",
|
||||
description: "Funding rate arbitrage monitoring system",
|
||||
@ -12,7 +16,7 @@ export const metadata: Metadata = {
|
||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<html lang="zh">
|
||||
<body className="antialiased min-h-screen bg-slate-50 text-slate-900">
|
||||
<body className={`${geistSans.variable} ${geistMono.variable} antialiased min-h-screen bg-slate-50 text-slate-900`}>
|
||||
<AuthProvider>
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar />
|
||||
|
||||
@ -1,10 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, Suspense } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { authFetch, useAuth } from "@/lib/auth";
|
||||
import { XAxis, YAxis, Tooltip, ResponsiveContainer, ReferenceLine, Area, AreaChart } from "recharts";
|
||||
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, ReferenceLine, Area, AreaChart } from "recharts";
|
||||
|
||||
// ─── 工具函数 ────────────────────────────────────────────────────
|
||||
|
||||
@ -17,54 +15,6 @@ function fmtPrice(p: number) {
|
||||
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() {
|
||||
@ -72,12 +22,7 @@ function ControlPanel() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
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();
|
||||
}, []);
|
||||
|
||||
@ -89,11 +34,8 @@ function ControlPanel() {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled: !config.enabled }),
|
||||
});
|
||||
if (r.ok) setConfig(await r.json().then((j) => j.config));
|
||||
} catch {
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
if (r.ok) setConfig(await r.json().then(j => j.config));
|
||||
} catch {} finally { setSaving(false); }
|
||||
};
|
||||
|
||||
if (!config) return null;
|
||||
@ -101,13 +43,12 @@ function ControlPanel() {
|
||||
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="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 ${
|
||||
config.enabled ? "bg-red-500 text-white hover:bg-red-600" : "bg-emerald-500 text-white hover:bg-emerald-600"
|
||||
}`}
|
||||
>
|
||||
config.enabled
|
||||
? "bg-red-500 text-white hover:bg-red-600"
|
||||
: "bg-emerald-500 text-white hover:bg-emerald-600"
|
||||
}`}>
|
||||
{saving ? "..." : config.enabled ? "⏹ 停止模拟盘" : "▶️ 启动模拟盘"}
|
||||
</button>
|
||||
<span className={`text-xs font-medium ${config.enabled ? "text-emerald-700" : "text-slate-500"}`}>
|
||||
@ -125,40 +66,23 @@ function ControlPanel() {
|
||||
|
||||
// ─── 总览面板 ────────────────────────────────────────────────────
|
||||
|
||||
function SummaryCards({ strategy }: { strategy: StrategyFilter }) {
|
||||
function SummaryCards() {
|
||||
const [data, setData] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const f = async () => {
|
||||
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]);
|
||||
|
||||
const f = async () => { try { const r = await authFetch("/api/paper/summary"); if (r.ok) setData(await r.json()); } catch {} };
|
||||
f(); const iv = setInterval(f, 10000); return () => clearInterval(iv);
|
||||
}, []);
|
||||
if (!data) return <div className="text-center text-slate-400 text-sm py-4">加载中...</div>;
|
||||
|
||||
return (
|
||||
<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">
|
||||
<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 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={`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 text-[10px] ${data.total_pnl_usdt >= 0 ? "text-emerald-500" : "text-red-400"}`}>
|
||||
{data.total_pnl_usdt >= 0 ? "+" : ""}${data.total_pnl_usdt}
|
||||
</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 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 className="bg-white rounded-lg border border-slate-200 px-2.5 py-2">
|
||||
<p className="text-[10px] text-slate-400">胜率</p>
|
||||
@ -194,19 +118,17 @@ function LatestSignals() {
|
||||
const f = async () => {
|
||||
for (const sym of COINS) {
|
||||
try {
|
||||
const r = await authFetch(`/api/signals/signal-history?symbol=${sym.replace("USDT", "")}&limit=1`);
|
||||
const r = await authFetch(`/api/signals/signal-history?symbol=${sym.replace("USDT","")}&limit=1`);
|
||||
if (r.ok) {
|
||||
const j = await r.json();
|
||||
if (j.data && j.data.length > 0) {
|
||||
setSignals((prev) => ({ ...prev, [sym]: j.data[0] }));
|
||||
setSignals(prev => ({ ...prev, [sym]: j.data[0] }));
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
};
|
||||
f();
|
||||
const iv = setInterval(f, 15000);
|
||||
return () => clearInterval(iv);
|
||||
f(); const iv = setInterval(f, 15000); return () => clearInterval(iv);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@ -215,7 +137,7 @@ function LatestSignals() {
|
||||
<h3 className="font-semibold text-slate-800 text-xs">最新信号</h3>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-50">
|
||||
{COINS.map((sym) => {
|
||||
{COINS.map(sym => {
|
||||
const s = signals[sym];
|
||||
const coin = sym.replace("USDT", "");
|
||||
const ago = s?.ts ? Math.round((Date.now() - s.ts) / 60000) : null;
|
||||
@ -234,7 +156,7 @@ function LatestSignals() {
|
||||
<span className="text-[10px] text-slate-400">⚪ 无信号</span>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
@ -245,27 +167,19 @@ function LatestSignals() {
|
||||
|
||||
// ─── 当前持仓 ────────────────────────────────────────────────────
|
||||
|
||||
function ActivePositions({ strategy }: { strategy: StrategyFilter }) {
|
||||
function ActivePositions() {
|
||||
const [positions, setPositions] = useState<any[]>([]);
|
||||
const [wsPrices, setWsPrices] = useState<Record<string, number>>({});
|
||||
|
||||
// 从API获取持仓列表(10秒刷新)
|
||||
useEffect(() => {
|
||||
const f = async () => {
|
||||
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]);
|
||||
const f = async () => { try { const r = await authFetch("/api/paper/positions"); if (r.ok) { const j = await r.json(); setPositions(j.data || []); } } catch {} };
|
||||
f(); const iv = setInterval(f, 10000); return () => clearInterval(iv);
|
||||
}, []);
|
||||
|
||||
// WebSocket实时价格(aggTrade逐笔成交)
|
||||
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}`);
|
||||
ws.onmessage = (e) => {
|
||||
try {
|
||||
@ -273,62 +187,49 @@ function ActivePositions({ strategy }: { strategy: StrategyFilter }) {
|
||||
if (msg.data) {
|
||||
const sym = msg.data.s;
|
||||
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 {}
|
||||
};
|
||||
return () => ws.close();
|
||||
}, []);
|
||||
|
||||
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">
|
||||
{strategy === "all" ? "暂无活跃持仓" : `${strategyName(strategy)} 暂无活跃持仓`}
|
||||
</div>
|
||||
);
|
||||
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>
|
||||
);
|
||||
|
||||
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">
|
||||
当前持仓 <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 className="divide-y divide-slate-100">
|
||||
{positions.map((p: any) => {
|
||||
const sym = p.symbol?.replace("USDT", "") || "";
|
||||
const holdMin = Math.round((Date.now() - p.entry_ts) / 60000);
|
||||
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 atr = p.atr_at_entry || 1;
|
||||
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 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 unrealUsdt = unrealR * 200;
|
||||
const isV52 = normalizeStrategy(p.strategy) === "v52_8signals";
|
||||
return (
|
||||
<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 gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div key={p.id} className="px-3 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-xs font-bold ${p.direction === "LONG" ? "text-emerald-600" : "text-red-500"}`}>
|
||||
{p.direction === "LONG" ? "🟢" : "🔴"} {sym} {p.direction}
|
||||
</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>
|
||||
)}
|
||||
<span className="text-[10px] text-slate-400">评分{p.score} · {p.tier === "heavy" ? "加仓" : p.tier === "standard" ? "标准" : "轻仓"}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<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 className={`font-mono text-[10px] ${unrealUsdt >= 0 ? "text-emerald-500" : "text-red-400"}`}>
|
||||
({unrealUsdt >= 0 ? "+" : ""}${unrealUsdt.toFixed(0)})
|
||||
@ -336,20 +237,13 @@ function ActivePositions({ strategy }: { strategy: StrategyFilter }) {
|
||||
<span className="text-[10px] text-slate-400">{holdMin}m</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 mt-1 text-[10px] font-mono text-slate-600 flex-wrap">
|
||||
<div className="flex gap-3 mt-1 text-[10px] font-mono text-slate-600">
|
||||
<span>入场: ${fmtPrice(p.entry_price)}</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">TP2: ${fmtPrice(p.tp2_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>
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
@ -360,44 +254,31 @@ function ActivePositions({ strategy }: { strategy: StrategyFilter }) {
|
||||
|
||||
// ─── 权益曲线 ────────────────────────────────────────────────────
|
||||
|
||||
function EquityCurve({ strategy }: { strategy: StrategyFilter }) {
|
||||
function EquityCurve() {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
|
||||
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]);
|
||||
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;
|
||||
|
||||
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">权益曲线 (累计PnL)</h3>
|
||||
</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 }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={data}>
|
||||
<XAxis dataKey="ts" tickFormatter={(v) => bjt(v)} tick={{ fontSize: 10 }} />
|
||||
<YAxis tick={{ fontSize: 10 }} tickFormatter={(v) => `${v}R`} />
|
||||
<Tooltip labelFormatter={(v) => bjt(Number(v))} formatter={(v: any) => [`${v}R`, "累计PnL"]} />
|
||||
<ReferenceLine y={0} stroke="#94a3b8" strokeDasharray="3 3" />
|
||||
<Area type="monotone" dataKey="pnl" stroke="#10b981" fill="#d1fae5" strokeWidth={2} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-2" style={{ height: 200 }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={data}>
|
||||
<XAxis dataKey="ts" tickFormatter={(v) => bjt(v)} tick={{ fontSize: 10 }} />
|
||||
<YAxis tick={{ fontSize: 10 }} tickFormatter={(v) => `${v}R`} />
|
||||
<Tooltip labelFormatter={(v) => bjt(Number(v))} formatter={(v: any) => [`${v}R`, "累计PnL"]} />
|
||||
<ReferenceLine y={0} stroke="#94a3b8" strokeDasharray="3 3" />
|
||||
<Area type="monotone" dataKey="pnl" stroke="#10b981" fill="#d1fae5" strokeWidth={2} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -407,7 +288,7 @@ function EquityCurve({ strategy }: { strategy: StrategyFilter }) {
|
||||
type FilterSymbol = "all" | "BTC" | "ETH" | "XRP" | "SOL";
|
||||
type FilterResult = "all" | "win" | "loss";
|
||||
|
||||
function TradeHistory({ strategy }: { strategy: StrategyFilter }) {
|
||||
function TradeHistory() {
|
||||
const [trades, setTrades] = useState<any[]>([]);
|
||||
const [symbol, setSymbol] = useState<FilterSymbol>("all");
|
||||
const [result, setResult] = useState<FilterResult>("all");
|
||||
@ -415,43 +296,28 @@ function TradeHistory({ strategy }: { strategy: StrategyFilter }) {
|
||||
useEffect(() => {
|
||||
const f = async () => {
|
||||
try {
|
||||
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 || []);
|
||||
}
|
||||
const r = await authFetch(`/api/paper/trades?symbol=${symbol}&result=${result}&limit=50`);
|
||||
if (r.ok) { const j = await r.json(); setTrades(j.data || []); }
|
||||
} catch {}
|
||||
};
|
||||
f();
|
||||
const iv = setInterval(f, 10000);
|
||||
return () => clearInterval(iv);
|
||||
}, [symbol, result, strategy]);
|
||||
f(); const iv = setInterval(f, 10000); return () => clearInterval(iv);
|
||||
}, [symbol, result]);
|
||||
|
||||
return (
|
||||
<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">
|
||||
<h3 className="font-semibold text-slate-800 text-xs">历史交易</h3>
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
<span className={`px-2 py-0.5 rounded text-[10px] font-semibold ${strategy === "all" ? "bg-slate-100 text-slate-600" : strategyBadgeClass(strategy)}`}>
|
||||
{strategy === "all" ? "全部策略" : `${strategyBadgeText(strategy)} 视图`}
|
||||
</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"}`}
|
||||
>
|
||||
<div className="flex gap-1">
|
||||
{(["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}
|
||||
</button>
|
||||
))}
|
||||
<span className="text-slate-300">|</span>
|
||||
{(["all", "win", "loss"] as FilterResult[]).map((r) => (
|
||||
<button
|
||||
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"}`}
|
||||
>
|
||||
{(["all", "win", "loss"] as FilterResult[]).map(r => (
|
||||
<button 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" ? "盈利" : "亏损"}
|
||||
</button>
|
||||
))}
|
||||
@ -465,7 +331,6 @@ function TradeHistory({ strategy }: { strategy: StrategyFilter }) {
|
||||
<thead className="bg-slate-50 sticky top-0">
|
||||
<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-right font-medium">入场</th>
|
||||
<th className="px-2 py-1.5 text-right font-medium">出场</th>
|
||||
@ -478,58 +343,29 @@ function TradeHistory({ strategy }: { strategy: StrategyFilter }) {
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{trades.map((t: any) => {
|
||||
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 (
|
||||
<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 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"}`}>
|
||||
{t.direction === "LONG" ? "🟢" : "🔴"} {t.direction}
|
||||
</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 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 className="px-2 py-1.5 text-center">
|
||||
<span
|
||||
className={`px-1 py-0.5 rounded text-[9px] ${
|
||||
t.status === "tp"
|
||||
? "bg-emerald-100 text-emerald-700"
|
||||
: t.status === "sl"
|
||||
? "bg-red-100 text-red-700"
|
||||
: t.status === "sl_be"
|
||||
? "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 className={`px-1 py-0.5 rounded text-[9px] ${
|
||||
t.status === "tp" ? "bg-emerald-100 text-emerald-700" :
|
||||
t.status === "sl" ? "bg-red-100 text-red-700" :
|
||||
t.status === "sl_be" ? "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>
|
||||
</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 font-mono">{t.score}</td>
|
||||
<td className="px-2 py-1.5 text-right text-slate-400">{holdMin}m</td>
|
||||
</tr>
|
||||
);
|
||||
@ -544,36 +380,15 @@ function TradeHistory({ strategy }: { strategy: StrategyFilter }) {
|
||||
|
||||
// ─── 统计面板 ────────────────────────────────────────────────────
|
||||
|
||||
function StatsPanel({ strategy }: { strategy: StrategyFilter }) {
|
||||
function StatsPanel() {
|
||||
const [data, setData] = useState<any>(null);
|
||||
const [tab, setTab] = useState("ALL");
|
||||
|
||||
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]);
|
||||
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);
|
||||
}, []);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
if (!data || data.error) return null;
|
||||
|
||||
const tabs = ["ALL", "BTC", "ETH", "XRP", "SOL"];
|
||||
const st = tab === "ALL" ? data : (data.by_symbol?.[tab] || null);
|
||||
@ -582,72 +397,29 @@ function StatsPanel({ strategy }: { strategy: StrategyFilter }) {
|
||||
<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">
|
||||
<h3 className="font-semibold text-slate-800 text-xs">详细统计</h3>
|
||||
<div className="flex items-center gap-1">
|
||||
{strategy !== "all" && <span className={`px-2 py-0.5 rounded text-[10px] font-semibold ${strategyBadgeClass(strategy)}`}>{strategyBadgeText(strategy)}</span>}
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
<div className="flex gap-1">
|
||||
{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"}`}
|
||||
>
|
||||
{t === "ALL" ? "总计" : t}
|
||||
</button>
|
||||
>{t === "ALL" ? "总计" : t}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{st ? (
|
||||
<div className="p-3 space-y-3">
|
||||
<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_rate}%</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-400">盈亏比</span>
|
||||
<p className="font-mono font-bold">{st.win_loss_ratio}</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]) => (
|
||||
<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 className="p-3 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_rate}%</p></div>
|
||||
<div><span className="text-slate-400">盈亏比</span><p className="font-mono font-bold">{st.win_loss_ratio}</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]) => (
|
||||
<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 className="p-3 text-xs text-slate-400">该币种暂无数据</div>
|
||||
@ -658,74 +430,33 @@ function StatsPanel({ strategy }: { strategy: StrategyFilter }) {
|
||||
|
||||
// ─── 主页面 ──────────────────────────────────────────────────────
|
||||
|
||||
function PaperTradingPageInner() {
|
||||
export default function PaperTradingPage() {
|
||||
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 (!isLoggedIn)
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-64 gap-4">
|
||||
<div className="text-5xl">🔒</div>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
if (!isLoggedIn) return (
|
||||
<div className="flex flex-col items-center justify-center h-64 gap-4">
|
||||
<div className="text-5xl">🔒</div>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<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>
|
||||
<h1 className="text-lg font-bold text-slate-900">📊 模拟盘</h1>
|
||||
<p className="text-[10px] text-slate-500">V5.2策略AB测试 · 实时追踪 · 数据驱动优化 · {strategyTabDescription(strategyTab)}</p>
|
||||
<p className="text-[10px] text-slate-500">V5.1信号引擎自动交易 · 实时追踪 · 数据驱动优化</p>
|
||||
</div>
|
||||
|
||||
<ControlPanel />
|
||||
<SummaryCards strategy={strategyTab} />
|
||||
<SummaryCards />
|
||||
<LatestSignals />
|
||||
<ActivePositions strategy={strategyTab} />
|
||||
<EquityCurve strategy={strategyTab} />
|
||||
<TradeHistory strategy={strategyTab} />
|
||||
<StatsPanel strategy={strategyTab} />
|
||||
<ActivePositions />
|
||||
<EquityCurve />
|
||||
<TradeHistory />
|
||||
<StatsPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PaperTradingPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="text-center text-slate-400 py-8">加载中...</div>}>
|
||||
<PaperTradingPageInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@ -47,23 +47,6 @@ interface LatestIndicator {
|
||||
} | 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 {
|
||||
value: Record<string, unknown>;
|
||||
ts: number;
|
||||
@ -98,14 +81,6 @@ function pct(v: number, digits = 1): string {
|
||||
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 }) {
|
||||
const ratio = Math.max(0, Math.min((score / max) * 100, 100));
|
||||
return (
|
||||
@ -119,73 +94,6 @@ 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 }) {
|
||||
const [data, setData] = useState<MarketIndicatorSet | null>(null);
|
||||
|
||||
@ -528,8 +436,8 @@ export default function SignalsPage() {
|
||||
{/* 标题 */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-slate-900">⚡ 信号引擎 V5.1 vs V5.2</h1>
|
||||
<p className="text-slate-500 text-[10px]">并排评分对比 · V5.2 含 Funding Rate / Liquidation 额外维度</p>
|
||||
<h1 className="text-lg font-bold text-slate-900">⚡ 信号引擎 V5.1</h1>
|
||||
<p className="text-slate-500 text-[10px]">五层100分评分 · 市场拥挤度 · 环境确认</p>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{(["BTC", "ETH", "XRP", "SOL"] as Symbol[]).map(s => (
|
||||
@ -541,8 +449,6 @@ export default function SignalsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LatestStrategyComparison />
|
||||
|
||||
{/* 实时指标卡片 */}
|
||||
<IndicatorCards symbol={symbol} />
|
||||
|
||||
|
||||
@ -7,16 +7,14 @@ import { useAuth } from "@/lib/auth";
|
||||
import {
|
||||
LayoutDashboard, Info,
|
||||
Menu, X, Zap, LogIn, UserPlus,
|
||||
ChevronLeft, ChevronRight, Activity, LogOut, Crosshair, Monitor, LineChart, Sparkles, FlaskConical
|
||||
ChevronLeft, ChevronRight, Activity, LogOut, Crosshair, Monitor, LineChart
|
||||
} from "lucide-react";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/", label: "仪表盘", icon: LayoutDashboard },
|
||||
{ href: "/trades", label: "成交流", icon: Activity },
|
||||
{ href: "/signals", label: "信号引擎", icon: Crosshair, section: "信号" },
|
||||
{ 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: "/signals", label: "信号引擎 V5.1", icon: Crosshair },
|
||||
{ href: "/paper", label: "模拟盘", icon: LineChart },
|
||||
{ href: "/server", label: "服务器", icon: Monitor },
|
||||
{ href: "/about", label: "说明", icon: Info },
|
||||
];
|
||||
@ -39,29 +37,17 @@ export default function Sidebar() {
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 py-4 space-y-1 px-2">
|
||||
{navItems.map(({ href, label, icon: Icon, section, badge }, idx) => {
|
||||
const active = pathname === href || (href.includes("?") && pathname === href.split("?")[0] && typeof window !== "undefined" && window.location.search === "?" + href.split("?")[1]);
|
||||
{navItems.map(({ href, label, icon: Icon }) => {
|
||||
const active = pathname === href;
|
||||
return (
|
||||
<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)}
|
||||
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"}
|
||||
${collapsed && !mobile ? "justify-center" : ""}`}>
|
||||
<Icon className={`shrink-0 ${active ? "text-blue-600" : "text-slate-400"}`} size={18} />
|
||||
{(!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>
|
||||
</div>
|
||||
<Link key={href} href={href}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
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"}
|
||||
${collapsed && !mobile ? "justify-center" : ""}`}>
|
||||
<Icon className={`shrink-0 ${active ? "text-blue-600" : "text-slate-400"}`} size={18} />
|
||||
{(!collapsed || mobile) && <span>{label}</span>}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{/* 手机端:登录/登出放在菜单里 */}
|
||||
|
||||
16
frontend/package-lock.json
generated
16
frontend/package-lock.json
generated
@ -10,7 +10,6 @@
|
||||
"dependencies": {
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lightweight-charts": "^5.0.0",
|
||||
"lucide-react": "^0.575.0",
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.3",
|
||||
@ -3766,12 +3765,6 @@
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"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": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@ -5134,15 +5127,6 @@
|
||||
"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": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user