Compare commits

..

13 Commits

Author SHA1 Message Date
root
7cd053c345 docs: add full project spec for Codex rewrite 2026-03-12 14:34:59 +00:00
root
569c192448 fix: restore custom_ route to _evaluate_v53 (was lost in scp overwrites) 2026-03-12 13:43:24 +00:00
root
07bf1a8a58 fix: strategy only opens positions on its own configured symbol (V5.4) 2026-03-12 13:19:29 +00:00
root
cb34b1cb39 feat: dynamic CVD window per strategy + full generic signal/paper pages for V5.4 Strategy Factory 2026-03-12 13:03:55 +00:00
root
a4bb7828f8 fix: route custom_ strategy names to _evaluate_v53() for V5.4 Strategy Factory 2026-03-12 12:30:09 +00:00
root
89a6809c20 fix: 详情页5门显示修undefined bug,表单加per-symbol推荐值,切换symbol自动填推荐 2026-03-11 16:44:25 +00:00
root
2e4c05b2e0 feat(v54b): 5门Gate重构 — 与signal_engine执行逻辑完全对应,DB字段改名,前后端统一 2026-03-11 16:26:44 +00:00
root
06f900b89b fix: _get_strategy_trade_stats 兼容旧数据(strategy文本)和新数据(strategy_id),修复open_positions计算(含tp1_hit),current_balance实时计算 2026-03-11 16:02:04 +00:00
root
d3784aaf79 feat(v54): signal_engine reads strategy config from DB, writes strategy_id to paper_trades/signal_indicators 2026-03-11 15:46:30 +00:00
root
06552c2b75 fix: gate toggle button overflow, white dot stays within bounds 2026-03-11 15:29:37 +00:00
root
f8f13a48d5 feat(v54-frontend): add create/edit/deprecated pages, config tab, sidebar entry 2026-03-11 15:23:56 +00:00
root
9d44885188 test: verify all 9 V5.4 strategy API endpoints pass 2026-03-11 15:15:14 +00:00
root
7be7b5b4c0 feat(v54): add strategies table, migration script, 9 CRUD API endpoints 2026-03-11 15:11:44 +00:00
16 changed files with 4670 additions and 202 deletions

View File

@ -688,10 +688,32 @@ async def paper_set_config(request: Request, user: dict = Depends(get_current_us
@app.get("/api/paper/summary") @app.get("/api/paper/summary")
async def paper_summary( async def paper_summary(
strategy: str = "all", strategy: str = "all",
strategy_id: str = "all",
user: dict = Depends(get_current_user), user: dict = Depends(get_current_user),
): ):
"""模拟盘总览""" """模拟盘总览"""
if strategy == "all": if strategy_id != "all":
closed = await async_fetch(
"SELECT pnl_r, direction FROM paper_trades "
"WHERE status NOT IN ('active','tp1_hit') AND strategy_id = $1",
strategy_id,
)
active = await async_fetch(
"SELECT id FROM paper_trades WHERE status IN ('active','tp1_hit') AND strategy_id = $1",
strategy_id,
)
first = await async_fetchrow(
"SELECT MIN(created_at) as start FROM paper_trades WHERE strategy_id = $1",
strategy_id,
)
# 从 strategies 表取该策略的 initial_balance
strat_row = await async_fetchrow(
"SELECT initial_balance FROM strategies WHERE strategy_id = $1",
strategy_id,
)
initial_balance = float(strat_row["initial_balance"]) if strat_row else paper_config["initial_balance"]
risk_per_trade = paper_config["risk_per_trade"]
elif strategy == "all":
closed = await async_fetch( closed = await async_fetch(
"SELECT pnl_r, direction FROM paper_trades WHERE status NOT IN ('active','tp1_hit')" "SELECT pnl_r, direction FROM paper_trades WHERE status NOT IN ('active','tp1_hit')"
) )
@ -699,6 +721,8 @@ async def paper_summary(
"SELECT id FROM paper_trades WHERE status IN ('active','tp1_hit')" "SELECT id FROM paper_trades WHERE status IN ('active','tp1_hit')"
) )
first = await async_fetchrow("SELECT MIN(created_at) as start FROM paper_trades") first = await async_fetchrow("SELECT MIN(created_at) as start FROM paper_trades")
initial_balance = paper_config["initial_balance"]
risk_per_trade = paper_config["risk_per_trade"]
else: else:
closed = await async_fetch( closed = await async_fetch(
"SELECT pnl_r, direction FROM paper_trades " "SELECT pnl_r, direction FROM paper_trades "
@ -713,13 +737,15 @@ async def paper_summary(
"SELECT MIN(created_at) as start FROM paper_trades WHERE strategy = $1", "SELECT MIN(created_at) as start FROM paper_trades WHERE strategy = $1",
strategy, strategy,
) )
initial_balance = paper_config["initial_balance"]
risk_per_trade = paper_config["risk_per_trade"]
total = len(closed) total = len(closed)
wins = len([r for r in closed if r["pnl_r"] > 0]) wins = len([r for r in closed if r["pnl_r"] > 0])
total_pnl = sum(r["pnl_r"] for r in closed) total_pnl = sum(r["pnl_r"] for r in closed)
paper_1r_usd = paper_config["initial_balance"] * paper_config["risk_per_trade"] paper_1r_usd = initial_balance * risk_per_trade
total_pnl_usdt = total_pnl * paper_1r_usd total_pnl_usdt = total_pnl * paper_1r_usd
balance = paper_config["initial_balance"] + total_pnl_usdt balance = initial_balance + total_pnl_usdt
win_rate = (wins / total * 100) if total > 0 else 0 win_rate = (wins / total * 100) if total > 0 else 0
gross_profit = sum(r["pnl_r"] for r in closed if r["pnl_r"] > 0) gross_profit = sum(r["pnl_r"] for r in closed if r["pnl_r"] > 0)
gross_loss = abs(sum(r["pnl_r"] for r in closed if r["pnl_r"] <= 0)) gross_loss = abs(sum(r["pnl_r"] for r in closed if r["pnl_r"] <= 0))
@ -740,18 +766,26 @@ async def paper_summary(
@app.get("/api/paper/positions") @app.get("/api/paper/positions")
async def paper_positions( async def paper_positions(
strategy: str = "all", strategy: str = "all",
strategy_id: str = "all",
user: dict = Depends(get_current_user), user: dict = Depends(get_current_user),
): ):
"""当前活跃持仓(含实时价格和浮动盈亏)""" """当前活跃持仓(含实时价格和浮动盈亏)"""
if strategy == "all": if strategy_id != "all":
rows = await async_fetch( rows = await async_fetch(
"SELECT id, symbol, direction, score, tier, strategy, entry_price, entry_ts, " "SELECT id, symbol, direction, score, tier, strategy, strategy_id, entry_price, entry_ts, "
"tp1_price, tp2_price, sl_price, tp1_hit, status, atr_at_entry, score_factors, risk_distance "
"FROM paper_trades WHERE status IN ('active','tp1_hit') AND strategy_id = $1 ORDER BY entry_ts DESC",
strategy_id,
)
elif strategy == "all":
rows = await async_fetch(
"SELECT id, symbol, direction, score, tier, strategy, strategy_id, entry_price, entry_ts, "
"tp1_price, tp2_price, sl_price, tp1_hit, status, atr_at_entry, score_factors, risk_distance " "tp1_price, tp2_price, sl_price, tp1_hit, status, atr_at_entry, score_factors, risk_distance "
"FROM paper_trades WHERE status IN ('active','tp1_hit') ORDER BY entry_ts DESC" "FROM paper_trades WHERE status IN ('active','tp1_hit') ORDER BY entry_ts DESC"
) )
else: else:
rows = await async_fetch( rows = await async_fetch(
"SELECT id, symbol, direction, score, tier, strategy, entry_price, entry_ts, " "SELECT id, symbol, direction, score, tier, strategy, strategy_id, entry_price, entry_ts, "
"tp1_price, tp2_price, sl_price, tp1_hit, status, atr_at_entry, score_factors, risk_distance " "tp1_price, tp2_price, sl_price, tp1_hit, status, atr_at_entry, score_factors, risk_distance "
"FROM paper_trades WHERE status IN ('active','tp1_hit') AND strategy = $1 ORDER BY entry_ts DESC", "FROM paper_trades WHERE status IN ('active','tp1_hit') AND strategy = $1 ORDER BY entry_ts DESC",
strategy, strategy,
@ -809,6 +843,7 @@ async def paper_trades(
symbol: str = "all", symbol: str = "all",
result: str = "all", result: str = "all",
strategy: str = "all", strategy: str = "all",
strategy_id: str = "all",
limit: int = 100, limit: int = 100,
user: dict = Depends(get_current_user), user: dict = Depends(get_current_user),
): ):
@ -827,7 +862,11 @@ async def paper_trades(
elif result == "loss": elif result == "loss":
conditions.append("pnl_r <= 0") conditions.append("pnl_r <= 0")
if strategy != "all": if strategy_id != "all":
conditions.append(f"strategy_id = ${idx}")
params.append(strategy_id)
idx += 1
elif strategy != "all":
conditions.append(f"strategy = ${idx}") conditions.append(f"strategy = ${idx}")
params.append(strategy) params.append(strategy)
idx += 1 idx += 1
@ -835,7 +874,7 @@ async def paper_trades(
where = " AND ".join(conditions) where = " AND ".join(conditions)
params.append(limit) params.append(limit)
rows = await async_fetch( rows = await async_fetch(
f"SELECT id, symbol, direction, score, tier, strategy, entry_price, exit_price, " f"SELECT id, symbol, direction, score, tier, strategy, strategy_id, entry_price, exit_price, "
f"entry_ts, exit_ts, pnl_r, status, tp1_hit, score_factors " f"entry_ts, exit_ts, pnl_r, status, tp1_hit, score_factors "
f"FROM paper_trades WHERE {where} ORDER BY exit_ts DESC LIMIT ${idx}", f"FROM paper_trades WHERE {where} ORDER BY exit_ts DESC LIMIT ${idx}",
*params *params
@ -846,10 +885,17 @@ async def paper_trades(
@app.get("/api/paper/equity-curve") @app.get("/api/paper/equity-curve")
async def paper_equity_curve( async def paper_equity_curve(
strategy: str = "all", strategy: str = "all",
strategy_id: str = "all",
user: dict = Depends(get_current_user), user: dict = Depends(get_current_user),
): ):
"""权益曲线""" """权益曲线"""
if strategy == "all": if strategy_id != "all":
rows = await async_fetch(
"SELECT exit_ts, pnl_r FROM paper_trades "
"WHERE status NOT IN ('active','tp1_hit') AND strategy_id = $1 ORDER BY exit_ts ASC",
strategy_id,
)
elif strategy == "all":
rows = await async_fetch( rows = await async_fetch(
"SELECT exit_ts, pnl_r FROM paper_trades " "SELECT exit_ts, pnl_r FROM paper_trades "
"WHERE status NOT IN ('active','tp1_hit') ORDER BY exit_ts ASC" "WHERE status NOT IN ('active','tp1_hit') ORDER BY exit_ts ASC"
@ -871,10 +917,17 @@ async def paper_equity_curve(
@app.get("/api/paper/stats") @app.get("/api/paper/stats")
async def paper_stats( async def paper_stats(
strategy: str = "all", strategy: str = "all",
strategy_id: str = "all",
user: dict = Depends(get_current_user), user: dict = Depends(get_current_user),
): ):
"""详细统计""" """详细统计"""
if strategy == "all": if strategy_id != "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') AND strategy_id = $1",
strategy_id,
)
elif strategy == "all":
rows = await async_fetch( rows = await async_fetch(
"SELECT symbol, direction, pnl_r, tier, entry_ts, exit_ts " "SELECT symbol, direction, pnl_r, tier, entry_ts, exit_ts "
"FROM paper_trades WHERE status NOT IN ('active','tp1_hit')" "FROM paper_trades WHERE status NOT IN ('active','tp1_hit')"
@ -2179,3 +2232,614 @@ async def strategy_plaza_trades(
strategy_id, limit strategy_id, limit
) )
return {"trades": [dict(r) for r in rows]} return {"trades": [dict(r) for r in rows]}
# ─────────────────────────────────────────────────────────────────────────────
# V5.4 Strategy Factory API
# ─────────────────────────────────────────────────────────────────────────────
import uuid as _uuid
from typing import Optional
from pydantic import BaseModel, Field, field_validator, model_validator
# ── Pydantic Models ──────────────────────────────────────────────────────────
class StrategyCreateRequest(BaseModel):
display_name: str = Field(..., min_length=1, max_length=50)
symbol: str
direction: str = "both"
initial_balance: float = 10000.0
cvd_fast_window: str = "30m"
cvd_slow_window: str = "4h"
weight_direction: int = 55
weight_env: int = 25
weight_aux: int = 15
weight_momentum: int = 5
entry_score: int = 75
# 门1 波动率
gate_vol_enabled: bool = True
vol_atr_pct_min: float = 0.002
# 门2 CVD共振
gate_cvd_enabled: bool = True
# 门3 鲸鱼否决
gate_whale_enabled: bool = True
whale_usd_threshold: float = 50000.0
whale_flow_pct: float = 0.5
# 门4 OBI否决
gate_obi_enabled: bool = True
obi_threshold: float = 0.35
# 门5 期现背离
gate_spot_perp_enabled: bool = False
spot_perp_threshold: float = 0.005
# 风控参数
sl_atr_multiplier: float = 1.5
tp1_ratio: float = 0.75
tp2_ratio: float = 1.5
timeout_minutes: int = 240
flip_threshold: int = 80
description: Optional[str] = None
@field_validator("symbol")
@classmethod
def validate_symbol(cls, v):
allowed = {"BTCUSDT", "ETHUSDT", "SOLUSDT", "XRPUSDT"}
if v not in allowed:
raise ValueError(f"symbol must be one of {allowed}")
return v
@field_validator("direction")
@classmethod
def validate_direction(cls, v):
if v not in {"long_only", "short_only", "both"}:
raise ValueError("direction must be long_only, short_only, or both")
return v
@field_validator("cvd_fast_window")
@classmethod
def validate_cvd_fast(cls, v):
if v not in {"5m", "15m", "30m"}:
raise ValueError("cvd_fast_window must be 5m, 15m, or 30m")
return v
@field_validator("cvd_slow_window")
@classmethod
def validate_cvd_slow(cls, v):
if v not in {"30m", "1h", "4h"}:
raise ValueError("cvd_slow_window must be 30m, 1h, or 4h")
return v
@field_validator("weight_direction")
@classmethod
def validate_w_dir(cls, v):
if not 10 <= v <= 80:
raise ValueError("weight_direction must be 10-80")
return v
@field_validator("weight_env")
@classmethod
def validate_w_env(cls, v):
if not 5 <= v <= 60:
raise ValueError("weight_env must be 5-60")
return v
@field_validator("weight_aux")
@classmethod
def validate_w_aux(cls, v):
if not 0 <= v <= 40:
raise ValueError("weight_aux must be 0-40")
return v
@field_validator("weight_momentum")
@classmethod
def validate_w_mom(cls, v):
if not 0 <= v <= 20:
raise ValueError("weight_momentum must be 0-20")
return v
@model_validator(mode="after")
def validate_weights_sum(self):
total = self.weight_direction + self.weight_env + self.weight_aux + self.weight_momentum
if total != 100:
raise ValueError(f"Weights must sum to 100, got {total}")
return self
@field_validator("entry_score")
@classmethod
def validate_entry_score(cls, v):
if not 60 <= v <= 95:
raise ValueError("entry_score must be 60-95")
return v
@field_validator("vol_atr_pct_min")
@classmethod
def validate_vol_atr(cls, v):
if not 0.0001 <= v <= 0.02:
raise ValueError("vol_atr_pct_min must be 0.0001-0.02")
return v
@field_validator("whale_usd_threshold")
@classmethod
def validate_whale_usd(cls, v):
if not 1000 <= v <= 1000000:
raise ValueError("whale_usd_threshold must be 1000-1000000")
return v
@field_validator("whale_flow_pct")
@classmethod
def validate_whale_flow(cls, v):
if not 0.0 <= v <= 1.0:
raise ValueError("whale_flow_pct must be 0.0-1.0")
return v
@field_validator("obi_threshold")
@classmethod
def validate_obi(cls, v):
if not 0.1 <= v <= 0.9:
raise ValueError("obi_threshold must be 0.1-0.9")
return v
@field_validator("spot_perp_threshold")
@classmethod
def validate_spot_perp(cls, v):
if not 0.0005 <= v <= 0.01:
raise ValueError("spot_perp_threshold must be 0.0005-0.01")
return v
@field_validator("sl_atr_multiplier")
@classmethod
def validate_sl(cls, v):
if not 0.5 <= v <= 3.0:
raise ValueError("sl_atr_multiplier must be 0.5-3.0")
return v
@field_validator("tp1_ratio")
@classmethod
def validate_tp1(cls, v):
if not 0.3 <= v <= 2.0:
raise ValueError("tp1_ratio must be 0.3-2.0")
return v
@field_validator("tp2_ratio")
@classmethod
def validate_tp2(cls, v):
if not 0.5 <= v <= 4.0:
raise ValueError("tp2_ratio must be 0.5-4.0")
return v
@field_validator("timeout_minutes")
@classmethod
def validate_timeout(cls, v):
if not 30 <= v <= 1440:
raise ValueError("timeout_minutes must be 30-1440")
return v
@field_validator("flip_threshold")
@classmethod
def validate_flip(cls, v):
if not 60 <= v <= 95:
raise ValueError("flip_threshold must be 60-95")
return v
@field_validator("initial_balance")
@classmethod
def validate_balance(cls, v):
if v < 1000:
raise ValueError("initial_balance must be >= 1000")
return v
class StrategyUpdateRequest(BaseModel):
"""Partial update - all fields optional"""
display_name: Optional[str] = Field(None, min_length=1, max_length=50)
direction: Optional[str] = None
cvd_fast_window: Optional[str] = None
cvd_slow_window: Optional[str] = None
weight_direction: Optional[int] = None
weight_env: Optional[int] = None
weight_aux: Optional[int] = None
weight_momentum: Optional[int] = None
entry_score: Optional[int] = None
# 门1 波动率
gate_vol_enabled: Optional[bool] = None
vol_atr_pct_min: Optional[float] = None
# 门2 CVD共振
gate_cvd_enabled: Optional[bool] = None
# 门3 鲸鱼否决
gate_whale_enabled: Optional[bool] = None
whale_usd_threshold: Optional[float] = None
whale_flow_pct: Optional[float] = None
# 门4 OBI否决
gate_obi_enabled: Optional[bool] = None
obi_threshold: Optional[float] = None
# 门5 期现背离
gate_spot_perp_enabled: Optional[bool] = None
spot_perp_threshold: Optional[float] = None
# 风控
sl_atr_multiplier: Optional[float] = None
tp1_ratio: Optional[float] = None
tp2_ratio: Optional[float] = None
timeout_minutes: Optional[int] = None
flip_threshold: Optional[int] = None
description: Optional[str] = None
class AddBalanceRequest(BaseModel):
amount: float = Field(..., gt=0)
class DeprecateRequest(BaseModel):
confirm: bool
# ── Helper ──────────────────────────────────────────────────────────────────
async def _get_strategy_or_404(strategy_id: str) -> dict:
row = await async_fetchrow(
"SELECT * FROM strategies WHERE strategy_id=$1",
strategy_id
)
if not row:
raise HTTPException(status_code=404, detail="Strategy not found")
return dict(row)
def _strategy_row_to_card(row: dict) -> dict:
"""Convert a strategies row to a card-level response (no config params)"""
return {
"strategy_id": str(row["strategy_id"]),
"display_name": row["display_name"],
"status": row["status"],
"symbol": row["symbol"],
"direction": row["direction"],
"started_at": int(row["created_at"].timestamp() * 1000) if row.get("created_at") else 0,
"initial_balance": row["initial_balance"],
"current_balance": row["current_balance"],
"net_usdt": round(row["current_balance"] - row["initial_balance"], 2),
"deprecated_at": int(row["deprecated_at"].timestamp() * 1000) if row.get("deprecated_at") else None,
"last_run_at": int(row["last_run_at"].timestamp() * 1000) if row.get("last_run_at") else None,
"schema_version": row["schema_version"],
}
def _strategy_row_to_detail(row: dict) -> dict:
"""Full detail including all config params"""
base = _strategy_row_to_card(row)
base.update({
"cvd_fast_window": row["cvd_fast_window"],
"cvd_slow_window": row["cvd_slow_window"],
"weight_direction": row["weight_direction"],
"weight_env": row["weight_env"],
"weight_aux": row["weight_aux"],
"weight_momentum": row["weight_momentum"],
"entry_score": row["entry_score"],
# 门1 波动率
"gate_vol_enabled": row["gate_vol_enabled"],
"vol_atr_pct_min": row["vol_atr_pct_min"],
# 门2 CVD共振
"gate_cvd_enabled": row["gate_cvd_enabled"],
# 门3 鲸鱼否决
"gate_whale_enabled": row["gate_whale_enabled"],
"whale_usd_threshold": row["whale_usd_threshold"],
"whale_flow_pct": row["whale_flow_pct"],
# 门4 OBI否决
"gate_obi_enabled": row["gate_obi_enabled"],
"obi_threshold": row["obi_threshold"],
# 门5 期现背离
"gate_spot_perp_enabled": row["gate_spot_perp_enabled"],
"spot_perp_threshold": row["spot_perp_threshold"],
"sl_atr_multiplier": row["sl_atr_multiplier"],
"tp1_ratio": row["tp1_ratio"],
"tp2_ratio": row["tp2_ratio"],
"timeout_minutes": row["timeout_minutes"],
"flip_threshold": row["flip_threshold"],
"description": row.get("description"),
"created_at": int(row["created_at"].timestamp() * 1000) if row.get("created_at") else 0,
"updated_at": int(row["updated_at"].timestamp() * 1000) if row.get("updated_at") else 0,
})
return base
async def _get_strategy_trade_stats(strategy_id: str) -> dict:
"""Fetch trade statistics for a strategy by strategy_id.
兼容新数据strategy_id列和旧数据strategy文本列
"""
# 固定 UUID → legacy strategy文本名映射迁移时写死的三条策略
LEGACY_NAME_MAP = {
"00000000-0000-0000-0000-000000000053": "v53",
"00000000-0000-0000-0000-000000000054": "v53_middle",
"00000000-0000-0000-0000-000000000055": "v53_fast",
}
legacy_name = LEGACY_NAME_MAP.get(strategy_id)
# 查已关闭的交易记录(同时兼容新旧两种匹配方式)
if legacy_name:
rows = await async_fetch(
"""SELECT status, pnl_r, tp1_hit, entry_ts, exit_ts
FROM paper_trades
WHERE status NOT IN ('active', 'tp1_hit')
AND (strategy_id=$1 OR (strategy_id IS NULL AND strategy=$2))
ORDER BY entry_ts DESC""",
strategy_id, legacy_name
)
else:
rows = await async_fetch(
"""SELECT status, pnl_r, tp1_hit, entry_ts, exit_ts
FROM paper_trades
WHERE strategy_id=$1 AND status NOT IN ('active', 'tp1_hit')
ORDER BY entry_ts DESC""",
strategy_id
)
if not rows:
# 即使没有历史记录也要查持仓
pass
total = len(rows)
wins = [r for r in rows if (r["pnl_r"] or 0) > 0]
losses = [r for r in rows if (r["pnl_r"] or 0) < 0]
win_rate = round(len(wins) / total * 100, 1) if total else 0.0
avg_win = round(sum(r["pnl_r"] for r in wins) / len(wins), 3) if wins else 0.0
avg_loss = round(sum(r["pnl_r"] for r in losses) / len(losses), 3) if losses else 0.0
last_trade_at = rows[0]["exit_ts"] if rows else None
# 24h stats
cutoff_ms = int((datetime.utcnow() - timedelta(hours=24)).timestamp() * 1000)
rows_24h = [r for r in rows if (r["exit_ts"] or 0) >= cutoff_ms]
pnl_r_24h = round(sum(r["pnl_r"] or 0 for r in rows_24h), 3)
pnl_usdt_24h = round(pnl_r_24h * 200, 2)
# Open positions — status IN ('active','tp1_hit'),同时兼容新旧记录
if legacy_name:
open_rows = await async_fetch(
"""SELECT COUNT(*) as cnt FROM paper_trades
WHERE status IN ('active','tp1_hit')
AND (strategy_id=$1 OR (strategy_id IS NULL AND strategy=$2))""",
strategy_id, legacy_name
)
else:
open_rows = await async_fetch(
"""SELECT COUNT(*) as cnt FROM paper_trades
WHERE strategy_id=$1 AND status IN ('active','tp1_hit')""",
strategy_id
)
open_positions = int(open_rows[0]["cnt"]) if open_rows else 0
return {
"trade_count": total,
"win_rate": win_rate,
"avg_win_r": avg_win,
"avg_loss_r": avg_loss,
"open_positions": open_positions,
"pnl_usdt_24h": pnl_usdt_24h,
"pnl_r_24h": pnl_r_24h,
"last_trade_at": last_trade_at,
"net_r": round(sum(r["pnl_r"] or 0 for r in rows), 3),
"net_usdt": round(sum(r["pnl_r"] or 0 for r in rows) * 200, 2),
}
# ── Endpoints ────────────────────────────────────────────────────────────────
@app.post("/api/strategies")
async def create_strategy(body: StrategyCreateRequest, user: dict = Depends(get_current_user)):
"""创建新策略实例"""
new_id = str(_uuid.uuid4())
await async_execute(
"""INSERT INTO strategies (
strategy_id, display_name, schema_version, status,
symbol, direction,
cvd_fast_window, cvd_slow_window,
weight_direction, weight_env, weight_aux, weight_momentum,
entry_score,
gate_vol_enabled, vol_atr_pct_min,
gate_cvd_enabled,
gate_whale_enabled, whale_usd_threshold, whale_flow_pct,
gate_obi_enabled, obi_threshold,
gate_spot_perp_enabled, spot_perp_threshold,
sl_atr_multiplier, tp1_ratio, tp2_ratio,
timeout_minutes, flip_threshold,
initial_balance, current_balance,
description
) VALUES (
$1,$2,1,'running',
$3,$4,$5,$6,
$7,$8,$9,$10,
$11,
$12,$13,
$14,
$15,$16,$17,
$18,$19,
$20,$21,
$22,$23,$24,
$25,$26,
$27,$27,$28
)""",
new_id, body.display_name,
body.symbol, body.direction, body.cvd_fast_window, body.cvd_slow_window,
body.weight_direction, body.weight_env, body.weight_aux, body.weight_momentum,
body.entry_score,
body.gate_vol_enabled, body.vol_atr_pct_min,
body.gate_cvd_enabled,
body.gate_whale_enabled, body.whale_usd_threshold, body.whale_flow_pct,
body.gate_obi_enabled, body.obi_threshold,
body.gate_spot_perp_enabled, body.spot_perp_threshold,
body.sl_atr_multiplier, body.tp1_ratio, body.tp2_ratio,
body.timeout_minutes, body.flip_threshold,
body.initial_balance, body.description
)
row = await async_fetchrow("SELECT * FROM strategies WHERE strategy_id=$1", new_id)
return {"ok": True, "strategy": _strategy_row_to_detail(dict(row))}
@app.get("/api/strategies")
async def list_strategies(
include_deprecated: bool = False,
user: dict = Depends(get_current_user)
):
"""获取策略列表"""
if include_deprecated:
rows = await async_fetch("SELECT * FROM strategies ORDER BY created_at ASC")
else:
rows = await async_fetch(
"SELECT * FROM strategies WHERE status != 'deprecated' ORDER BY created_at ASC"
)
result = []
for row in rows:
d = _strategy_row_to_card(dict(row))
stats = await _get_strategy_trade_stats(str(row["strategy_id"]))
d.update(stats)
# 用实时计算的 net_usdt 覆盖 DB 静态的 current_balance
d["current_balance"] = round(row["initial_balance"] + d["net_usdt"], 2)
result.append(d)
return {"strategies": result}
@app.get("/api/strategies/{sid}")
async def get_strategy(sid: str, user: dict = Depends(get_current_user)):
"""获取单个策略详情(含完整参数配置)"""
row = await _get_strategy_or_404(sid)
detail = _strategy_row_to_detail(row)
stats = await _get_strategy_trade_stats(sid)
detail.update(stats)
detail["current_balance"] = round(row["initial_balance"] + detail["net_usdt"], 2)
return {"strategy": detail}
@app.patch("/api/strategies/{sid}")
async def update_strategy(sid: str, body: StrategyUpdateRequest, user: dict = Depends(get_current_user)):
"""更新策略参数Partial Update"""
row = await _get_strategy_or_404(sid)
if row["status"] == "deprecated":
raise HTTPException(status_code=403, detail="Cannot modify a deprecated strategy")
# Build SET clause dynamically from non-None fields
updates = body.model_dump(exclude_none=True)
if not updates:
raise HTTPException(status_code=400, detail="No fields to update")
# Validate weights sum if any weight is being changed
weight_fields = {"weight_direction", "weight_env", "weight_aux", "weight_momentum"}
if weight_fields & set(updates.keys()):
w_dir = updates.get("weight_direction", row["weight_direction"])
w_env = updates.get("weight_env", row["weight_env"])
w_aux = updates.get("weight_aux", row["weight_aux"])
w_mom = updates.get("weight_momentum", row["weight_momentum"])
if w_dir + w_env + w_aux + w_mom != 100:
raise HTTPException(status_code=400, detail=f"Weights must sum to 100, got {w_dir+w_env+w_aux+w_mom}")
# Validate individual field ranges
validators = {
"direction": lambda v: v in {"long_only", "short_only", "both"},
"cvd_fast_window": lambda v: v in {"5m", "15m", "30m"},
"cvd_slow_window": lambda v: v in {"30m", "1h", "4h"},
"weight_direction": lambda v: 10 <= v <= 80,
"weight_env": lambda v: 5 <= v <= 60,
"weight_aux": lambda v: 0 <= v <= 40,
"weight_momentum": lambda v: 0 <= v <= 20,
"entry_score": lambda v: 60 <= v <= 95,
"obi_threshold": lambda v: 0.1 <= v <= 0.9,
"vol_atr_pct_min": lambda v: 0.0001 <= v <= 0.02,
"whale_usd_threshold": lambda v: 1000 <= v <= 1000000,
"whale_flow_pct": lambda v: 0.0 <= v <= 1.0,
"spot_perp_threshold": lambda v: 0.0005 <= v <= 0.01,
"sl_atr_multiplier": lambda v: 0.5 <= v <= 3.0,
"tp1_ratio": lambda v: 0.3 <= v <= 2.0,
"tp2_ratio": lambda v: 0.5 <= v <= 4.0,
"timeout_minutes": lambda v: 30 <= v <= 1440,
"flip_threshold": lambda v: 60 <= v <= 95,
}
for field, val in updates.items():
if field in validators and not validators[field](val):
raise HTTPException(status_code=400, detail=f"Invalid value for {field}: {val}")
# Execute update
set_parts = [f"{k}=${i+2}" for i, k in enumerate(updates.keys())]
set_parts.append(f"updated_at=NOW()")
sql = f"UPDATE strategies SET {', '.join(set_parts)} WHERE strategy_id=$1"
await async_execute(sql, sid, *updates.values())
updated = await async_fetchrow("SELECT * FROM strategies WHERE strategy_id=$1", sid)
return {"ok": True, "strategy": _strategy_row_to_detail(dict(updated))}
@app.post("/api/strategies/{sid}/pause")
async def pause_strategy(sid: str, user: dict = Depends(get_current_user)):
"""暂停策略(停止开新仓,不影响现有持仓)"""
row = await _get_strategy_or_404(sid)
if row["status"] == "deprecated":
raise HTTPException(status_code=403, detail="Cannot pause a deprecated strategy")
if row["status"] == "paused":
return {"ok": True, "message": "Already paused"}
await async_execute(
"UPDATE strategies SET status='paused', status_changed_at=NOW(), updated_at=NOW() WHERE strategy_id=$1",
sid
)
return {"ok": True, "status": "paused"}
@app.post("/api/strategies/{sid}/resume")
async def resume_strategy(sid: str, user: dict = Depends(get_current_user)):
"""恢复策略"""
row = await _get_strategy_or_404(sid)
if row["status"] == "running":
return {"ok": True, "message": "Already running"}
await async_execute(
"UPDATE strategies SET status='running', status_changed_at=NOW(), updated_at=NOW() WHERE strategy_id=$1",
sid
)
return {"ok": True, "status": "running"}
@app.post("/api/strategies/{sid}/deprecate")
async def deprecate_strategy(sid: str, body: DeprecateRequest, user: dict = Depends(get_current_user)):
"""废弃策略(数据永久保留,可重新启用)"""
if not body.confirm:
raise HTTPException(status_code=400, detail="Must set confirm=true to deprecate")
row = await _get_strategy_or_404(sid)
if row["status"] == "deprecated":
return {"ok": True, "message": "Already deprecated"}
await async_execute(
"""UPDATE strategies
SET status='deprecated', deprecated_at=NOW(),
status_changed_at=NOW(), updated_at=NOW()
WHERE strategy_id=$1""",
sid
)
return {"ok": True, "status": "deprecated"}
@app.post("/api/strategies/{sid}/restore")
async def restore_strategy(sid: str, user: dict = Depends(get_current_user)):
"""重新启用废弃策略(继续原有余额和历史数据)"""
row = await _get_strategy_or_404(sid)
if row["status"] != "deprecated":
raise HTTPException(status_code=400, detail="Strategy is not deprecated")
await async_execute(
"""UPDATE strategies
SET status='running', deprecated_at=NULL,
status_changed_at=NOW(), updated_at=NOW()
WHERE strategy_id=$1""",
sid
)
return {"ok": True, "status": "running"}
@app.post("/api/strategies/{sid}/add-balance")
async def add_balance(sid: str, body: AddBalanceRequest, user: dict = Depends(get_current_user)):
"""追加余额initial_balance 和 current_balance 同步增加)"""
row = await _get_strategy_or_404(sid)
if row["status"] == "deprecated":
raise HTTPException(status_code=403, detail="Cannot add balance to a deprecated strategy")
new_initial = round(row["initial_balance"] + body.amount, 2)
new_current = round(row["current_balance"] + body.amount, 2)
await async_execute(
"""UPDATE strategies
SET initial_balance=$2, current_balance=$3, updated_at=NOW()
WHERE strategy_id=$1""",
sid, new_initial, new_current
)
return {
"ok": True,
"initial_balance": new_initial,
"current_balance": new_current,
"added": body.amount,
}

327
backend/migrate_v54.py Normal file
View File

@ -0,0 +1,327 @@
#!/usr/bin/env python3
"""
V5.4 Strategy Factory DB Migration Script
- Creates `strategies` table
- Adds strategy_id + strategy_name_snapshot to paper_trades, signal_indicators
- Inserts existing 3 strategies with fixed UUIDs
- Backfills strategy_id + strategy_name_snapshot for all existing records
"""
import os
import sys
import psycopg2
from psycopg2.extras import execute_values
PG_HOST = os.environ.get("PG_HOST", "10.106.0.3")
PG_PASS = os.environ.get("PG_PASS", "arb_engine_2026")
PG_USER = "arb"
PG_DB = "arb_engine"
# Fixed UUIDs for existing strategies (deterministic, easy to recognize)
LEGACY_STRATEGY_MAP = {
"v53": ("00000000-0000-0000-0000-000000000053", "V5.3 Standard"),
"v53_middle": ("00000000-0000-0000-0000-000000000054", "V5.3 Middle"),
"v53_fast": ("00000000-0000-0000-0000-000000000055", "V5.3 Fast"),
}
# Default config values per strategy (from strategy JSON files)
LEGACY_CONFIGS = {
"v53": {
"symbol": "BTCUSDT", # multi-symbol, use BTC as representative
"cvd_fast_window": "30m",
"cvd_slow_window": "4h",
"weight_direction": 55,
"weight_env": 25,
"weight_aux": 15,
"weight_momentum": 5,
"entry_score": 75,
"sl_atr_multiplier": 1.0,
"tp1_ratio": 0.75,
"tp2_ratio": 1.5,
"timeout_minutes": 60,
"flip_threshold": 75,
"status": "running",
"initial_balance": 10000.0,
},
"v53_middle": {
"symbol": "BTCUSDT",
"cvd_fast_window": "15m",
"cvd_slow_window": "1h",
"weight_direction": 55,
"weight_env": 25,
"weight_aux": 15,
"weight_momentum": 5,
"entry_score": 75,
"sl_atr_multiplier": 1.0,
"tp1_ratio": 0.75,
"tp2_ratio": 1.5,
"timeout_minutes": 60,
"flip_threshold": 75,
"status": "running",
"initial_balance": 10000.0,
},
"v53_fast": {
"symbol": "BTCUSDT",
"cvd_fast_window": "5m",
"cvd_slow_window": "30m",
"weight_direction": 55,
"weight_env": 25,
"weight_aux": 15,
"weight_momentum": 5,
"entry_score": 75,
"sl_atr_multiplier": 1.0,
"tp1_ratio": 0.75,
"tp2_ratio": 1.5,
"timeout_minutes": 60,
"flip_threshold": 75,
"status": "running",
"initial_balance": 10000.0,
},
}
def get_conn():
return psycopg2.connect(
host=PG_HOST, user=PG_USER, password=PG_PASS, dbname=PG_DB
)
def step1_create_strategies_table(cur):
print("[Step 1] Creating strategies table...")
cur.execute("""
CREATE TABLE IF NOT EXISTS strategies (
strategy_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
display_name TEXT NOT NULL,
schema_version INT NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT 'running'
CHECK (status IN ('running', 'paused', 'deprecated')),
status_changed_at TIMESTAMP,
last_run_at TIMESTAMP,
deprecated_at TIMESTAMP,
symbol TEXT NOT NULL
CHECK (symbol IN ('BTCUSDT', 'ETHUSDT', 'SOLUSDT', 'XRPUSDT')),
direction TEXT NOT NULL DEFAULT 'both'
CHECK (direction IN ('long_only', 'short_only', 'both')),
cvd_fast_window TEXT NOT NULL DEFAULT '30m'
CHECK (cvd_fast_window IN ('5m', '15m', '30m')),
cvd_slow_window TEXT NOT NULL DEFAULT '4h'
CHECK (cvd_slow_window IN ('30m', '1h', '4h')),
weight_direction INT NOT NULL DEFAULT 55,
weight_env INT NOT NULL DEFAULT 25,
weight_aux INT NOT NULL DEFAULT 15,
weight_momentum INT NOT NULL DEFAULT 5,
entry_score INT NOT NULL DEFAULT 75,
gate_obi_enabled BOOL NOT NULL DEFAULT TRUE,
obi_threshold FLOAT NOT NULL DEFAULT 0.3,
gate_whale_enabled BOOL NOT NULL DEFAULT TRUE,
whale_cvd_threshold FLOAT NOT NULL DEFAULT 0.0,
gate_vol_enabled BOOL NOT NULL DEFAULT TRUE,
atr_percentile_min INT NOT NULL DEFAULT 20,
gate_spot_perp_enabled BOOL NOT NULL DEFAULT FALSE,
spot_perp_threshold FLOAT NOT NULL DEFAULT 0.002,
sl_atr_multiplier FLOAT NOT NULL DEFAULT 1.5,
tp1_ratio FLOAT NOT NULL DEFAULT 0.75,
tp2_ratio FLOAT NOT NULL DEFAULT 1.5,
timeout_minutes INT NOT NULL DEFAULT 240,
flip_threshold INT NOT NULL DEFAULT 80,
initial_balance FLOAT NOT NULL DEFAULT 10000.0,
current_balance FLOAT NOT NULL DEFAULT 10000.0,
description TEXT,
tags TEXT[],
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
)
""")
cur.execute("CREATE INDEX IF NOT EXISTS idx_strategies_status ON strategies(status)")
cur.execute("CREATE INDEX IF NOT EXISTS idx_strategies_symbol ON strategies(symbol)")
cur.execute("CREATE INDEX IF NOT EXISTS idx_strategies_last_run ON strategies(last_run_at)")
print("[Step 1] Done.")
def step2_add_columns(cur):
print("[Step 2] Adding strategy_id + strategy_name_snapshot columns...")
# paper_trades
for col, col_type in [
("strategy_id", "UUID REFERENCES strategies(strategy_id)"),
("strategy_name_snapshot", "TEXT"),
]:
cur.execute(f"""
ALTER TABLE paper_trades
ADD COLUMN IF NOT EXISTS {col} {col_type}
""")
# signal_indicators
for col, col_type in [
("strategy_id", "UUID REFERENCES strategies(strategy_id)"),
("strategy_name_snapshot", "TEXT"),
]:
cur.execute(f"""
ALTER TABLE signal_indicators
ADD COLUMN IF NOT EXISTS {col} {col_type}
""")
# Indexes
cur.execute("CREATE INDEX IF NOT EXISTS idx_paper_trades_strategy_id ON paper_trades(strategy_id)")
cur.execute("CREATE INDEX IF NOT EXISTS idx_si_strategy_id ON signal_indicators(strategy_id)")
print("[Step 2] Done.")
def step3_insert_legacy_strategies(cur):
print("[Step 3] Inserting legacy strategies into strategies table...")
for strategy_name, (uuid, display_name) in LEGACY_STRATEGY_MAP.items():
cfg = LEGACY_CONFIGS[strategy_name]
# Compute current_balance from actual paper trades
cur.execute("""
SELECT
COALESCE(SUM(pnl_r) * 200, 0) as total_pnl_usdt
FROM paper_trades
WHERE strategy = %s AND status != 'active'
""", (strategy_name,))
row = cur.fetchone()
pnl_usdt = row[0] if row else 0
current_balance = round(cfg["initial_balance"] + pnl_usdt, 2)
cur.execute("""
INSERT INTO strategies (
strategy_id, display_name, schema_version, status,
symbol, direction,
cvd_fast_window, cvd_slow_window,
weight_direction, weight_env, weight_aux, weight_momentum,
entry_score,
gate_obi_enabled, obi_threshold,
gate_whale_enabled, whale_cvd_threshold,
gate_vol_enabled, atr_percentile_min,
gate_spot_perp_enabled, spot_perp_threshold,
sl_atr_multiplier, tp1_ratio, tp2_ratio,
timeout_minutes, flip_threshold,
initial_balance, current_balance,
description
) VALUES (
%s, %s, 1, %s,
%s, 'both',
%s, %s,
%s, %s, %s, %s,
%s,
TRUE, 0.3,
TRUE, 0.0,
TRUE, 20,
FALSE, 0.002,
%s, %s, %s,
%s, %s,
%s, %s,
%s
)
ON CONFLICT (strategy_id) DO NOTHING
""", (
uuid, display_name, cfg["status"],
cfg["symbol"], cfg["cvd_fast_window"], cfg["cvd_slow_window"],
cfg["weight_direction"], cfg["weight_env"], cfg["weight_aux"], cfg["weight_momentum"],
cfg["entry_score"],
cfg["sl_atr_multiplier"], cfg["tp1_ratio"], cfg["tp2_ratio"],
cfg["timeout_minutes"], cfg["flip_threshold"],
cfg["initial_balance"], current_balance,
f"Migrated from V5.3 legacy strategy: {strategy_name}"
))
print(f" Inserted {strategy_name}{uuid} (balance: {current_balance})")
print("[Step 3] Done.")
def step4_backfill(cur):
print("[Step 4] Backfilling strategy_id + strategy_name_snapshot...")
for strategy_name, (uuid, display_name) in LEGACY_STRATEGY_MAP.items():
# paper_trades
cur.execute("""
UPDATE paper_trades
SET strategy_id = %s::uuid,
strategy_name_snapshot = %s
WHERE strategy = %s AND strategy_id IS NULL
""", (uuid, display_name, strategy_name))
count = cur.rowcount
print(f" paper_trades [{strategy_name}]: {count} rows updated")
# signal_indicators
cur.execute("""
UPDATE signal_indicators
SET strategy_id = %s::uuid,
strategy_name_snapshot = %s
WHERE strategy = %s AND strategy_id IS NULL
""", (uuid, display_name, strategy_name))
count = cur.rowcount
print(f" signal_indicators [{strategy_name}]: {count} rows updated")
print("[Step 4] Done.")
def step5_verify(cur):
print("[Step 5] Verifying migration completeness...")
# Check strategies table
cur.execute("SELECT COUNT(*) FROM strategies")
n = cur.fetchone()[0]
print(f" strategies table: {n} rows")
# Check NULL strategy_id in paper_trades (for known strategies)
cur.execute("""
SELECT strategy, COUNT(*) as cnt
FROM paper_trades
WHERE strategy IN ('v53', 'v53_middle', 'v53_fast')
AND strategy_id IS NULL
GROUP BY strategy
""")
rows = cur.fetchall()
if rows:
print(f" WARNING: NULL strategy_id found in paper_trades:")
for r in rows:
print(f" {r[0]}: {r[1]} rows")
else:
print(" paper_trades: all known strategies backfilled ✅")
# Check NULL in signal_indicators
cur.execute("""
SELECT strategy, COUNT(*) as cnt
FROM signal_indicators
WHERE strategy IN ('v53', 'v53_middle', 'v53_fast')
AND strategy_id IS NULL
GROUP BY strategy
""")
rows = cur.fetchall()
if rows:
print(f" WARNING: NULL strategy_id found in signal_indicators:")
for r in rows:
print(f" {r[0]}: {r[1]} rows")
else:
print(" signal_indicators: all known strategies backfilled ✅")
print("[Step 5] Done.")
def main():
dry_run = "--dry-run" in sys.argv
if dry_run:
print("=== DRY RUN MODE (no changes will be committed) ===")
conn = get_conn()
conn.autocommit = False
cur = conn.cursor()
try:
step1_create_strategies_table(cur)
step2_add_columns(cur)
step3_insert_legacy_strategies(cur)
step4_backfill(cur)
step5_verify(cur)
if dry_run:
conn.rollback()
print("\n=== DRY RUN: rolled back all changes ===")
else:
conn.commit()
print("\n=== Migration completed successfully ✅ ===")
except Exception as e:
conn.rollback()
print(f"\n=== ERROR: {e} ===")
raise
finally:
cur.close()
conn.close()
if __name__ == "__main__":
main()

View File

@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""
V5.4b Gate Schema Migration
strategies 表的 gate 字段从4门改为5门 signal_engine.py 实际执行逻辑完全对应
变更
1. 重命名 atr_percentile_min vol_atr_pct_minATR%价格阈值如0.002=0.2%
2. 重命名 whale_cvd_threshold whale_flow_pct鲸鱼CVD流量阈值BTC专用0-1
3. 新增 gate_cvd_enabled BOOLEAN DEFAULT TRUE门2 CVD共振开关
4. 新增 whale_usd_threshold FLOAT DEFAULT 50000门3 大单USD金额阈值
5. v53.json 里的 per-symbol 默认值回填旧三条策略
五门对应
门1 波动率gate_vol_enabled + vol_atr_pct_min
门2 CVD共振gate_cvd_enabled无参数判断快慢CVD同向
门3 鲸鱼否决gate_whale_enabled + whale_usd_thresholdALT大单USD+ whale_flow_pctBTC CVD流量
门4 OBI否决gate_obi_enabled + obi_threshold
门5 期现背离gate_spot_perp_enabled + spot_perp_threshold
"""
import os, sys
import psycopg2
from psycopg2.extras import RealDictCursor
PG_HOST = os.getenv("PG_HOST", "10.106.0.3")
PG_USER = os.getenv("PG_USER", "arb")
PG_PASS = os.getenv("PG_PASS", "arb_engine_2026")
PG_DB = os.getenv("PG_DB", "arb_engine")
# Per-symbol 默认值(来自 v53.json symbol_gates
SYMBOL_DEFAULTS = {
"BTCUSDT": {"vol_atr_pct_min": 0.002, "whale_usd_threshold": 100000, "whale_flow_pct": 0.5, "obi_threshold": 0.30, "spot_perp_threshold": 0.003},
"ETHUSDT": {"vol_atr_pct_min": 0.003, "whale_usd_threshold": 50000, "whale_flow_pct": 0.5, "obi_threshold": 0.35, "spot_perp_threshold": 0.005},
"SOLUSDT": {"vol_atr_pct_min": 0.004, "whale_usd_threshold": 20000, "whale_flow_pct": 0.5, "obi_threshold": 0.45, "spot_perp_threshold": 0.008},
"XRPUSDT": {"vol_atr_pct_min": 0.0025,"whale_usd_threshold": 30000, "whale_flow_pct": 0.5, "obi_threshold": 0.40, "spot_perp_threshold": 0.006},
None: {"vol_atr_pct_min": 0.002, "whale_usd_threshold": 50000, "whale_flow_pct": 0.5, "obi_threshold": 0.35, "spot_perp_threshold": 0.005},
}
DRY_RUN = "--dry-run" in sys.argv
def get_conn():
return psycopg2.connect(
host=PG_HOST, port=5432,
user=PG_USER, password=PG_PASS, dbname=PG_DB
)
def run():
conn = get_conn()
cur = conn.cursor(cursor_factory=RealDictCursor)
print("=== V5.4b Gate Schema Migration ===")
print(f"DRY_RUN={DRY_RUN}")
print()
# Step 1: 检查字段是否已迁移
cur.execute("""
SELECT column_name FROM information_schema.columns
WHERE table_name='strategies' AND column_name IN
('vol_atr_pct_min','whale_flow_pct','gate_cvd_enabled','whale_usd_threshold',
'atr_percentile_min','whale_cvd_threshold')
""")
existing = {r["column_name"] for r in cur.fetchall()}
print(f"现有相关字段: {existing}")
sqls = []
# Step 2: 改名 atr_percentile_min → vol_atr_pct_min
if "atr_percentile_min" in existing and "vol_atr_pct_min" not in existing:
sqls.append("ALTER TABLE strategies RENAME COLUMN atr_percentile_min TO vol_atr_pct_min")
print("✅ RENAME atr_percentile_min → vol_atr_pct_min")
elif "vol_atr_pct_min" in existing:
print("⏭️ vol_atr_pct_min 已存在,跳过改名")
# Step 3: 改名 whale_cvd_threshold → whale_flow_pct
if "whale_cvd_threshold" in existing and "whale_flow_pct" not in existing:
sqls.append("ALTER TABLE strategies RENAME COLUMN whale_cvd_threshold TO whale_flow_pct")
print("✅ RENAME whale_cvd_threshold → whale_flow_pct")
elif "whale_flow_pct" in existing:
print("⏭️ whale_flow_pct 已存在,跳过改名")
# Step 4: 新增 gate_cvd_enabled
if "gate_cvd_enabled" not in existing:
sqls.append("ALTER TABLE strategies ADD COLUMN gate_cvd_enabled BOOLEAN NOT NULL DEFAULT TRUE")
print("✅ ADD gate_cvd_enabled BOOLEAN DEFAULT TRUE")
else:
print("⏭️ gate_cvd_enabled 已存在,跳过")
# Step 5: 新增 whale_usd_threshold
if "whale_usd_threshold" not in existing:
sqls.append("ALTER TABLE strategies ADD COLUMN whale_usd_threshold FLOAT NOT NULL DEFAULT 50000")
print("✅ ADD whale_usd_threshold FLOAT DEFAULT 50000")
else:
print("⏭️ whale_usd_threshold 已存在,跳过")
print()
if not sqls:
print("无需迁移,所有字段已是最新状态。")
conn.close()
return
if DRY_RUN:
print("=== DRY RUN - 以下SQL不会执行 ===")
for sql in sqls:
print(f" {sql};")
conn.close()
return
# 执行 DDL
for sql in sqls:
print(f"执行: {sql}")
cur.execute(sql)
conn.commit()
print()
# Step 6: 回填 per-symbol 默认值
cur.execute("SELECT strategy_id, symbol FROM strategies")
rows = cur.fetchall()
print(f"回填 {len(rows)} 条策略的 per-symbol 默认值...")
for row in rows:
sid = row["strategy_id"]
sym = row["symbol"]
defaults = SYMBOL_DEFAULTS.get(sym, SYMBOL_DEFAULTS[None])
cur.execute("""
UPDATE strategies SET
vol_atr_pct_min = %s,
whale_flow_pct = %s,
whale_usd_threshold = %s,
obi_threshold = %s,
spot_perp_threshold = %s
WHERE strategy_id = %s
""", (
defaults["vol_atr_pct_min"],
defaults["whale_flow_pct"],
defaults["whale_usd_threshold"],
defaults["obi_threshold"],
defaults["spot_perp_threshold"],
sid
))
print(f" {sid} ({sym}): vol_atr_pct={defaults['vol_atr_pct_min']} whale_usd={defaults['whale_usd_threshold']} obi={defaults['obi_threshold']}")
conn.commit()
print()
print("=== 迁移完成 ===")
# 验证
cur.execute("SELECT strategy_id, display_name, gate_cvd_enabled, gate_vol_enabled, vol_atr_pct_min, gate_whale_enabled, whale_usd_threshold, whale_flow_pct, gate_obi_enabled, obi_threshold, gate_spot_perp_enabled, spot_perp_threshold FROM strategies ORDER BY created_at")
print("\n验证结果:")
print(f"{'display_name':<15} {'cvd':>4} {'vol':>4} {'vol_pct':>8} {'whale':>6} {'whale_usd':>10} {'flow_pct':>9} {'obi':>4} {'obi_thr':>8} {'spd':>4} {'spd_thr':>8}")
for r in cur.fetchall():
print(f"{r['display_name']:<15} {str(r['gate_cvd_enabled']):>4} {str(r['gate_vol_enabled']):>4} {r['vol_atr_pct_min']:>8.4f} {str(r['gate_whale_enabled']):>6} {r['whale_usd_threshold']:>10.0f} {r['whale_flow_pct']:>9.3f} {str(r['gate_obi_enabled']):>4} {r['obi_threshold']:>8.3f} {str(r['gate_spot_perp_enabled']):>4} {r['spot_perp_threshold']:>8.4f}")
conn.close()
if __name__ == "__main__":
run()

View File

@ -70,6 +70,104 @@ def load_strategy_configs() -> list[dict]:
) )
return configs return configs
def load_strategy_configs_from_db() -> list[dict]:
"""
V5.4: strategies 表读取 running 状态的策略配置
DB 字段映射成现有 JSON 格式保持与 JSON 文件完全兼容
失败时返回空列表调用方应 fallback JSON
内存安全每次读取只返回配置列表无缓存无大对象
"""
try:
with get_sync_conn() as conn:
with conn.cursor() as cur:
cur.execute("""
SELECT
strategy_id::text, display_name, symbol,
cvd_fast_window, cvd_slow_window,
weight_direction, weight_env, weight_aux, weight_momentum,
entry_score,
gate_obi_enabled, obi_threshold,
gate_whale_enabled, whale_usd_threshold, whale_flow_pct,
gate_vol_enabled, vol_atr_pct_min,
gate_cvd_enabled,
gate_spot_perp_enabled, spot_perp_threshold,
sl_atr_multiplier, tp1_ratio, tp2_ratio,
timeout_minutes, flip_threshold, direction
FROM strategies
WHERE status = 'running'
ORDER BY created_at ASC
""")
rows = cur.fetchall()
configs = []
for row in rows:
(sid, display_name, symbol,
cvd_fast, cvd_slow,
w_dir, w_env, w_aux, w_mom,
entry_score,
gate_obi, obi_thr,
gate_whale, whale_usd_thr, whale_flow_pct_val,
gate_vol, vol_atr_pct,
gate_cvd,
gate_spot, spot_thr,
sl_mult, tp1_r, tp2_r,
timeout_min, flip_thr, direction) = row
# 把 display_name 映射回 legacy strategy name用于兼容评分逻辑
# legacy 策略用固定 UUID 识别
LEGACY_UUID_MAP = {
"00000000-0000-0000-0000-000000000053": "v53",
"00000000-0000-0000-0000-000000000054": "v53_middle",
"00000000-0000-0000-0000-000000000055": "v53_fast",
}
strategy_name = LEGACY_UUID_MAP.get(sid, f"custom_{sid[:8]}")
# 构造与 JSON 文件格式兼容的配置 dict
cfg = {
"name": strategy_name,
"strategy_id": sid, # V5.4 新增:用于写 strategy_id 到 DB
"strategy_name_snapshot": display_name, # V5.4 新增:写入时快照名称
"symbol": symbol,
"direction": direction,
"cvd_fast_window": cvd_fast,
"cvd_slow_window": cvd_slow,
"threshold": entry_score,
"weights": {
"direction": w_dir,
"env": w_env,
"aux": w_aux,
"momentum": w_mom,
},
"gates": {
# 门1 波动率
"vol": {"enabled": gate_vol, "vol_atr_pct_min": float(vol_atr_pct or 0.002)},
# 门2 CVD共振
"cvd": {"enabled": gate_cvd},
# 门3 鲸鱼否决
"whale": {"enabled": gate_whale, "whale_usd_threshold": float(whale_usd_thr or 50000), "whale_flow_pct": float(whale_flow_pct_val or 0.5)},
# 门4 OBI否决
"obi": {"enabled": gate_obi, "threshold": float(obi_thr or 0.35)},
# 门5 期现背离
"spot_perp": {"enabled": gate_spot, "threshold": float(spot_thr or 0.005)},
},
"tp_sl": {
"sl_multiplier": sl_mult,
"tp1_multiplier": tp1_r,
"tp2_multiplier": tp2_r,
},
"timeout_minutes": timeout_min,
"flip_threshold": flip_thr,
}
configs.append(cfg)
logger.info(f"[DB] 已加载 {len(configs)} 个策略配置: {[c['name'] for c in configs]}")
return configs
except Exception as e:
logger.warning(f"[DB] load_strategy_configs_from_db 失败,将 fallback 到 JSON: {e}")
return []
# ─── 模拟盘配置 ─────────────────────────────────────────────────── # ─── 模拟盘配置 ───────────────────────────────────────────────────
PAPER_TRADING_ENABLED = False # 总开关(兼容旧逻辑) PAPER_TRADING_ENABLED = False # 总开关(兼容旧逻辑)
PAPER_ENABLED_STRATEGIES = [] # 分策略开关: ["v51_baseline", "v52_8signals"] PAPER_ENABLED_STRATEGIES = [] # 分策略开关: ["v51_baseline", "v52_8signals"]
@ -456,7 +554,7 @@ class SymbolState:
# v53 → 统一评分BTC/ETH/XRP/SOL # v53 → 统一评分BTC/ETH/XRP/SOL
# v53_alt / v53_btc → 兼容旧策略名,转发到 _evaluate_v53() # v53_alt / v53_btc → 兼容旧策略名,转发到 _evaluate_v53()
# v51/v52 → 原有代码路径(兼容,不修改) # v51/v52 → 原有代码路径(兼容,不修改)
if strategy_name.startswith("v53"): if strategy_name.startswith("v53") or strategy_name.startswith("custom_"):
allowed_symbols = strategy_cfg.get("symbols", []) allowed_symbols = strategy_cfg.get("symbols", [])
if allowed_symbols and self.symbol not in allowed_symbols: if allowed_symbols and self.symbol not in allowed_symbols:
snap = snapshot or self.build_evaluation_snapshot(now_ms) snap = snapshot or self.build_evaluation_snapshot(now_ms)
@ -717,6 +815,16 @@ class SymbolState:
"signal": None, "direction": None, "score": 0, "tier": None, "factors": {}, "signal": None, "direction": None, "score": 0, "tier": None, "factors": {},
} }
def _window_ms(window_str: str) -> int:
"""把CVD窗口字符串转换为毫秒'5m'->300000, '1h'->3600000, '4h'->14400000"""
window_str = (window_str or "30m").strip().lower()
if window_str.endswith("h"):
return int(window_str[:-1]) * 3600 * 1000
elif window_str.endswith("m"):
return int(window_str[:-1]) * 60 * 1000
return 30 * 60 * 1000 # fallback 30min
def _evaluate_v53(self, now_ms: int, strategy_cfg: dict, snapshot: Optional[dict] = None) -> dict: def _evaluate_v53(self, now_ms: int, strategy_cfg: dict, snapshot: Optional[dict] = None) -> dict:
""" """
V5.3 统一评分BTC/ETH/XRP/SOL V5.3 统一评分BTC/ETH/XRP/SOL
@ -730,31 +838,36 @@ class SymbolState:
strategy_name = strategy_cfg.get("name", "v53") strategy_name = strategy_cfg.get("name", "v53")
strategy_threshold = int(strategy_cfg.get("threshold", 75)) strategy_threshold = int(strategy_cfg.get("threshold", 75))
flip_threshold = int(strategy_cfg.get("flip_threshold", 85)) flip_threshold = int(strategy_cfg.get("flip_threshold", 85))
is_fast = strategy_name.endswith("_fast")
snap = snapshot or self.build_evaluation_snapshot(now_ms) snap = snapshot or self.build_evaluation_snapshot(now_ms)
# v53_fast: 用自定义短窗口重算 cvd_fast / cvd_mid # 按策略配置的 cvd_fast_window / cvd_slow_window 动态切片重算CVD
if is_fast: # 支持 5m/15m/30m/1h/4h 所有组合
fast_ms = int(strategy_cfg.get("cvd_window_fast_ms", 5 * 60 * 1000)) cvd_fast_window = strategy_cfg.get("cvd_fast_window", "30m")
mid_ms = int(strategy_cfg.get("cvd_window_mid_ms", 30 * 60 * 1000)) cvd_slow_window = strategy_cfg.get("cvd_slow_window", "4h")
fast_ms = _window_ms(cvd_fast_window)
slow_ms = _window_ms(cvd_slow_window)
# 默认窗口 (30m/4h) 直接用快照,否则从 trades 列表切片重算
if cvd_fast_window == "30m" and cvd_slow_window == "4h":
cvd_fast = snap["cvd_fast"]
cvd_mid = snap["cvd_mid"]
else:
cutoff_fast = now_ms - fast_ms cutoff_fast = now_ms - fast_ms
cutoff_mid = now_ms - mid_ms cutoff_slow = now_ms - slow_ms
buy_f = sell_f = buy_m = sell_m = 0.0 buy_f = sell_f = buy_m = sell_m = 0.0
for t_ms, qty, _price, ibm in self.win_fast.trades: # fast: 从 win_fast (30min) 或 win_mid (4h) 中切片
src_fast = self.win_mid if fast_ms > WINDOW_FAST else self.win_fast
for t_ms, qty, _price, ibm in src_fast.trades:
if t_ms >= cutoff_fast: if t_ms >= cutoff_fast:
if ibm == 0: buy_f += qty if ibm == 0: buy_f += qty
else: sell_f += qty else: sell_f += qty
# mid 从 win_mid 中读win_mid 窗口是4h包含30min内数据 # slow: 从 win_mid (4h) 中切片
for t_ms, qty, _price, ibm in self.win_mid.trades: for t_ms, qty, _price, ibm in self.win_mid.trades:
if t_ms >= cutoff_mid: if t_ms >= cutoff_slow:
if ibm == 0: buy_m += qty if ibm == 0: buy_m += qty
else: sell_m += qty else: sell_m += qty
cvd_fast = buy_f - sell_f cvd_fast = buy_f - sell_f
cvd_mid = buy_m - sell_m cvd_mid = buy_m - sell_m
else:
cvd_fast = snap["cvd_fast"]
cvd_mid = snap["cvd_mid"]
price = snap["price"] price = snap["price"]
atr = snap["atr"] atr = snap["atr"]
@ -769,21 +882,44 @@ class SymbolState:
last_signal_ts = self.last_signal_ts.get(strategy_name, 0) last_signal_ts = self.last_signal_ts.get(strategy_name, 0)
in_cooldown = now_ms - last_signal_ts < COOLDOWN_MS in_cooldown = now_ms - last_signal_ts < COOLDOWN_MS
# ── Per-symbol 四门参数 ──────────────────────────────────── # ── 五门参数:优先读 DB configV5.4fallback 到 JSON symbol_gates ────
# DB config 来自 load_strategy_configs_from_db(),已映射到 strategy_cfg["gates"]
db_gates = strategy_cfg.get("gates") or {}
symbol_gates = (strategy_cfg.get("symbol_gates") or {}).get(self.symbol, {}) symbol_gates = (strategy_cfg.get("symbol_gates") or {}).get(self.symbol, {})
min_vol = float(symbol_gates.get("min_vol_threshold", 0.002))
whale_usd = float(symbol_gates.get("whale_threshold_usd", 50000)) # 门1 波动率vol_atr_pct_minATR%价格如0.002=价格的0.2%
obi_veto = float(symbol_gates.get("obi_veto_threshold", 0.35)) gate_vol_enabled = db_gates.get("vol", {}).get("enabled", True)
spd_veto = float(symbol_gates.get("spot_perp_divergence_veto", 0.005)) min_vol = float(db_gates.get("vol", {}).get("vol_atr_pct_min") or
symbol_gates.get("min_vol_threshold", 0.002))
# 门2 CVD共振gate_cvd_enabled快慢CVD必须同向
gate_cvd_enabled = db_gates.get("cvd", {}).get("enabled", True)
# 门3 鲸鱼否决whale_usd_threshold + whale_flow_pctBTC
gate_whale_enabled = db_gates.get("whale", {}).get("enabled", True)
whale_usd = float(db_gates.get("whale", {}).get("whale_usd_threshold") or
symbol_gates.get("whale_threshold_usd", 50000))
whale_flow_pct = float(db_gates.get("whale", {}).get("whale_flow_pct") or
symbol_gates.get("whale_flow_threshold_pct", 0.5)) / 100
# 门4 OBI否决obi_threshold
gate_obi_enabled = db_gates.get("obi", {}).get("enabled", True)
obi_veto = float(db_gates.get("obi", {}).get("threshold") or
symbol_gates.get("obi_veto_threshold", 0.35))
# 门5 期现背离spot_perp_threshold
gate_spd_enabled = db_gates.get("spot_perp", {}).get("enabled", False)
spd_veto = float(db_gates.get("spot_perp", {}).get("threshold") or
symbol_gates.get("spot_perp_divergence_veto", 0.005))
gate_block = None gate_block = None
# 门1波动率下限 # 门1波动率下限(可关闭)
atr_pct_price = atr / price if price > 0 else 0 atr_pct_price = atr / price if price > 0 else 0
if atr_pct_price < min_vol: if gate_vol_enabled and atr_pct_price < min_vol:
gate_block = f"low_vol({atr_pct_price:.4f}<{min_vol})" gate_block = f"low_vol({atr_pct_price:.4f}<{min_vol})"
# 门2CVD共振方向门 # 门2CVD共振方向门,可关闭
if cvd_fast > 0 and cvd_mid > 0: if cvd_fast > 0 and cvd_mid > 0:
direction = "LONG" direction = "LONG"
cvd_resonance = 30 cvd_resonance = 30
@ -795,18 +931,20 @@ class SymbolState:
else: else:
direction = "LONG" if cvd_fast > 0 else "SHORT" direction = "LONG" if cvd_fast > 0 else "SHORT"
cvd_resonance = 0 cvd_resonance = 0
no_direction = True if gate_cvd_enabled:
if not gate_block: no_direction = True
gate_block = "no_direction_consensus" if not gate_block:
gate_block = "no_direction_consensus"
else:
no_direction = False # 门2关闭时允许单线CVD方向
# 门3鲸鱼否决BTC用whale_cvd_ratioALT用大单对立 # 门3鲸鱼否决BTC用whale_cvd_ratioALT用大单对立,可关闭
if not gate_block and not no_direction: if gate_whale_enabled and not gate_block and not no_direction:
if self.symbol == "BTCUSDT": if self.symbol == "BTCUSDT":
# BTC巨鲸CVD净方向与信号方向冲突时否决 # BTC巨鲸CVD净方向与信号方向冲突时否决
whale_cvd = self.whale_cvd_ratio if self._whale_trades else to_float(self.market_indicators.get("tiered_cvd_whale")) or 0.0 whale_cvd = self.whale_cvd_ratio if self._whale_trades else to_float(self.market_indicators.get("tiered_cvd_whale")) or 0.0
whale_threshold_pct = float(symbol_gates.get("whale_flow_threshold_pct", 0.5)) / 100 if (direction == "LONG" and whale_cvd < -whale_flow_pct) or \
if (direction == "LONG" and whale_cvd < -whale_threshold_pct) or \ (direction == "SHORT" and whale_cvd > whale_flow_pct):
(direction == "SHORT" and whale_cvd > whale_threshold_pct):
gate_block = f"whale_cvd_veto({whale_cvd:.3f})" gate_block = f"whale_cvd_veto({whale_cvd:.3f})"
else: else:
# ALTrecent_large_trades 里有对立大单则否决 # ALTrecent_large_trades 里有对立大单则否决
@ -823,17 +961,17 @@ class SymbolState:
if whale_adverse and not whale_aligned: if whale_adverse and not whale_aligned:
gate_block = f"whale_adverse(>${whale_usd/1000:.0f}k)" gate_block = f"whale_adverse(>${whale_usd/1000:.0f}k)"
# 门4OBI否决实时WS优先fallback DB # 门4OBI否决实时WS优先fallback DB,可关闭
obi_raw = self.rt_obi if self.rt_obi != 0.0 else to_float(self.market_indicators.get("obi_depth_10")) obi_raw = self.rt_obi if self.rt_obi != 0.0 else to_float(self.market_indicators.get("obi_depth_10"))
if not gate_block and not no_direction and obi_raw is not None: if gate_obi_enabled and not gate_block and not no_direction and obi_raw is not None:
if direction == "LONG" and obi_raw < -obi_veto: if direction == "LONG" and obi_raw < -obi_veto:
gate_block = f"obi_veto({obi_raw:.3f}<-{obi_veto})" gate_block = f"obi_veto({obi_raw:.3f}<-{obi_veto})"
elif direction == "SHORT" and obi_raw > obi_veto: elif direction == "SHORT" and obi_raw > obi_veto:
gate_block = f"obi_veto({obi_raw:.3f}>{obi_veto})" gate_block = f"obi_veto({obi_raw:.3f}>{obi_veto})"
# 门5期现背离否决实时WS优先fallback DB # 门5期现背离否决实时WS优先fallback DB,可关闭
spot_perp_div = self.rt_spot_perp_div if self.rt_spot_perp_div != 0.0 else to_float(self.market_indicators.get("spot_perp_divergence")) spot_perp_div = self.rt_spot_perp_div if self.rt_spot_perp_div != 0.0 else to_float(self.market_indicators.get("spot_perp_divergence"))
if not gate_block and not no_direction and spot_perp_div is not None: if gate_spd_enabled and not gate_block and not no_direction and spot_perp_div is not None:
if (direction == "LONG" and spot_perp_div < -spd_veto) or \ if (direction == "LONG" and spot_perp_div < -spd_veto) or \
(direction == "SHORT" and spot_perp_div > spd_veto): (direction == "SHORT" and spot_perp_div > spd_veto):
gate_block = f"spd_veto({spot_perp_div:.4f})" gate_block = f"spd_veto({spot_perp_div:.4f})"
@ -1018,7 +1156,8 @@ def fetch_new_trades(symbol: str, last_id: int) -> list:
for r in cur.fetchall()] for r in cur.fetchall()]
def save_indicator(ts: int, symbol: str, result: dict, strategy: str = "v52_8signals"): def save_indicator(ts: int, symbol: str, result: dict, strategy: str = "v52_8signals",
strategy_id: Optional[str] = None, strategy_name_snapshot: Optional[str] = None):
with get_sync_conn() as conn: with get_sync_conn() as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
import json as _json3 import json as _json3
@ -1026,13 +1165,13 @@ def save_indicator(ts: int, symbol: str, result: dict, strategy: str = "v52_8sig
cvd_fast_5m = result.get("cvd_fast_5m") # v53_fast 专用5m窗口CVD其他策略为None cvd_fast_5m = result.get("cvd_fast_5m") # v53_fast 专用5m窗口CVD其他策略为None
cur.execute( cur.execute(
"INSERT INTO signal_indicators " "INSERT INTO signal_indicators "
"(ts,symbol,strategy,cvd_fast,cvd_mid,cvd_day,cvd_fast_slope,atr_5m,atr_percentile,atr_value,vwap_30m,price,p95_qty,p99_qty,score,signal,factors,cvd_fast_5m) " "(ts,symbol,strategy,cvd_fast,cvd_mid,cvd_day,cvd_fast_slope,atr_5m,atr_percentile,atr_value,vwap_30m,price,p95_qty,p99_qty,score,signal,factors,cvd_fast_5m,strategy_id,strategy_name_snapshot) "
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)",
(ts, symbol, strategy, result["cvd_fast"], result["cvd_mid"], result["cvd_day"], result["cvd_fast_slope"], (ts, symbol, strategy, result["cvd_fast"], result["cvd_mid"], result["cvd_day"], result["cvd_fast_slope"],
result["atr"], result["atr_pct"], result.get("atr_value", result["atr"]), result["atr"], result["atr_pct"], result.get("atr_value", result["atr"]),
result["vwap"], result["price"], result["vwap"], result["price"],
result["p95"], result["p99"], result["score"], result.get("signal"), factors_json, result["p95"], result["p99"], result["score"], result.get("signal"), factors_json,
cvd_fast_5m) cvd_fast_5m, strategy_id, strategy_name_snapshot)
) )
# 有信号时通知live_executor # 有信号时通知live_executor
if result.get("signal"): if result.get("signal"):
@ -1128,6 +1267,8 @@ def paper_open_trade(
factors: dict = None, factors: dict = None,
strategy: str = "v51_baseline", strategy: str = "v51_baseline",
tp_sl: Optional[dict] = None, tp_sl: Optional[dict] = None,
strategy_id: Optional[str] = None,
strategy_name_snapshot: Optional[str] = None,
): ):
"""模拟开仓""" """模拟开仓"""
import json as _json3 import json as _json3
@ -1158,8 +1299,8 @@ def paper_open_trade(
with get_sync_conn() as conn: with get_sync_conn() as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute( cur.execute(
"INSERT INTO paper_trades (symbol,direction,score,tier,entry_price,entry_ts,tp1_price,tp2_price,sl_price,atr_at_entry,score_factors,strategy,risk_distance) " "INSERT INTO paper_trades (symbol,direction,score,tier,entry_price,entry_ts,tp1_price,tp2_price,sl_price,atr_at_entry,score_factors,strategy,risk_distance,strategy_id,strategy_name_snapshot) "
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)",
( (
symbol, symbol,
direction, direction,
@ -1174,6 +1315,8 @@ def paper_open_trade(
_json3.dumps(factors) if factors else None, _json3.dumps(factors) if factors else None,
strategy, strategy,
risk_distance, risk_distance,
strategy_id,
strategy_name_snapshot,
), ),
) )
conn.commit() conn.commit()
@ -1370,7 +1513,11 @@ def start_realtime_ws(states: dict):
def main(): def main():
init_schema() init_schema()
strategy_configs = load_strategy_configs() # V5.4: 优先从 DB 加载策略配置,失败 fallback 到 JSON
strategy_configs = load_strategy_configs_from_db()
if not strategy_configs:
logger.warning("[DB] 未能从 DB 加载策略配置,使用 JSON fallback")
strategy_configs = load_strategy_configs()
strategy_names = [cfg.get("name", "unknown") for cfg in strategy_configs] strategy_names = [cfg.get("name", "unknown") for cfg in strategy_configs]
logger.info(f"已加载策略配置: {', '.join(strategy_names)}") 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] primary_strategy_name = "v52_8signals" if any(cfg.get("name") == "v52_8signals" for cfg in strategy_configs) else strategy_names[0]
@ -1409,7 +1556,10 @@ def main():
# 每个策略独立存储indicator # 每个策略独立存储indicator
for strategy_cfg, strategy_result in strategy_results: for strategy_cfg, strategy_result in strategy_results:
sname = strategy_cfg.get("name", "v51_baseline") sname = strategy_cfg.get("name", "v51_baseline")
save_indicator(now_ms, sym, strategy_result, strategy=sname) sid = strategy_cfg.get("strategy_id")
ssnap = strategy_cfg.get("strategy_name_snapshot")
save_indicator(now_ms, sym, strategy_result, strategy=sname,
strategy_id=sid, strategy_name_snapshot=ssnap)
save_feature_event(now_ms, sym, strategy_result, strategy=sname) save_feature_event(now_ms, sym, strategy_result, strategy=sname)
# 1m表仍用primary图表用 # 1m表仍用primary图表用
@ -1430,6 +1580,10 @@ def main():
strategy_name = strategy_cfg.get("name", "v51_baseline") strategy_name = strategy_cfg.get("name", "v51_baseline")
if not is_strategy_enabled(strategy_name): if not is_strategy_enabled(strategy_name):
continue continue
# V5.4: custom策略只处理自己配置的symbol
strategy_symbol = strategy_cfg.get("symbol", "")
if strategy_symbol and strategy_symbol != sym:
continue
eval_dir = result.get("direction") eval_dir = result.get("direction")
existing_dir = paper_get_active_direction(sym, strategy_name) existing_dir = paper_get_active_direction(sym, strategy_name)
if existing_dir and eval_dir and existing_dir != eval_dir and result["score"] >= 75: if existing_dir and eval_dir and existing_dir != eval_dir and result["score"] >= 75:
@ -1448,6 +1602,10 @@ def main():
) )
# 模拟盘开仓(需该策略启用 + 跳过冷启动) # 模拟盘开仓(需该策略启用 + 跳过冷启动)
if is_strategy_enabled(strategy_name) and warmup_cycles <= 0: if is_strategy_enabled(strategy_name) and warmup_cycles <= 0:
# V5.4: custom策略只在自己配置的symbol上开仓
strategy_symbol = strategy_cfg.get("symbol", "")
if strategy_symbol and strategy_symbol != sym:
continue # 跳过不属于该策略的币种
if not paper_has_active_position(sym, strategy_name): if not paper_has_active_position(sym, strategy_name):
active_count = paper_active_count(strategy_name) active_count = paper_active_count(strategy_name)
if active_count < PAPER_MAX_POSITIONS: if active_count < PAPER_MAX_POSITIONS:
@ -1463,6 +1621,8 @@ def main():
factors=result.get("factors"), factors=result.get("factors"),
strategy=strategy_name, strategy=strategy_name,
tp_sl=strategy_cfg.get("tp_sl"), tp_sl=strategy_cfg.get("tp_sl"),
strategy_id=strategy_cfg.get("strategy_id"),
strategy_name_snapshot=strategy_cfg.get("strategy_name_snapshot"),
) )
# 模拟盘持仓检查由paper_monitor.py通过WebSocket实时处理这里不再检查 # 模拟盘持仓检查由paper_monitor.py通过WebSocket实时处理这里不再检查
@ -1476,7 +1636,12 @@ def main():
if cycle % 10 == 0: if cycle % 10 == 0:
old_strategies = list(PAPER_ENABLED_STRATEGIES) old_strategies = list(PAPER_ENABLED_STRATEGIES)
load_paper_config() load_paper_config()
strategy_configs = load_strategy_configs() # A1: 热重载权重/阈值/TP/SL # V5.4: 热重载优先读 DB失败 fallback 到 JSON
new_configs = load_strategy_configs_from_db()
if new_configs:
strategy_configs = new_configs
else:
strategy_configs = load_strategy_configs() # A1: 热重载权重/阈值/TP/SL
strategy_names = [cfg.get("name", "unknown") for cfg in strategy_configs] strategy_names = [cfg.get("name", "unknown") for cfg in strategy_configs]
primary_strategy_name = "v52_8signals" if any(cfg.get("name") == "v52_8signals" for cfg in strategy_configs) else strategy_names[0] primary_strategy_name = "v52_8signals" if any(cfg.get("name") == "v52_8signals" for cfg in strategy_configs) else strategy_names[0]
if list(PAPER_ENABLED_STRATEGIES) != old_strategies: if list(PAPER_ENABLED_STRATEGIES) != old_strategies:

View File

@ -0,0 +1,593 @@
---
title: "Arbitrage Engine 完整项目规格文档"
---
# Arbitrage Engine — 完整项目规格文档
> 供 Codex 重写使用。描述现有系统的所有功能、界面、后端逻辑、数据库结构。
> 语言:精确、无歧义、面向 AI。
---
## 一、项目概述
**项目名称**ArbitrageEngine
**核心目标**:加密货币量化策略研究平台,通过实时信号引擎计算多因子评分,在模拟盘上验证策略表现,为未来实盘交易提供数据支撑。
**当前阶段**模拟盘运行paper trading不连接真实资金。
**主要币种**BTCUSDT、ETHUSDT、SOLUSDT、XRPUSDT币安永续合约
---
## 二、技术栈
### 后端
- **语言**Python 3.11
- **框架**FastAPI异步 HTTP
- **数据库**PostgreSQL 18GCP Cloud SQL内网 IP 10.106.0.3,数据库名 arb_engine
- **认证**JWTHS256secret=`arb-engine-jwt-secret-v2-2026`
- **进程管理**PM2
- **WebSocket**`websockets` 库,连接币安 WebSocket stream
### 前端
- **框架**Next.js 14App Router
- **UI 组件**shadcn/ui + Tailwind CSS + Radix UI + Lucide Icons
- **图表**Recharts
- **主题**:默认暗色,主色 slate + cyan
- **HTTP 客户端**fetch原生
### 基础设施
- **服务器**GCP asia-northeast1-bUbuntuTailscale IP 100.105.186.73
- **Cloud SQL**GCP内网 10.106.0.3,公网 34.85.117.248PostgreSQL 18
- **PM2 路径**`/home/fzq1228/Projects/ops-dashboard/node_modules/pm2/bin/pm2`
- **项目路径**`/home/fzq1228/Projects/arbitrage-engine/`
- **前端端口**4333arb-web
- **后端端口**4332arb-api
- **对外域名**`https://arb.zhouyangclaw.com`
---
## 三、PM2 进程列表
| ID | 名称 | 职责 |
|----|------|------|
| 0 | arb-web | Next.js 前端,端口 4333 |
| 8 | arb-api | FastAPI 后端,端口 4332 |
| 9 | signal-engine | 核心信号引擎Python单进程事件循环 |
| 24 | agg-collector | 从币安 WebSocket 收集逐笔成交agg_trades |
| 25 | paper-monitor | 模拟盘实时止盈止损监控WebSocket 价格推送) |
| 26 | liq-collector | 收集强平数据liquidations 表) |
| 27 | market-collector | 收集市场指标资金费率、OI、多空比等 |
| 28 | position-sync | 实盘持仓同步(暂不使用) |
| 29 | risk-guard | 风控守护进程 |
---
## 四、数据库完整结构
### 4.1 `strategies` — 策略配置表V5.4 核心)
每行代表一个可独立运行的策略实例。
| 字段 | 类型 | 说明 |
|------|------|------|
| strategy_id | uuid PK | 自动生成,全局唯一 |
| display_name | text | 展示名,如 `BTC_CVD15x1h_TP保守` |
| schema_version | int | 默认 1 |
| status | text | `running` / `paused` / `deprecated` |
| status_changed_at | timestamp | 状态变更时间 |
| last_run_at | timestamp | 最近一次信号评估时间 |
| deprecated_at | timestamp | 停用时间 |
| symbol | text | 交易对,如 `BTCUSDT` |
| direction | text | `long` / `short` / `both`(默认 both |
| cvd_fast_window | text | CVD 快线窗口,如 `5m` / `15m` / `30m` |
| cvd_slow_window | text | CVD 慢线窗口,如 `30m` / `1h` / `4h` |
| weight_direction | int | 方向层权重(默认 55 |
| weight_env | int | 环境层权重(默认 25 |
| weight_aux | int | 辅助层权重(默认 15 |
| weight_momentum | int | 动量层权重(默认 5 |
| entry_score | int | 开仓最低分(默认 75 |
| gate_obi_enabled | bool | 是否启用 OBI 否决门 |
| obi_threshold | float | OBI 否决阈值(默认 0.3 |
| gate_whale_enabled | bool | 是否启用鲸鱼否决门 |
| whale_usd_threshold | float | 鲸鱼成交额阈值(默认 $50,000 |
| whale_flow_pct | float | 鲸鱼流向占比阈值 |
| gate_vol_enabled | bool | 是否启用波动率门 |
| vol_atr_pct_min | float | 最低 ATR/price 比例(默认 0.002 |
| gate_spot_perp_enabled | bool | 是否启用期现背离门 |
| spot_perp_threshold | float | 期现背离阈值(默认 0.002 |
| gate_cvd_enabled | bool | 是否启用 CVD 共振门 |
| sl_atr_multiplier | float | SL = sl_atr_multiplier × ATR默认 1.5 |
| tp1_ratio | float | TP1 = tp1_ratio × ATR默认 0.75 |
| tp2_ratio | float | TP2 = tp2_ratio × ATR默认 1.5 |
| timeout_minutes | int | 超时平仓分钟数(默认 240 |
| flip_threshold | int | 反向信号强制平仓的最低分(默认 80 |
| initial_balance | float | 初始模拟资金(默认 $10,000 |
| current_balance | float | 当前模拟余额 |
| description | text | 策略描述 |
| tags | text[] | 标签数组 |
| created_at | timestamp | 创建时间 |
| updated_at | timestamp | 更新时间 |
**已有的固定 UUID 策略legacystatus=deprecated**
- `00000000-0000-0000-0000-000000000053` → v53 StandardBTCUSDT+ETHUSDT+SOLUSDT+XRPUSDT30m/4h
- `00000000-0000-0000-0000-000000000054` → v53 Middle全币种15m/1h
- `00000000-0000-0000-0000-000000000055` → v53 Fast全币种5m/30m
**当前运行中策略18个 BTC 对照组)**
- 命名规则:`BTC_CVD{fast}x{slow}_TP{保守|平衡|激进}`
- 6个 CVD 组合 × 3个 TP 方案 = 18个策略
- symbol 全部 = `BTCUSDT`
- 权重统一dir=38 / env=32 / aux=28 / mom=2 / entry_score=71
---
### 4.2 `paper_trades` — 模拟盘交易记录
每行代表一笔模拟交易(开仓→平仓)。
| 字段 | 类型 | 说明 |
|------|------|------|
| id | bigint PK | 自增 |
| symbol | text | 交易对 |
| direction | text | `LONG` / `SHORT` |
| score | int | 开仓时评分 |
| tier | text | `light` / `standard` / `heavy`(仓位档位) |
| entry_price | float | 开仓价格(实时成交价快照) |
| entry_ts | bigint | 开仓时间戳(毫秒) |
| exit_price | float | 平仓价格 |
| exit_ts | bigint | 平仓时间戳(毫秒) |
| tp1_price | float | 止盈1价格 |
| tp2_price | float | 止盈2价格 |
| sl_price | float | 止损价格 |
| tp1_hit | bool | 是否曾触及 TP1 |
| status | text | `active` / `tp1_hit` / `tp` / `sl` / `sl_be` / `timeout` / `signal_flip` |
| pnl_r | float | 盈亏(以 R 计1R = SL 距离) |
| atr_at_entry | float | 开仓时 ATR 值 |
| score_factors | jsonb | 四层评分详情 |
| strategy | varchar | 策略名(如 `custom_62047807` |
| risk_distance | float | 1R 对应的价格距离(= sl_atr_multiplier × ATR |
| calc_version | int | 计算版本1=VWAP2=last_trade |
| price_source | text | 价格来源 |
| strategy_id | uuid FK | 关联 strategies 表 |
| strategy_name_snapshot | text | 开仓时的策略展示名快照 |
**status 含义**
- `active`:持仓中,尚未触及 TP1
- `tp1_hit`:已触及 TP1移动止损到保本价
- `tp`:全仓止盈出场(同时触及 TP2或手动
- `sl`止损出场pnl_r = -1.0
- `sl_be`保本止损出场pnl_r ≈ +0.5 × tp1_ratio小正收益
- `timeout`持仓超时平仓240分钟
- `signal_flip`:反向信号强制平仓
**pnl_r 计算规则**
- SL 触发:`pnl_r = -1.0`(恒定)
- TP1 触发后 SL_BE`pnl_r = 0.5 × tp1_ratio`
- 全仓 TP2`pnl_r = 0.5 × tp1_ratio + 0.5 × tp2_ratio`
- timeout/flip`pnl_r = (exit_price - entry_price) / risk_distance`LONG 方向SHORT 取反)
**胜率定义**(重要):`pnl_r > 0` 的笔数 / 总闭仓笔数。不用 status 字段判断。
---
### 4.3 `signal_indicators` — 信号指标快照
每次信号引擎评估时写入一行,记录当时所有原始指标和评分结果。
| 字段 | 类型 | 说明 |
|------|------|------|
| id | bigint PK | 自增 |
| ts | bigint | 时间戳(毫秒) |
| symbol | text | 交易对 |
| cvd_fast | float | 旧字段30m CVD别名实际=cvd_30m |
| cvd_mid | float | 旧字段4h CVD别名实际=cvd_4h |
| cvd_day | float | 24h CVD |
| cvd_fast_slope | float | CVD 快线斜率 |
| atr_5m | float | 5分钟粒度 ATR |
| atr_percentile | float | ATR 百分位0~100 |
| vwap_30m | float | 30分钟 VWAP |
| price | float | 当前价格(实时最新成交价) |
| p95_qty | float | 过去1分钟成交量 P95 |
| p99_qty | float | 过去1分钟成交量 P99 |
| buy_vol_1m | float | 过去1分钟主动买入量 |
| sell_vol_1m | float | 过去1分钟主动卖出量 |
| score | int | 综合评分0~100 |
| signal | text | `LONG` / `SHORT` / nullnull=无信号) |
| factors | jsonb | 四层评分详情,格式见下 |
| strategy | varchar | 策略名 |
| atr_value | float | ATR 值(用于计算 SL/TP |
| cvd_5m | float | 5分钟 CVD |
| cvd_15m | float | 15分钟 CVD |
| cvd_30m | float | 30分钟 CVD |
| cvd_1h | float | 1小时 CVD |
| cvd_4h | float | 4小时 CVD |
| strategy_id | uuid FK | 关联 strategies 表 |
| strategy_name_snapshot | text | 策略展示名快照 |
**factors jsonb 结构**
```json
{
"direction": {"score": 38.0, "detail": "CVD共振LONG, cvd_fast=1234.5, cvd_slow=5678.9"},
"environment": {"score": 32.0, "detail": "ATR正常, VWAP上方"},
"auxiliary": {"score": 28.0, "detail": "OBI=0.45, 无否决"},
"momentum": {"score": 2.0, "detail": "P99成交量正常"},
"total": 75,
"gate_passed": true,
"block_reason": null
}
```
---
### 4.4 `agg_trades` — 逐笔成交数据(分区表)
主表 + 按月分区子表agg_trades_202602, agg_trades_202603 等)。
| 字段 | 类型 | 说明 |
|------|------|------|
| agg_id | bigint PK | 币安 agg trade ID |
| symbol | text | 交易对 |
| price | float | 成交价格 |
| qty | float | 成交量 |
| time_ms | bigint | 成交时间戳(毫秒) |
| is_buyer_maker | smallint | 0=主动买入1=主动卖出 |
**数据量**
- BTCUSDT2026-02-05 起,约 8949 万条
- ETHUSDT2026-02-25 起,约 3297 万条
- SOLUSDT/XRPUSDT2026-02-28 起,约 400~500 万条
---
### 4.5 `market_indicators` — 市场宏观指标
| 字段 | 类型 | 说明 |
|------|------|------|
| id | int PK | 自增 |
| symbol | varchar | 交易对 |
| indicator_type | varchar | 指标类型(`funding_rate` / `open_interest` / `ls_ratio` 等) |
| timestamp_ms | bigint | 时间戳 |
| value | jsonb | 指标值(结构因类型而异) |
| created_at | timestamp | 写入时间 |
---
### 4.6 `rate_snapshots` — 资金费率快照
| 字段 | 说明 |
|------|------|
| ts | 时间戳(毫秒) |
| btc_rate | BTC 资金费率 |
| eth_rate | ETH 资金费率 |
| btc_price | BTC 价格 |
| eth_price | ETH 价格 |
| btc_index_price | BTC 指数价格 |
| eth_index_price | ETH 指数价格 |
---
### 4.7 `liquidations` — 强平数据
| 字段 | 说明 |
|------|------|
| symbol | 交易对 |
| side | `LONG` / `SHORT` |
| price | 强平价格 |
| qty | 数量 |
| usd_value | USD 价值 |
| trade_time | 时间戳(毫秒) |
---
### 4.8 `users` — 用户认证
| 字段 | 说明 |
|------|------|
| id | bigint PK |
| email | 邮箱(唯一) |
| password_hash | bcrypt 哈希 |
| role | `admin` / `user` |
| discord_id | Discord 用户 ID |
| banned | 0=正常1=封禁 |
Admin 账号:`fzq1228@gmail.com`role=admin。
---
### 4.9 `signal_feature_events` — 信号特征事件(机器学习数据集)
记录每次 gate 决策的全部原始特征,用于事后分析和 Optuna 优化。
重要字段:`gate_passed`bool、`score_direction`、`score_crowding`、`score_environment`、`score_aux`、`score_total`、`block_reason`。
---
## 五、信号引擎signal_engine.py详细逻辑
### 5.1 架构
- **单进程 Python 事件循环**asyncio每 ~15 秒运行一轮
- 每轮对 4 个币种 × N 个策略 进行评估
- 实时数据来源:`agg_trades` 表(由 agg-collector 写入)
- 附加数据来源:`market_indicators` 表OBI、OI、资金费率等
### 5.2 滑动窗口
每个币种维护 3 个滑动窗口(按 `agg_trades.time_ms` 切片):
- `win_fast`30分钟窗口`WINDOW_FAST = 30 * 60 * 1000 ms`
- `win_mid`4小时窗口`WINDOW_MID = 4 * 3600 * 1000 ms`
- `win_day`24小时窗口`WINDOW_DAY = 24 * 3600 * 1000 ms`
每个窗口存储 `(time_ms, qty, price, is_buyer_maker)` 元组列表,定期淘汰过期数据。
**CVD 计算**
`CVD = 主动买入量 - 主动卖出量`(在窗口时间范围内求和)
**动态切片**
当策略配置了非标准窗口(如 15m、1h`win_fast``win_mid` 的 trades 列表中按时间范围切片重算 CVD
- fast 周期 ≤ 30m → 从 `win_fast.trades` 切片
- fast 周期 > 30m → 从 `win_mid.trades` 切片
- slow 周期 → 始终从 `win_mid.trades` 切片
### 5.3 评分模型(`_evaluate_v53`
**四层线性评分**,总分 = 各层得分之和,满分 = 各层权重之和。
#### 门控系统5个门按顺序任意一门否决则 gate_passed=false不开仓
| 门 | 条件 | 否决理由 |
|----|------|----------|
| 门1 波动率 | `ATR/price >= vol_atr_pct_min` | ATR 过小,市场太平静 |
| 门2 CVD共振 | `cvd_fast``cvd_slow` 同向(同正=LONG同负=SHORT | 快慢 CVD 不共振 |
| 门3 鲸鱼否决 | 大额成交(>whale_usd_threshold的净流向与信号方向一致或该门禁用 | 鲸鱼反向 |
| 门4 OBI否决 | OBI订单簿不平衡不与信号方向矛盾或该门禁用 | OBI 反向 |
| 门5 期现背离 | 现货-永续价差在阈值内(或该门禁用) | 期现背离过大 |
门2CVD共振同时决定信号方向两个 CVD 都正→LONG都负→SHORT不同向→HOLD跳过
#### 评分计算gate_passed=true 后)
**方向层**weight_direction默认 38
- CVD共振强度 → 占方向层满分的比例
- 公式:`score_dir = weight_direction × min(1.0, abs(cvd_fast + cvd_slow) / normalization_factor)`
**环境层**weight_env默认 32
- ATR 百分位(价格波动强度)
- VWAP 相对位置
- 资金费率方向
**辅助层**weight_aux默认 28
- OBI 强度
- 期现背离程度
- 强平数据方向
**动量层**weight_momentum默认 2
- P99 成交量异常
- 买卖量比
**开仓条件**`total_score >= entry_score`(默认 75
### 5.4 开仓逻辑
```
for each symbol:
update sliding windows with new agg_trades
for each strategy (status=running):
if strategy.symbol != symbol → skip
evaluate_signal(strategy_cfg) → result
if result.signal and score >= entry_score:
if no active position for this strategy:
if active_position_count < max_positions:
paper_open_trade(...)
```
**开仓价格**:取 `signal_indicators.price`(实时最新成交价),不是 VWAP。
**SL/TP 计算**
- `risk_distance = sl_atr_multiplier × ATR`1R
- LONG`SL = price - risk_distance``TP1 = price + tp1_ratio × ATR``TP2 = price + tp2_ratio × ATR`
- SHORT`SL = price + risk_distance``TP1 = price - tp1_ratio × ATR``TP2 = price - tp2_ratio × ATR`
**当前标准TP/SL配置BTC 18组对照**
- 保守:`sl=2.0×ATR, tp1=0.75×ATR, tp2=1.5×ATR`TP全到=+0.5625R
- 平衡:`sl=2.0×ATR, tp1=1.0×ATR, tp2=2.0×ATR`TP全到=+1.5R
- 激进:`sl=2.0×ATR, tp1=1.5×ATR, tp2=3.0×ATR`TP全到=+2.25R
### 5.5 平仓逻辑paper_monitor.py
独立进程,通过币安 WebSocket 实时接收 mark price
1. `price >= tp1_price`LONG`price <= tp1_price`SHORT→ 触发 TP1status 改为 `tp1_hit`,移动止损到保本价
2. TP1 已触发后,`price >= tp2_price` → 全仓 TP2status=`tp`
3. `price <= sl_price`LONG`price >= sl_price`SHORT→ 止损status=`sl``pnl_r=-1.0`
4. 持仓超 240 分钟 → status=`timeout`
5. 反向信号强度 >= flip_threshold → signal_engine 触发 `signal_flip`
---
## 六、FastAPI 后端接口main.py
### 6.1 认证
- `POST /api/auth/login` → 返回 JWT token
- `POST /api/auth/register` → 注册(需邀请码)
- 所有接口需 Bearer JWT通过 `Depends(get_current_user)` 校验
### 6.2 行情数据
- `GET /api/rates` → 最新资金费率快照rate_snapshots 最新一行)
- `GET /api/snapshots` → 多个时间点的资金费率历史
- `GET /api/kline?symbol=BTC&interval=1m&limit=100` → K线从 agg_trades 聚合)
### 6.3 信号引擎
- `GET /api/signals/latest?strategy=v53` → 各币种最新一条信号指标signal_indicators 每币种 LIMIT 1
- `GET /api/signals/latest-v52?strategy=v52_8signals` → 同上v52 字段
- `GET /api/signals/indicators?symbol=BTCUSDT&strategy=v53&limit=100` → 历史信号指标列表
- `GET /api/signals/signal-history?symbol=BTC&strategy=v53&limit=50` → 有信号signal IS NOT NULL的历史列表
- `GET /api/signals/market-indicators?symbol=BTCUSDT` → 最新市场宏观指标OI/多空比/资金费率)
- `GET /api/signals/history?strategy=v53&limit=100` → 信号历史(含各层分数)
### 6.4 模拟盘paper trading
- `GET /api/paper/config` → 读取 paper_config.json
- `POST /api/paper/config` → 更新 paper_config.json控制总开关和每策略开关
- `GET /api/paper/summary?strategy=v53&strategy_id=uuid` → 总览总盈亏R/USDT、胜率、余额、持仓数、盈亏比
- `GET /api/paper/positions?strategy=v53&strategy_id=uuid` → 当前活跃持仓列表(含实时浮盈计算)
- `GET /api/paper/trades?strategy=v53&strategy_id=uuid&symbol=BTC&status=tp&limit=100` → 历史交易列表
- `GET /api/paper/equity-curve?strategy=v53&strategy_id=uuid` → 权益曲线(按时间累加 pnl_r
- `GET /api/paper/stats?strategy=v53&strategy_id=uuid` → 详细统计(胜率/盈亏比/MDD/Sharpe/avg_win/avg_loss按币种分组
- `GET /api/paper/stats-by-strategy` → 所有策略的汇总统计(策略广场用)
### 6.5 策略广场strategy plaza
- `GET /api/strategy-plaza?status=running` → 策略列表(支持 status 过滤)
- `GET /api/strategy-plaza/{strategy_id}/summary` → 单策略总览卡
- `GET /api/strategy-plaza/{strategy_id}/signals` → 单策略最新信号
- `GET /api/strategy-plaza/{strategy_id}/trades` → 单策略交易记录
### 6.6 策略管理 CRUD
- `POST /api/strategies` → 创建新策略(写入 strategies 表signal_engine 15秒内热重载
- `GET /api/strategies` → 策略列表(含 open_positions 数量)
- `GET /api/strategies/{sid}` → 单策略详情
- `PATCH /api/strategies/{sid}` → 更新策略参数
- `POST /api/strategies/{sid}/pause` → 暂停status=paused
- `POST /api/strategies/{sid}/resume` → 恢复status=running
- `POST /api/strategies/{sid}/deprecate` → 停用status=deprecated
- `POST /api/strategies/{sid}/restore` → 恢复到 running
- `POST /api/strategies/{sid}/add-balance` → 补充模拟资金
### 6.7 服务器监控
- `GET /api/server/status` → 服务器资源CPU/内存/磁盘/PM2进程状态
### 6.8 实盘接口(暂未真实使用)
- `GET /api/live/*` → 实盘持仓、交易、权益曲线、账户余额、风控状态等
- `POST /api/live/emergency-close` → 紧急平仓
- `POST /api/live/block-new` → 暂停新开仓
- `POST /api/live/resume` → 恢复开仓
---
## 七、前端页面列表
### 7.1 主导航Sidebar
固定左侧 sidebar包含所有页面入口和当前 BTC/ETH 资金费率实时显示。
### 7.2 页面详情
#### `/` (首页/Dashboard)
- 资金费率历史折线图BTC/ETH 双轨)
- 资金费率 24h 统计(最大/最小/均值)
- 近期套利机会卡片
#### `/signals-v53` (信号引擎 v5.3,老页面)
- CVD 三轨卡片cvd_fast/cvd_mid/cvd_day + 斜率 + 共振判断)
- ATR / VWAP / P95 / P99 市场环境指标
- 信号状态卡LONG/SHORT/无信号 + 评分 + 四层分数进度条)
- Gate-Control 卡5门详情波动率/CVD共振/鲸鱼/OBI/期现背离)
- 信号历史列表最近20条时间/方向/评分)
- CVD 折线图(可切 1h/4h/12h/24h 时间范围)
- 币种切换BTC/ETH/SOL/XRP
#### `/paper-v53` (模拟盘 v5.3,老页面)
- 总览卡片总盈亏R/USDT/胜率/持仓数/盈亏比/余额)
- 最新信号(四币种最近信号+四层分+Gate状态
- 控制面板(启动/停止按钮)
- 当前活跃持仓实时价格WebSocket/浮盈R/入场TP SL价格
- 权益曲线(面积图)
- 历史交易列表(筛选币种/盈亏;入场/出场价/状态/评分/持仓时长)
- 详细统计(胜率/盈亏比/avg_win/avg_loss/MDD/Sharpe按币种分Tab
#### `/strategy-plaza` (策略广场,主入口)
- 策略列表卡片视图(按 status 过滤running/paused/deprecated
- 每个卡片显示:策略名/币种/CVD周期/TP方案/胜率/净R/余额/状态
- 快速操作:暂停/恢复/停用
- 「新建策略」按钮 → 跳转 `/strategy-plaza/create`
#### `/strategy-plaza/create` (新建策略)
- 表单:策略名/symbol/CVD快慢周期/四层权重/五门阈值/TP-SL参数/初始余额
- 提交后 POST /api/strategiessignal_engine 15秒内热重载
#### `/strategy-plaza/[id]` (策略详情页,通用)
- Tab1: 信号引擎SignalsGeneric 组件)
- 动态显示该策略配置的 CVD 周期、权重、Gate 阈值
- 实时信号评分
- 信号历史、CVD 图表
- Tab2: 模拟盘PaperGeneric 组件)
- 总览统计、活跃持仓、权益曲线、交易列表、详细统计
- 启停控制
#### `/strategy-plaza/[id]/edit` (编辑策略)
- 同新建表单,预填当前参数,提交 PATCH /api/strategies/{id}
#### `/server` (服务器状态)
- CPU/内存/磁盘/PM2进程状态实时监控
#### `/kline` (K线图)
- 任意币种 K线支持 1m/5m/15m/1h 粒度
#### `/login` / `/register`
- JWT 登录/邀请码注册
---
## 八、paper_config.json模拟盘控制文件
路径:`backend/paper_config.json`
```json
{
"enabled": true,
"enabled_strategies": [],
"initial_balance": 10000,
"risk_per_trade": 0.02,
"max_positions": 100,
"tier_multiplier": {"light": 0.5, "standard": 1.0, "heavy": 1.5}
}
```
- `enabled_strategies` 为空列表 = 全部策略放行
- `max_positions` 当前设为 100实际无限制
---
## 九、数据流(完整链路)
```
币安 WebSocket逐笔成交
↓ agg-collector.py
agg_trades 表PostgreSQL 分区表)
↓ signal_engine.py每15秒读取
滑动窗口win_fast 30m / win_mid 4h / win_day 24h
↓ 计算CVD/ATR/VWAP
_evaluate_v53()
↓ 5门检查 → 四层评分
signal_indicators 写入
↓ signal IS NOT NULL 且 score >= entry_score
paper_open_trade() → paper_trades 写入
paper_monitor.pyWebSocket 实时价格监控)
↓ 触及 TP1/TP2/SL 或超时
paper_trades 平仓status/exit_price/pnl_r 更新)
FastAPImain.py← Next.js 前端查询展示
```
---
## 十、已知缺陷Codex 重写需改进)
1. **signal_engine.py 单体巨型文件**1800+ 行),所有逻辑混在一起
2. **所有策略共享同一个 snapshot**,无法真正独立评估
3. **CVD 动态切片依赖 win_fast/win_mid 两个固定窗口**,扩展性受限
4. **开仓逻辑耦合在 signal_engine 主循环**paper_monitor 只管平仓
5. **前端页面碎片化**:每个策略版本有独立页面,维护困难
6. **strategy_name 路由逻辑脆弱**`custom_` 路由曾多次因 scp 覆盖丢失
7. **API 无分页**,大数据量接口可能超时
---
## 十一、关键配置
```
数据库连接: postgresql://arb:arb_engine_2026@10.106.0.3/arb_engine
JWT Secret: arb-engine-jwt-secret-v2-2026
Admin: fzq1228@gmail.com (id=1, role=admin)
前端: https://arb.zhouyangclaw.com (端口 4333)
后端: 端口 4332
项目路径: /home/fzq1228/Projects/arbitrage-engine/
```

View File

@ -29,7 +29,7 @@
### 1.3 状态机伪代码 ### 1.3 状态机伪代码
```pseudo ```text
state = IDLE state = IDLE
on_signal_open(signal): on_signal_open(signal):
@ -106,7 +106,7 @@ on_timer():
### 2.3 TP 状态机maker 主路径 + taker 兜底) ### 2.3 TP 状态机maker 主路径 + taker 兜底)
```pseudo ```text
on_position_open(pos): on_position_open(pos):
// 开仓后立即挂 TP1 限价单maker // 开仓后立即挂 TP1 限价单maker
tp1_price = pos.entry_price + pos.side * tp1_r * pos.risk_distance tp1_price = pos.entry_price + pos.side * tp1_r * pos.risk_distance
@ -159,7 +159,7 @@ on_timer():
### 2.4 SL 状态机(纯 taker ### 2.4 SL 状态机(纯 taker
```pseudo ```text
on_sl_trigger(pos, sl_price): on_sl_trigger(pos, sl_price):
// 触发条件可以来自价格监控或止损订单触发 // 触发条件可以来自价格监控或止损订单触发
// 这里策略层只关心:一旦触发,立即使用 taker // 这里策略层只关心:一旦触发,立即使用 taker
@ -173,7 +173,7 @@ SL 不做 maker 逻辑,避免在极端行情下挂单无法成交。
### 2.5 Flip 状态机(平旧仓 + 新开仓) ### 2.5 Flip 状态机(平旧仓 + 新开仓)
```pseudo ```text
on_flip_signal(pos, new_side, flip_context): on_flip_signal(pos, new_side, flip_context):
if not flip_condition_met(flip_context): if not flip_condition_met(flip_context):
return return
@ -197,7 +197,7 @@ flip 的关键是:**门槛更高**(如 score < 85 且 OBI 翻转且价格跌
### 2.6 Timeout 状态机(超时出场) ### 2.6 Timeout 状态机(超时出场)
```pseudo ```text
on_timer(): on_timer():
if pos.state == POSITION_OPEN and now() - pos.open_ts >= timeout_seconds: if pos.state == POSITION_OPEN and now() - pos.open_ts >= timeout_seconds:
// 可以偏 maker先挂限价平仓超时再 taker // 可以偏 maker先挂限价平仓超时再 taker

View File

@ -0,0 +1,367 @@
"use client";
import { useState, useEffect } from "react";
import { authFetch } from "@/lib/auth";
import { XAxis, YAxis, Tooltip, ResponsiveContainer, ReferenceLine, Area, AreaChart } from "recharts";
function bjt(ms: number) {
const d = new Date(ms + 8 * 3600 * 1000);
return `${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")} ${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}`;
}
function fmtPrice(p: number) {
return p < 100 ? p.toFixed(4) : p.toLocaleString("en-US", { minimumFractionDigits: 1, maximumFractionDigits: 1 });
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function parseFactors(raw: any) {
if (!raw) return null;
if (typeof raw === "string") { try { return JSON.parse(raw); } catch { return null; } }
return raw;
}
interface Props {
strategyId: string;
symbol: string;
}
type FilterResult = "all" | "win" | "loss";
type FilterSymbol = "all" | string;
// ─── 控制面板(策略启停)─────────────────────────────────────────
function ControlPanel({ strategyId }: { strategyId: string }) {
const [status, setStatus] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
(async () => {
try {
const r = await authFetch(`/api/strategies/${strategyId}`);
if (r.ok) { const j = await r.json(); setStatus(j.status); }
} catch {}
})();
}, [strategyId]);
const toggle = async () => {
setSaving(true);
const newStatus = status === "running" ? "paused" : "running";
try {
const r = await authFetch(`/api/strategies/${strategyId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: newStatus }),
});
if (r.ok) setStatus(newStatus);
} catch {} finally { setSaving(false); }
};
if (!status) return null;
return (
<div className={`rounded-xl border-2 ${status === "running" ? "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}
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${status === "running" ? "bg-red-500 text-white hover:bg-red-600" : "bg-emerald-500 text-white hover:bg-emerald-600"}`}>
{saving ? "..." : status === "running" ? "⏹ 暂停" : "▶️ 启动"}
</button>
<span className={`text-xs font-medium ${status === "running" ? "text-emerald-700" : "text-slate-500"}`}>
{status === "running" ? "🟢 运行中" : "⚪ 已暂停"}
</span>
</div>
</div>
);
}
// ─── 总览卡片 ────────────────────────────────────────────────────
function SummaryCards({ strategyId }: { strategyId: string }) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [data, setData] = useState<any>(null);
useEffect(() => {
const f = async () => {
try {
const r = await authFetch(`/api/paper/summary?strategy_id=${strategyId}`);
if (r.ok) setData(await r.json());
} catch {}
};
f(); const iv = setInterval(f, 10000); return () => clearInterval(iv);
}, [strategyId]);
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-6 gap-1.5">
{[
{ label: "总盈亏(R)", value: `${data.total_pnl >= 0 ? "+" : ""}${data.total_pnl}R`, sub: `${data.total_pnl_usdt >= 0 ? "+" : ""}$${data.total_pnl_usdt}`, color: data.total_pnl >= 0 ? "text-emerald-600" : "text-red-500" },
{ label: "胜率", value: `${data.win_rate}%`, sub: `${data.total_trades}`, color: "text-slate-800" },
{ label: "持仓中", value: data.active_positions, sub: "活跃仓位", color: "text-blue-600" },
{ label: "盈亏比", value: data.profit_factor, sub: "PF", color: "text-slate-800" },
{ label: "当前资金", value: `$${data.balance?.toLocaleString()}`, sub: "虚拟余额", color: data.balance >= 10000 ? "text-emerald-600" : "text-red-500" },
{ label: "状态", value: data.start_time ? "运行中 ✅" : "等待首笔", sub: "accumulating", color: "text-slate-600" },
].map(({ label, value, sub, color }) => (
<div key={label} className="bg-white rounded-lg border border-slate-200 px-2.5 py-2">
<p className="text-[10px] text-slate-400">{label}</p>
<p className={`font-mono font-bold text-base ${color}`}>{value}</p>
<p className="text-[10px] text-slate-400">{sub}</p>
</div>
))}
</div>
);
}
// ─── 当前持仓 ────────────────────────────────────────────────────
function ActivePositions({ strategyId }: { strategyId: string }) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [positions, setPositions] = useState<any[]>([]);
const [wsPrices, setWsPrices] = useState<Record<string, number>>({});
const RISK_USD = 200; // 1R = 200 USDT
useEffect(() => {
const f = async () => {
try {
const r = await authFetch(`/api/paper/positions?strategy_id=${strategyId}`);
if (r.ok) setPositions((await r.json()).data || []);
} catch {}
};
f(); const iv = setInterval(f, 10000); return () => clearInterval(iv);
}, [strategyId]);
useEffect(() => {
const streams = ["btcusdt", "ethusdt", "xrpusdt", "solusdt"].map(s => `${s}@aggTrade`).join("/");
const ws = new WebSocket(`wss://fstream.binance.com/stream?streams=${streams}`);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ws.onmessage = (e) => { try { const msg = JSON.parse(e.data); if (msg.data) { const sym = msg.data.s; const price = parseFloat(msg.data.p); 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"></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></div>
<div className="divide-y divide-slate-100">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{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 entry = p.entry_price || 0;
const riskDist = p.risk_distance || Math.abs(entry - (p.sl_price || entry)) || 1;
const tp1R = riskDist > 0 ? (p.direction === "LONG" ? ((p.tp1_price || 0) - entry) / riskDist : (entry - (p.tp1_price || 0)) / riskDist) : 0;
const fullR = riskDist > 0 ? (p.direction === "LONG" ? (currentPrice - entry) / riskDist : (entry - currentPrice) / riskDist) : 0;
const unrealR = p.tp1_hit ? 0.5 * tp1R + 0.5 * fullR : fullR;
const unrealUsdt = unrealR * RISK_USD;
return (
<div key={p.id} className="px-3 py-2 bg-emerald-50/60">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 flex-wrap">
<span className={`text-xs font-bold ${p.direction === "LONG" ? "text-emerald-600" : "text-red-500"}`}>{p.direction === "LONG" ? "🟢" : "🔴"} {sym} {p.direction}</span>
<span className="text-[10px] text-slate-500">{p.score}</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</span>
<span className={`text-[10px] ${unrealUsdt >= 0 ? "text-emerald-500" : "text-red-400"}`}>${unrealUsdt.toFixed(0)}</span>
<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">
<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>
</div>
<div className="mt-1 text-[9px] text-slate-400">
: {p.entry_ts ? bjt(p.entry_ts) : "-"}
</div>
</div>
);
})}
</div>
</div>
);
}
// ─── 权益曲线 ────────────────────────────────────────────────────
function EquityCurve({ strategyId }: { strategyId: string }) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [data, setData] = useState<any[]>([]);
useEffect(() => {
const f = async () => {
try {
const r = await authFetch(`/api/paper/equity-curve?strategy_id=${strategyId}`);
if (r.ok) setData((await r.json()).data || []);
} catch {}
};
f(); const iv = setInterval(f, 30000); return () => clearInterval(iv);
}, [strategyId]);
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>
{data.length < 2 ? <div className="px-3 py-6 text-center text-xs text-slate-400">...</div> : (
<div className="p-2" style={{ height: 200 }}>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data}>
<XAxis dataKey="ts" tickFormatter={(v) => bjt(Number(v))} tick={{ fontSize: 10 }} />
<YAxis tick={{ fontSize: 10 }} tickFormatter={(v) => `${v}R`} />
<Tooltip labelFormatter={(v) => bjt(Number(v))} formatter={(v: unknown) => [`${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>
);
}
// ─── 历史交易 ────────────────────────────────────────────────────
function TradeHistory({ strategyId }: { strategyId: string }) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [trades, setTrades] = useState<any[]>([]);
const [filterResult, setFilterResult] = useState<FilterResult>("all");
const [filterSym, setFilterSym] = useState<FilterSymbol>("all");
useEffect(() => {
const f = async () => {
try {
const r = await authFetch(`/api/paper/trades?strategy_id=${strategyId}&result=${filterResult}&symbol=${filterSym}&limit=50`);
if (r.ok) setTrades((await r.json()).data || []);
} catch {}
};
f(); const iv = setInterval(f, 10000); return () => clearInterval(iv);
}, [strategyId, filterResult, filterSym]);
const fmtTime = (ms: number) => ms ? bjt(ms) : "-";
const STATUS_LABEL: Record<string, string> = { tp: "止盈", sl: "止损", sl_be: "保本", timeout: "超时", signal_flip: "翻转" };
const STATUS_COLOR: Record<string, string> = { tp: "bg-emerald-100 text-emerald-700", sl: "bg-red-100 text-red-700", sl_be: "bg-amber-100 text-amber-700", signal_flip: "bg-purple-100 text-purple-700", timeout: "bg-slate-100 text-slate-600" };
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">
{(["all", "BTC", "ETH", "XRP", "SOL"] as FilterSymbol[]).map(s => (
<button key={s} onClick={() => setFilterSym(s)} className={`px-2 py-0.5 rounded text-[10px] ${filterSym === 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={() => setFilterResult(r)} className={`px-2 py-0.5 rounded text-[10px] ${filterResult === r ? "bg-slate-800 text-white" : "text-slate-500 hover:bg-slate-100"}`}>{r === "all" ? "全部" : r === "win" ? "盈利" : "亏损"}</button>
))}
</div>
</div>
<div className="max-h-64 overflow-y-auto">
{trades.length === 0 ? <div className="text-center text-slate-400 text-sm py-6"></div> : (
<table className="w-full text-[11px]">
<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-right font-medium"></th>
<th className="px-2 py-1.5 text-right font-medium"></th>
<th className="px-2 py-1.5 text-right font-medium">PnL(R)</th>
<th className="px-2 py-1.5 text-center font-medium"></th>
<th className="px-2 py-1.5 text-right font-medium"></th>
<th className="px-2 py-1.5 text-right font-medium"></th>
<th className="px-2 py-1.5 text-right font-medium"></th>
<th className="px-2 py-1.5 text-right font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{trades.map((t: any) => {
const holdMin = t.exit_ts && t.entry_ts ? Math.round((t.exit_ts - t.entry_ts) / 60000) : 0;
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 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)}</td>
<td className="px-2 py-1.5 text-center"><span className={`px-1 py-0.5 rounded text-[9px] ${STATUS_COLOR[t.status] || "bg-slate-100 text-slate-600"}`}>{STATUS_LABEL[t.status] || t.status}</span></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 whitespace-nowrap">{fmtTime(t.entry_ts)}</td>
<td className="px-2 py-1.5 text-right text-slate-400 whitespace-nowrap">{fmtTime(t.exit_ts)}</td>
<td className="px-2 py-1.5 text-right text-slate-400">{holdMin}m</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>
);
}
// ─── 详细统计 ────────────────────────────────────────────────────
function StatsPanel({ strategyId }: { strategyId: string }) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [data, setData] = useState<any>(null);
const [tab, setTab] = useState("ALL");
useEffect(() => {
const f = async () => {
try {
const r = await authFetch(`/api/paper/stats?strategy_id=${strategyId}`);
if (r.ok) setData(await r.json());
} catch {}
};
f(); const iv = setInterval(f, 30000); return () => clearInterval(iv);
}, [strategyId]);
if (!data || data.error) return (
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden">
<div className="px-3 py-2 border-b border-slate-100"><h3 className="font-semibold text-slate-800 text-xs"></h3></div>
<div className="p-3 text-xs text-slate-400">...</div>
</div>
);
const coinTabs = ["ALL", "BTC", "ETH", "XRP", "SOL"];
const st = tab === "ALL" ? data : (data.by_symbol?.[tab] || 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 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">
{coinTabs.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>
))}
</div>
</div>
{st ? (
<div className="p-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>
</div>
</div>
) : <div className="p-3 text-xs text-slate-400"></div>}
</div>
);
}
// ─── 主组件 ──────────────────────────────────────────────────────
export default function PaperGeneric({ strategyId, symbol }: Props) {
return (
<div className="space-y-3 p-1">
<div>
<h2 className="text-sm font-bold text-slate-900">📈 </h2>
<p className="text-[10px] text-slate-500">{symbol.replace("USDT", "")} · strategy_id: {strategyId.slice(0, 8)}...</p>
</div>
<ControlPanel strategyId={strategyId} />
<SummaryCards strategyId={strategyId} />
<ActivePositions strategyId={strategyId} />
<EquityCurve strategyId={strategyId} />
<TradeHistory strategyId={strategyId} />
<StatsPanel strategyId={strategyId} />
</div>
);
}

View File

@ -0,0 +1,446 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { authFetch } from "@/lib/auth";
import {
ComposedChart, Area, Line, XAxis, YAxis, Tooltip, ResponsiveContainer,
ReferenceLine, CartesianGrid, Legend
} from "recharts";
interface IndicatorRow {
ts: number;
cvd_fast: number;
cvd_mid: number;
cvd_day: number;
atr_5m: number;
vwap_30m: number;
price: number;
score: number;
signal: string | null;
}
interface LatestIndicator {
ts: number;
cvd_fast: number;
cvd_mid: number;
cvd_day: number;
cvd_fast_slope: number;
atr_5m: number;
atr_percentile: number;
vwap_30m: number;
price: number;
p95_qty: number;
p99_qty: number;
score: number;
signal: string | null;
tier?: "light" | "standard" | "heavy" | null;
gate_passed?: boolean;
factors?: {
gate_passed?: boolean;
gate_block?: string;
block_reason?: string;
obi_raw?: number;
spot_perp_div?: number;
whale_cvd_ratio?: number;
atr_pct_price?: number;
direction?: { score?: number; max?: number };
crowding?: { score?: number; max?: number };
environment?: { score?: number; max?: number };
auxiliary?: { score?: number; max?: number };
} | null;
}
interface SignalRecord {
ts: number;
score: number;
signal: string;
}
interface Gates {
obi_threshold: number;
whale_usd_threshold: number;
whale_flow_pct: number;
vol_atr_pct_min: number;
spot_perp_threshold: number;
}
interface Weights {
direction: number;
env: number;
aux: number;
momentum: number;
}
interface Props {
strategyId: string;
symbol: string;
cvdFastWindow: string;
cvdSlowWindow: string;
weights: Weights;
gates: Gates;
}
const WINDOWS = [
{ label: "1h", value: 60 },
{ label: "4h", value: 240 },
{ label: "12h", value: 720 },
{ label: "24h", value: 1440 },
];
function bjtStr(ms: number) {
const d = new Date(ms + 8 * 3600 * 1000);
return `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}`;
}
function bjtFull(ms: number) {
const d = new Date(ms + 8 * 3600 * 1000);
return `${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")} ${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}:${String(d.getUTCSeconds()).padStart(2, "0")}`;
}
function fmt(v: number, decimals = 1): string {
if (Math.abs(v) >= 1000000) return `${(v / 1000000).toFixed(1)}M`;
if (Math.abs(v) >= 1000) return `${(v / 1000).toFixed(1)}K`;
return v.toFixed(decimals);
}
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 (
<div className="flex items-center gap-2">
<span className="text-[10px] text-slate-500 w-6 shrink-0">{label}</span>
<div className="flex-1 h-1.5 rounded-full bg-slate-100 overflow-hidden">
<div className={`h-full ${colorClass}`} style={{ width: `${ratio}%` }} />
</div>
<span className="text-[10px] font-mono text-slate-600 w-12 text-right">{score}/{max}</span>
</div>
);
}
function GateCard({ factors, gates }: { factors: LatestIndicator["factors"]; gates: Gates }) {
if (!factors) return null;
const passed = factors.gate_passed ?? true;
const blockReason = factors.gate_block || factors.block_reason;
return (
<div className={`rounded-xl border px-3 py-2 mt-2 ${passed ? "border-purple-200 bg-purple-50" : "border-red-200 bg-red-50"}`}>
<div className="flex items-center justify-between mb-1.5">
<p className="text-[10px] font-semibold text-purple-800">🔒 Gate-Control</p>
<span className={`text-[10px] font-bold px-2 py-0.5 rounded ${passed ? "bg-emerald-100 text-emerald-700" : "bg-red-100 text-red-600"}`}>
{passed ? "✅ Gate通过" : "❌ 否决"}
</span>
</div>
<div className="grid grid-cols-4 gap-1.5">
<div className="bg-white rounded px-2 py-1">
<p className="text-[10px] text-slate-400"></p>
<p className="text-xs font-mono text-slate-800">{((factors.atr_pct_price ?? 0) * 100).toFixed(3)}%</p>
<p className="text-[9px] text-slate-400"> {(gates.vol_atr_pct_min * 100).toFixed(2)}%</p>
</div>
<div className="bg-white rounded px-2 py-1">
<p className="text-[10px] text-slate-400">OBI</p>
<p className={`text-xs font-mono ${(factors.obi_raw ?? 0) >= 0 ? "text-emerald-600" : "text-red-500"}`}>
{((factors.obi_raw ?? 0) * 100).toFixed(2)}%
</p>
<p className="text-[9px] text-slate-400">±{gates.obi_threshold}</p>
</div>
<div className="bg-white rounded px-2 py-1">
<p className="text-[10px] text-slate-400"></p>
<p className={`text-xs font-mono ${(factors.spot_perp_div ?? 0) >= 0 ? "text-emerald-600" : "text-red-500"}`}>
{((factors.spot_perp_div ?? 0) * 10000).toFixed(2)}bps
</p>
<p className="text-[9px] text-slate-400">±{(gates.spot_perp_threshold * 100).toFixed(1)}%</p>
</div>
<div className="bg-white rounded px-2 py-1">
<p className="text-[10px] text-slate-400"></p>
<p className="text-xs font-mono text-slate-800">${(gates.whale_usd_threshold / 1000).toFixed(0)}k</p>
<p className="text-[9px] text-slate-400">{">"}{(gates.whale_flow_pct * 100).toFixed(0)}%</p>
</div>
</div>
{blockReason && (
<p className="text-[10px] text-red-600 mt-1.5 bg-red-50 rounded px-2 py-1">
: <span className="font-mono">{blockReason}</span>
</p>
)}
</div>
);
}
function IndicatorCards({ sym, strategyName, cvdFastWindow, cvdSlowWindow, weights, gates }: {
sym: string; strategyName: string; cvdFastWindow: string; cvdSlowWindow: string; weights: Weights; gates: Gates;
}) {
const [data, setData] = useState<LatestIndicator | null>(null);
const coin = sym.replace("USDT", "") as "BTC" | "ETH" | "XRP" | "SOL";
useEffect(() => {
const fetch_ = async () => {
try {
const res = await authFetch(`/api/signals/latest?strategy=${strategyName}`);
if (!res.ok) return;
const json = await res.json();
setData(json[coin] || null);
} catch {}
};
fetch_();
const iv = setInterval(fetch_, 5000);
return () => clearInterval(iv);
}, [coin, strategyName]);
if (!data) return <div className="text-center text-slate-400 text-sm py-4">...</div>;
const priceVsVwap = data.price > data.vwap_30m ? "上方" : "下方";
const totalWeight = weights.direction + weights.env + weights.aux + weights.momentum;
return (
<div className="space-y-3">
{/* CVD双轨 */}
<div className="grid grid-cols-3 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">CVD_fast ({cvdFastWindow})</p>
<p className={`font-mono font-bold text-sm ${data.cvd_fast >= 0 ? "text-emerald-600" : "text-red-500"}`}>
{fmt(data.cvd_fast)}
</p>
<p className="text-[10px] text-slate-400">
: <span className={data.cvd_fast_slope >= 0 ? "text-emerald-600" : "text-red-500"}>
{data.cvd_fast_slope >= 0 ? "↑" : "↓"}{fmt(Math.abs(data.cvd_fast_slope))}
</span>
</p>
</div>
<div className="bg-white rounded-lg border border-slate-200 px-2.5 py-2">
<p className="text-[10px] text-slate-400">CVD_slow ({cvdSlowWindow})</p>
<p className={`font-mono font-bold text-sm ${data.cvd_mid >= 0 ? "text-emerald-600" : "text-red-500"}`}>
{fmt(data.cvd_mid)}
</p>
<p className="text-[10px] text-slate-400">{data.cvd_mid > 0 ? "多" : "空"}</p>
</div>
<div className="bg-white rounded-lg border border-slate-200 px-2.5 py-2">
<p className="text-[10px] text-slate-400">CVD共振</p>
<p className={`font-mono font-bold text-sm ${data.cvd_fast >= 0 && data.cvd_mid >= 0 ? "text-emerald-600" : data.cvd_fast < 0 && data.cvd_mid < 0 ? "text-red-500" : "text-slate-400"}`}>
{data.cvd_fast >= 0 && data.cvd_mid >= 0 ? "✅ 多头共振" : data.cvd_fast < 0 && data.cvd_mid < 0 ? "✅ 空头共振" : "⚠️ 分歧"}
</p>
<p className="text-[10px] text-slate-400"></p>
</div>
</div>
{/* ATR + VWAP + P95/P99 */}
<div className="grid grid-cols-4 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">ATR</p>
<p className="font-mono font-semibold text-sm text-slate-800">${fmt(data.atr_5m, 2)}</p>
<p className="text-[10px]">
<span className={data.atr_percentile > 60 ? "text-amber-600 font-semibold" : "text-slate-400"}>
{data.atr_percentile.toFixed(0)}%{data.atr_percentile > 60 ? "🔥" : ""}
</span>
</p>
</div>
<div className="bg-white rounded-lg border border-slate-200 px-2.5 py-2">
<p className="text-[10px] text-slate-400">VWAP</p>
<p className="font-mono font-semibold text-sm text-slate-800">${data.vwap_30m.toLocaleString("en-US", { maximumFractionDigits: 1 })}</p>
<p className="text-[10px]"><span className={data.price > data.vwap_30m ? "text-emerald-600" : "text-red-500"}>{priceVsVwap}</span></p>
</div>
<div className="bg-white rounded-lg border border-slate-200 px-2.5 py-2">
<p className="text-[10px] text-slate-400">P95</p>
<p className="font-mono font-semibold text-sm text-slate-800">{data.p95_qty?.toFixed(4) ?? "-"}</p>
<p className="text-[10px] text-slate-400"></p>
</div>
<div className="bg-white rounded-lg border border-slate-200 px-2.5 py-2">
<p className="text-[10px] text-slate-400">P99</p>
<p className="font-mono font-semibold text-sm text-amber-600">{data.p99_qty?.toFixed(4) ?? "-"}</p>
<p className="text-[10px] text-slate-400"></p>
</div>
</div>
{/* 信号状态 + 四层分 */}
<div className={`rounded-xl border px-3 py-2.5 ${
data.signal === "LONG" ? "border-emerald-300 bg-emerald-50" :
data.signal === "SHORT" ? "border-red-300 bg-red-50" :
"border-slate-200 bg-slate-50"
}`}>
<div className="flex items-center justify-between">
<div>
<p className="text-[10px] text-slate-500"> · {coin}</p>
<p className={`font-bold text-base ${
data.signal === "LONG" ? "text-emerald-700" :
data.signal === "SHORT" ? "text-red-600" :
"text-slate-400"
}`}>
{data.signal === "LONG" ? "🟢 做多" : data.signal === "SHORT" ? "🔴 做空" : "⚪ 无信号"}
</p>
</div>
<div className="text-right">
<p className="font-mono font-bold text-lg text-slate-800">{data.score}/{totalWeight}</p>
<p className="text-[10px] text-slate-500">
{data.tier === "heavy" ? "加仓" : data.tier === "standard" ? "标准" : "不开仓"}
</p>
</div>
</div>
<div className="mt-2 space-y-1">
<LayerScore label="方向" score={data.factors?.direction?.score ?? 0} max={weights.direction} colorClass="bg-blue-600" />
<LayerScore label="环境" score={data.factors?.environment?.score ?? 0} max={weights.env} colorClass="bg-emerald-600" />
<LayerScore label="辅助" score={data.factors?.auxiliary?.score ?? 0} max={weights.aux} colorClass="bg-violet-600" />
<LayerScore label="动量" score={data.factors?.crowding?.score ?? 0} max={weights.momentum} colorClass="bg-slate-500" />
</div>
</div>
{/* Gate 卡片 */}
<GateCard factors={data.factors} gates={gates} />
</div>
);
}
function SignalHistory({ coin, strategyName }: { coin: string; strategyName: string }) {
const [data, setData] = useState<SignalRecord[]>([]);
useEffect(() => {
const fetchData = async () => {
try {
const res = await authFetch(`/api/signals/signal-history?symbol=${coin}&limit=20&strategy=${strategyName}`);
if (!res.ok) return;
const json = await res.json();
setData(json.data || []);
} catch {}
};
fetchData();
const iv = setInterval(fetchData, 15000);
return () => clearInterval(iv);
}, [coin, strategyName]);
if (data.length === 0) return null;
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"></h3>
</div>
<div className="divide-y divide-slate-100 max-h-48 overflow-y-auto">
{data.map((s, i) => (
<div key={i} className="px-3 py-1.5 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={`text-xs font-bold ${s.signal === "LONG" ? "text-emerald-600" : "text-red-500"}`}>
{s.signal === "LONG" ? "🟢 LONG" : "🔴 SHORT"}
</span>
<span className="text-[10px] text-slate-400">{bjtFull(s.ts)}</span>
</div>
<span className="font-mono text-xs text-slate-700">{s.score}</span>
</div>
))}
</div>
</div>
);
}
function CVDChart({ sym, minutes, strategyName, cvdFastWindow, cvdSlowWindow }: {
sym: string; minutes: number; strategyName: string; cvdFastWindow: string; cvdSlowWindow: string;
}) {
const [data, setData] = useState<IndicatorRow[]>([]);
const [loading, setLoading] = useState(true);
const coin = sym.replace("USDT", "");
const fetchData = useCallback(async (silent = false) => {
try {
const res = await authFetch(`/api/signals/indicators?symbol=${coin}&minutes=${minutes}&strategy=${strategyName}`);
if (!res.ok) return;
const json = await res.json();
setData(json.data || []);
if (!silent) setLoading(false);
} catch {}
}, [coin, minutes, strategyName]);
useEffect(() => {
setLoading(true);
fetchData();
const iv = setInterval(() => fetchData(true), 30000);
return () => clearInterval(iv);
}, [fetchData]);
const chartData = data.map(d => ({
time: bjtStr(d.ts),
fast: parseFloat(d.cvd_fast?.toFixed(2) || "0"),
mid: parseFloat(d.cvd_mid?.toFixed(2) || "0"),
price: d.price,
}));
const prices = chartData.map(d => d.price).filter(v => v > 0);
const pMin = prices.length ? Math.min(...prices) : 0;
const pMax = prices.length ? Math.max(...prices) : 0;
const pPad = (pMax - pMin) * 0.3 || pMax * 0.001;
if (loading) return <div className="flex items-center justify-center h-48 text-slate-400 text-sm">...</div>;
if (data.length === 0) return <div className="flex items-center justify-center h-48 text-slate-400 text-sm">...</div>;
return (
<ResponsiveContainer width="100%" height={220}>
<ComposedChart data={chartData} margin={{ top: 4, right: 60, bottom: 0, left: 8 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
<XAxis dataKey="time" tick={{ fill: "#94a3b8", fontSize: 10 }} tickLine={false} interval="preserveStartEnd" />
<YAxis yAxisId="cvd" tick={{ fill: "#94a3b8", fontSize: 10 }} tickLine={false} axisLine={false} width={55} />
<YAxis yAxisId="price" orientation="right" tick={{ fill: "#f59e0b", fontSize: 10 }} tickLine={false} axisLine={false} width={65}
domain={[Math.floor(pMin - pPad), Math.ceil(pMax + pPad)]}
tickFormatter={(v: number) => v >= 1000 ? `$${(v / 1000).toFixed(1)}k` : `$${v.toFixed(0)}`}
/>
<Tooltip
// eslint-disable-next-line @typescript-eslint/no-explicit-any
formatter={(v: any, name: any) => {
if (name === "price") return [`$${Number(v).toLocaleString()}`, "币价"];
if (name === "fast") return [fmt(Number(v)), `CVD_fast(${cvdFastWindow})`];
return [fmt(Number(v)), `CVD_slow(${cvdSlowWindow})`];
}}
contentStyle={{ background: "#fff", border: "1px solid #e2e8f0", borderRadius: 8, fontSize: 11 }}
/>
<Legend wrapperStyle={{ fontSize: 11 }} />
<ReferenceLine yAxisId="cvd" y={0} stroke="#94a3b8" strokeDasharray="4 2" />
<Area yAxisId="cvd" type="monotone" dataKey="fast" name="fast" stroke="#2563eb" fill="#eff6ff" strokeWidth={1.5} dot={false} connectNulls />
<Line yAxisId="cvd" type="monotone" dataKey="mid" name="mid" stroke="#7c3aed" strokeWidth={1.5} dot={false} connectNulls strokeDasharray="6 3" />
<Line yAxisId="price" type="monotone" dataKey="price" name="price" stroke="#f59e0b" strokeWidth={1.5} dot={false} connectNulls strokeDasharray="4 2" />
</ComposedChart>
</ResponsiveContainer>
);
}
export default function SignalsGeneric({ strategyId, symbol, cvdFastWindow, cvdSlowWindow, weights, gates }: Props) {
const [minutes, setMinutes] = useState(240);
const coin = symbol.replace("USDT", "");
const strategyName = `custom_${strategyId.slice(0, 8)}`;
return (
<div className="space-y-3 p-1">
<div className="flex items-center justify-between flex-wrap gap-2">
<div>
<h2 className="text-sm font-bold text-slate-900"> </h2>
<p className="text-slate-500 text-[10px]">
CVD {cvdFastWindow}/{cvdSlowWindow} · {weights.direction}/{weights.env}/{weights.aux}/{weights.momentum} · {coin}
</p>
</div>
<span className="px-2 py-0.5 rounded text-[10px] font-semibold bg-blue-100 text-blue-700 border border-blue-200">{coin}</span>
</div>
<IndicatorCards
sym={symbol}
strategyName={strategyName}
cvdFastWindow={cvdFastWindow}
cvdSlowWindow={cvdSlowWindow}
weights={weights}
gates={gates}
/>
<SignalHistory coin={coin} strategyName={strategyName} />
<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 flex items-center justify-between flex-wrap gap-1">
<div>
<h3 className="font-semibold text-slate-800 text-xs">CVD双轨 + </h3>
<p className="text-[10px] text-slate-400">=fast({cvdFastWindow}) · =slow({cvdSlowWindow}) · =</p>
</div>
<div className="flex gap-1">
{WINDOWS.map(w => (
<button key={w.value} onClick={() => setMinutes(w.value)}
className={`px-2 py-1 rounded border text-xs transition-colors ${minutes === w.value ? "bg-slate-800 text-white border-slate-800" : "border-slate-200 text-slate-500 hover:border-slate-400"}`}>
{w.label}
</button>
))}
</div>
</div>
<div className="px-3 py-2">
<CVDChart sym={symbol} minutes={minutes} strategyName={strategyName} cvdFastWindow={cvdFastWindow} cvdSlowWindow={cvdSlowWindow} />
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,82 @@
"use client";
import { useAuth, authFetch } from "@/lib/auth";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import StrategyForm, { StrategyFormData } from "@/components/StrategyForm";
export default function EditStrategyPage() {
useAuth();
const params = useParams();
const router = useRouter();
const sid = params?.id as string;
const [formData, setFormData] = useState<StrategyFormData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
if (!sid) return;
authFetch(`/api/strategies/${sid}`)
.then((r) => r.json())
.then((d) => {
const s = d.strategy;
if (!s) throw new Error("策略不存在");
setFormData({
display_name: s.display_name,
symbol: s.symbol,
direction: s.direction,
initial_balance: s.initial_balance,
cvd_fast_window: s.cvd_fast_window,
cvd_slow_window: s.cvd_slow_window,
weight_direction: s.weight_direction,
weight_env: s.weight_env,
weight_aux: s.weight_aux,
weight_momentum: s.weight_momentum,
entry_score: s.entry_score,
// 门1 波动率
gate_vol_enabled: s.gate_vol_enabled,
vol_atr_pct_min: s.vol_atr_pct_min,
// 门2 CVD共振
gate_cvd_enabled: s.gate_cvd_enabled ?? true,
// 门3 鲸鱼否决
gate_whale_enabled: s.gate_whale_enabled,
whale_usd_threshold: s.whale_usd_threshold,
whale_flow_pct: s.whale_flow_pct,
// 门4 OBI否决
gate_obi_enabled: s.gate_obi_enabled,
obi_threshold: s.obi_threshold,
// 门5 期现背离
gate_spot_perp_enabled: s.gate_spot_perp_enabled,
spot_perp_threshold: s.spot_perp_threshold,
sl_atr_multiplier: s.sl_atr_multiplier,
tp1_ratio: s.tp1_ratio,
tp2_ratio: s.tp2_ratio,
timeout_minutes: s.timeout_minutes,
flip_threshold: s.flip_threshold,
description: s.description || "",
});
})
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, [sid]);
if (loading) return <div className="p-8 text-slate-400 text-sm animate-pulse">...</div>;
if (error) return <div className="p-8 text-red-500 text-sm">{error}</div>;
if (!formData) return null;
return (
<div className="p-4 max-w-2xl mx-auto">
<div className="mb-5">
<h1 className="text-lg font-bold text-slate-800"></h1>
<p className="text-slate-500 text-xs mt-0.5">15</p>
</div>
<StrategyForm
mode="edit"
initialData={formData}
strategyId={sid}
isBalanceEditable={false}
onSuccess={() => router.push(`/strategy-plaza/${sid}`)}
/>
</div>
);
}

View File

@ -11,6 +11,7 @@ import {
PauseCircle, PauseCircle,
AlertCircle, AlertCircle,
Clock, Clock,
Settings,
} from "lucide-react"; } from "lucide-react";
// ─── Dynamic imports for each strategy's pages ─────────────────── // ─── Dynamic imports for each strategy's pages ───────────────────
@ -20,10 +21,20 @@ const SignalsV53Middle = dynamic(() => import("@/app/signals-v53middle/page"), {
const PaperV53 = dynamic(() => import("@/app/paper-v53/page"), { ssr: false }); const PaperV53 = dynamic(() => import("@/app/paper-v53/page"), { ssr: false });
const PaperV53Fast = dynamic(() => import("@/app/paper-v53fast/page"), { ssr: false }); const PaperV53Fast = dynamic(() => import("@/app/paper-v53fast/page"), { ssr: false });
const PaperV53Middle = dynamic(() => import("@/app/paper-v53middle/page"), { ssr: false }); const PaperV53Middle = dynamic(() => import("@/app/paper-v53middle/page"), { ssr: false });
const SignalsGeneric = dynamic(() => import("./SignalsGeneric"), { ssr: false });
const PaperGeneric = dynamic(() => import("./PaperGeneric"), { ssr: false });
// ─── UUID → legacy strategy name map ─────────────────────────────
const UUID_TO_LEGACY: Record<string, string> = {
"00000000-0000-0000-0000-000000000053": "v53",
"00000000-0000-0000-0000-000000000054": "v53_middle",
"00000000-0000-0000-0000-000000000055": "v53_fast",
};
// ─── Types ──────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────
interface StrategySummary { interface StrategySummary {
id: string; strategy_id?: string;
id?: string;
display_name: string; display_name: string;
status: string; status: string;
started_at: number; started_at: number;
@ -39,7 +50,42 @@ interface StrategySummary {
pnl_usdt_24h: number; pnl_usdt_24h: number;
pnl_r_24h: number; pnl_r_24h: number;
cvd_windows?: string; cvd_windows?: string;
cvd_fast_window?: string;
cvd_slow_window?: string;
description?: string; description?: string;
symbol?: string;
}
interface StrategyDetail {
weight_direction: number;
weight_env: number;
weight_aux: number;
weight_momentum: number;
entry_score: number;
// 门1 波动率
gate_vol_enabled: boolean;
vol_atr_pct_min: number;
// 门2 CVD共振
gate_cvd_enabled: boolean;
// 门3 鲸鱼否决
gate_whale_enabled: boolean;
whale_usd_threshold: number;
whale_flow_pct: number;
// 门4 OBI否决
gate_obi_enabled: boolean;
obi_threshold: number;
// 门5 期现背离
gate_spot_perp_enabled: boolean;
spot_perp_threshold: number;
sl_atr_multiplier: number;
tp1_ratio: number;
tp2_ratio: number;
timeout_minutes: number;
flip_threshold: number;
symbol: string;
direction: string;
cvd_fast_window: string;
cvd_slow_window: string;
} }
// ─── Helpers ────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────
@ -54,24 +100,127 @@ function fmtDur(ms: number) {
} }
function StatusBadge({ status }: { status: string }) { function StatusBadge({ status }: { status: string }) {
if (status === "running") return <span className="flex items-center gap-1 text-xs text-emerald-400"><CheckCircle size={12} /></span>; if (status === "running") return <span className="flex items-center gap-1 text-xs text-emerald-600 font-medium"><CheckCircle size={12} /></span>;
if (status === "paused") return <span className="flex items-center gap-1 text-xs text-yellow-400"><PauseCircle size={12} /></span>; if (status === "paused") return <span className="flex items-center gap-1 text-xs text-yellow-600 font-medium"><PauseCircle size={12} /></span>;
return <span className="flex items-center gap-1 text-xs text-red-400"><AlertCircle size={12} /></span>; return <span className="flex items-center gap-1 text-xs text-red-500 font-medium"><AlertCircle size={12} /></span>;
}
// ─── Config Tab ───────────────────────────────────────────────────
function ConfigTab({ detail, strategyId }: { detail: StrategyDetail; strategyId: string }) {
const router = useRouter();
const row = (label: string, value: string | number | boolean) => (
<div className="flex items-center justify-between py-2 border-b border-slate-100 last:border-0">
<span className="text-xs text-slate-500">{label}</span>
<span className="text-xs font-medium text-slate-800">{String(value)}</span>
</div>
);
const gateRow = (label: string, enabled: boolean, threshold: string) => (
<div className="flex items-center justify-between py-2 border-b border-slate-100 last:border-0">
<div className="flex items-center gap-2">
<span className={`inline-block w-2 h-2 rounded-full ${enabled ? "bg-emerald-400" : "bg-slate-300"}`} />
<span className="text-xs text-slate-500">{label}</span>
</div>
<span className={`text-xs font-medium ${enabled ? "text-slate-800" : "text-slate-400"}`}>
{enabled ? threshold : "已关闭"}
</span>
</div>
);
return (
<div className="space-y-4">
<div className="flex justify-end">
<button
onClick={() => router.push(`/strategy-plaza/${strategyId}/edit`)}
className="flex items-center gap-1.5 px-4 py-2 rounded-xl bg-blue-600 text-white text-xs font-medium hover:bg-blue-700 transition-colors"
>
<Settings size={13} />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 基础配置 */}
<div className="bg-white border border-slate-200 rounded-xl p-4">
<h4 className="text-xs font-semibold text-slate-600 mb-2"></h4>
{row("交易对", detail.symbol)}
{row("交易方向", detail.direction === "both" ? "多空双向" : detail.direction === "long_only" ? "只做多" : "只做空")}
{row("CVD 快线", detail.cvd_fast_window)}
{row("CVD 慢线", detail.cvd_slow_window)}
</div>
{/* 四层权重 */}
<div className="bg-white border border-slate-200 rounded-xl p-4">
<h4 className="text-xs font-semibold text-slate-600 mb-2"></h4>
{row("方向权重", `${detail.weight_direction}%`)}
{row("环境权重", `${detail.weight_env}%`)}
{row("辅助权重", `${detail.weight_aux}%`)}
{row("动量权重", `${detail.weight_momentum}%`)}
{row("入场阈值", `${detail.entry_score}`)}
</div>
{/* 五道 Gate */}
<div className="bg-white border border-slate-200 rounded-xl p-4">
<h4 className="text-xs font-semibold text-slate-600 mb-2"> (Gate)</h4>
{gateRow("门1 波动率", detail.gate_vol_enabled, `ATR% ≥ ${((detail.vol_atr_pct_min ?? 0) * 100).toFixed(2)}%`)}
{gateRow("门2 CVD共振", detail.gate_cvd_enabled ?? true, "快慢CVD同向")}
{gateRow("门3 鲸鱼否决", detail.gate_whale_enabled, `USD ≥ $${((detail.whale_usd_threshold ?? 50000) / 1000).toFixed(0)}k`)}
{gateRow("门4 OBI否决", detail.gate_obi_enabled, `阈值 ${detail.obi_threshold}`)}
{gateRow("门5 期现背离", detail.gate_spot_perp_enabled, `溢价 ≤ ${((detail.spot_perp_threshold ?? 0.005) * 100).toFixed(2)}%`)}
</div>
{/* 风控参数 */}
<div className="bg-white border border-slate-200 rounded-xl p-4">
<h4 className="text-xs font-semibold text-slate-600 mb-2"></h4>
{row("SL 宽度", `${detail.sl_atr_multiplier} × ATR`)}
{row("TP1 目标", `${detail.tp1_ratio} × RD`)}
{row("TP2 目标", `${detail.tp2_ratio} × RD`)}
{row("超时", `${detail.timeout_minutes} 分钟`)}
{row("反转阈值", `${detail.flip_threshold}`)}
</div>
</div>
</div>
);
} }
// ─── Content router ─────────────────────────────────────────────── // ─── Content router ───────────────────────────────────────────────
function SignalsContent({ strategyId }: { strategyId: string }) { function SignalsContent({ strategyId, symbol, detail }: { strategyId: string; symbol?: string; detail?: StrategyDetail | null }) {
if (strategyId === "v53") return <SignalsV53 />; const legacy = UUID_TO_LEGACY[strategyId] || strategyId;
if (strategyId === "v53_fast") return <SignalsV53Fast />; if (legacy === "v53") return <SignalsV53 />;
if (strategyId === "v53_middle") return <SignalsV53Middle />; if (legacy === "v53_fast") return <SignalsV53Fast />;
return <div className="p-8 text-gray-400">: {strategyId}</div>; if (legacy === "v53_middle") return <SignalsV53Middle />;
const weights = detail ? {
direction: detail.weight_direction,
env: detail.weight_env,
aux: detail.weight_aux,
momentum: detail.weight_momentum,
} : { direction: 38, env: 32, aux: 28, momentum: 2 };
const gates = detail ? {
obi_threshold: detail.obi_threshold,
whale_usd_threshold: detail.whale_usd_threshold,
whale_flow_pct: detail.whale_flow_pct,
vol_atr_pct_min: detail.vol_atr_pct_min,
spot_perp_threshold: detail.spot_perp_threshold,
} : { obi_threshold: 0.3, whale_usd_threshold: 100000, whale_flow_pct: 0.5, vol_atr_pct_min: 0.002, spot_perp_threshold: 0.003 };
return (
<SignalsGeneric
strategyId={strategyId}
symbol={symbol || "BTCUSDT"}
cvdFastWindow={detail?.cvd_fast_window || "15m"}
cvdSlowWindow={detail?.cvd_slow_window || "1h"}
weights={weights}
gates={gates}
/>
);
} }
function PaperContent({ strategyId }: { strategyId: string }) { function PaperContent({ strategyId, symbol }: { strategyId: string; symbol?: string }) {
if (strategyId === "v53") return <PaperV53 />; const legacy = UUID_TO_LEGACY[strategyId] || strategyId;
if (strategyId === "v53_fast") return <PaperV53Fast />; if (legacy === "v53") return <PaperV53 />;
if (strategyId === "v53_middle") return <PaperV53Middle />; if (legacy === "v53_fast") return <PaperV53Fast />;
return <div className="p-8 text-gray-400">: {strategyId}</div>; if (legacy === "v53_middle") return <PaperV53Middle />;
return <PaperGeneric strategyId={strategyId} symbol={symbol || "BTCUSDT"} />;
} }
// ─── Main Page ──────────────────────────────────────────────────── // ─── Main Page ────────────────────────────────────────────────────
@ -83,9 +232,66 @@ export default function StrategyDetailPage() {
const tab = searchParams?.get("tab") || "signals"; const tab = searchParams?.get("tab") || "signals";
const [summary, setSummary] = useState<StrategySummary | null>(null); const [summary, setSummary] = useState<StrategySummary | null>(null);
const [detail, setDetail] = useState<StrategyDetail | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const fetchSummary = useCallback(async () => { const fetchData = useCallback(async () => {
try {
// Try new /api/strategies/{id} first for full detail
const r = await authFetch(`/api/strategies/${strategyId}`);
if (r.ok) {
const d = await r.json();
const s = d.strategy;
setSummary({
strategy_id: s.strategy_id,
display_name: s.display_name,
status: s.status,
started_at: s.started_at || s.created_at || Date.now(),
initial_balance: s.initial_balance,
current_balance: s.current_balance,
net_usdt: s.net_usdt || 0,
net_r: s.net_r || 0,
trade_count: s.trade_count || 0,
win_rate: s.win_rate || 0,
avg_win_r: s.avg_win_r || 0,
avg_loss_r: s.avg_loss_r || 0,
open_positions: s.open_positions || 0,
pnl_usdt_24h: s.pnl_usdt_24h || 0,
pnl_r_24h: s.pnl_r_24h || 0,
cvd_fast_window: s.cvd_fast_window,
cvd_slow_window: s.cvd_slow_window,
description: s.description,
});
setDetail({
weight_direction: s.weight_direction,
weight_env: s.weight_env,
weight_aux: s.weight_aux,
weight_momentum: s.weight_momentum,
entry_score: s.entry_score,
gate_vol_enabled: s.gate_vol_enabled,
vol_atr_pct_min: s.vol_atr_pct_min,
gate_cvd_enabled: s.gate_cvd_enabled,
gate_whale_enabled: s.gate_whale_enabled,
whale_usd_threshold: s.whale_usd_threshold,
whale_flow_pct: s.whale_flow_pct,
gate_obi_enabled: s.gate_obi_enabled,
obi_threshold: s.obi_threshold,
gate_spot_perp_enabled: s.gate_spot_perp_enabled,
spot_perp_threshold: s.spot_perp_threshold,
sl_atr_multiplier: s.sl_atr_multiplier,
tp1_ratio: s.tp1_ratio,
tp2_ratio: s.tp2_ratio,
timeout_minutes: s.timeout_minutes,
flip_threshold: s.flip_threshold,
symbol: s.symbol,
direction: s.direction,
cvd_fast_window: s.cvd_fast_window,
cvd_slow_window: s.cvd_slow_window,
});
return;
}
} catch {}
// Fallback to legacy /api/strategy-plaza/{id}/summary
try { try {
const r = await authFetch(`/api/strategy-plaza/${strategyId}/summary`); const r = await authFetch(`/api/strategy-plaza/${strategyId}/summary`);
if (r.ok) { if (r.ok) {
@ -97,10 +303,10 @@ export default function StrategyDetailPage() {
}, [strategyId]); }, [strategyId]);
useEffect(() => { useEffect(() => {
fetchSummary(); fetchData().finally(() => setLoading(false));
const iv = setInterval(fetchSummary, 30000); const iv = setInterval(fetchData, 30000);
return () => clearInterval(iv); return () => clearInterval(iv);
}, [fetchSummary]); }, [fetchData]);
if (loading) { if (loading) {
return ( return (
@ -111,17 +317,20 @@ export default function StrategyDetailPage() {
} }
const isProfit = (summary?.net_usdt ?? 0) >= 0; const isProfit = (summary?.net_usdt ?? 0) >= 0;
const cvdLabel = summary?.cvd_fast_window
? `${summary.cvd_fast_window}/${summary.cvd_slow_window}`
: summary?.cvd_windows || "";
return ( return (
<div className="p-4 max-w-full"> <div className="p-4 max-w-full">
{/* Back + Strategy Header */} {/* Back + Strategy Header */}
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<Link href="/strategy-plaza" className="flex items-center gap-1 text-gray-400 hover:text-white text-sm transition-colors"> <Link href="/strategy-plaza" className="flex items-center gap-1 text-slate-500 hover:text-slate-800 text-sm transition-colors">
<ArrowLeft size={16} /> <ArrowLeft size={16} />
广 广
</Link> </Link>
<span className="text-gray-600">/</span> <span className="text-slate-300">/</span>
<span className="text-white font-medium">{summary?.display_name ?? strategyId}</span> <span className="text-slate-800 font-medium">{summary?.display_name ?? strategyId}</span>
</div> </div>
{/* Summary Bar */} {/* Summary Bar */}
@ -131,8 +340,8 @@ export default function StrategyDetailPage() {
<span className="text-xs text-slate-400 flex items-center gap-1"> <span className="text-xs text-slate-400 flex items-center gap-1">
<Clock size={10} /> {fmtDur(summary.started_at)} <Clock size={10} /> {fmtDur(summary.started_at)}
</span> </span>
{summary.cvd_windows && ( {cvdLabel && (
<span className="text-xs text-blue-600 bg-blue-50 border border-blue-100 px-2 py-0.5 rounded">CVD {summary.cvd_windows}</span> <span className="text-xs text-blue-600 bg-blue-50 border border-blue-100 px-2 py-0.5 rounded">CVD {cvdLabel}</span>
)} )}
<span className="ml-auto flex items-center gap-4 text-xs"> <span className="ml-auto flex items-center gap-4 text-xs">
<span className="text-slate-500"> <span className={summary.win_rate >= 50 ? "text-emerald-600 font-bold" : "text-amber-600 font-bold"}>{summary.win_rate}%</span></span> <span className="text-slate-500"> <span className={summary.win_rate >= 50 ? "text-emerald-600 font-bold" : "text-amber-600 font-bold"}>{summary.win_rate}%</span></span>
@ -148,6 +357,7 @@ export default function StrategyDetailPage() {
{[ {[
{ key: "signals", label: "📊 信号引擎" }, { key: "signals", label: "📊 信号引擎" },
{ key: "paper", label: "📈 模拟盘" }, { key: "paper", label: "📈 模拟盘" },
{ key: "config", label: "⚙️ 参数配置" },
].map(({ key, label }) => ( ].map(({ key, label }) => (
<button <button
key={key} key={key}
@ -163,12 +373,13 @@ export default function StrategyDetailPage() {
))} ))}
</div> </div>
{/* Content — direct render of existing pages */} {/* Content */}
<div> <div>
{tab === "signals" ? ( {tab === "signals" && <SignalsContent strategyId={strategyId} symbol={summary?.symbol} detail={detail} />}
<SignalsContent strategyId={strategyId} /> {tab === "paper" && <PaperContent strategyId={strategyId} symbol={summary?.symbol} />}
) : ( {tab === "config" && detail && <ConfigTab detail={detail} strategyId={strategyId} />}
<PaperContent strategyId={strategyId} /> {tab === "config" && !detail && (
<div className="text-center text-slate-400 text-sm py-16"></div>
)} )}
</div> </div>
</div> </div>

View File

@ -0,0 +1,24 @@
"use client";
import { useAuth } from "@/lib/auth";
import { useRouter } from "next/navigation";
import StrategyForm, { DEFAULT_FORM } from "@/components/StrategyForm";
export default function CreateStrategyPage() {
useAuth();
const router = useRouter();
return (
<div className="p-4 max-w-2xl mx-auto">
<div className="mb-5">
<h1 className="text-lg font-bold text-slate-800"></h1>
<p className="text-slate-500 text-xs mt-0.5"></p>
</div>
<StrategyForm
mode="create"
initialData={DEFAULT_FORM}
onSuccess={(id) => router.push(`/strategy-plaza/${id}`)}
/>
</div>
);
}

View File

@ -0,0 +1,196 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { authFetch, useAuth } from "@/lib/auth";
import Link from "next/link";
import {
TrendingUp, TrendingDown, Clock, Activity, RotateCcw
} from "lucide-react";
interface DeprecatedStrategy {
strategy_id: string;
display_name: string;
symbol: string;
status: string;
started_at: number;
deprecated_at: number | null;
initial_balance: number;
current_balance: number;
net_usdt: number;
net_r: number;
trade_count: number;
win_rate: number;
avg_win_r: number;
avg_loss_r: number;
pnl_usdt_24h: number;
last_trade_at: number | null;
}
function formatTime(ms: number | null): string {
if (!ms) return "—";
return new Date(ms).toLocaleString("zh-CN", {
timeZone: "Asia/Shanghai",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
}
export default function DeprecatedStrategiesPage() {
useAuth();
const [strategies, setStrategies] = useState<DeprecatedStrategy[]>([]);
const [loading, setLoading] = useState(true);
const [restoring, setRestoring] = useState<string | null>(null);
const fetchData = useCallback(async () => {
try {
const res = await authFetch("/api/strategies?include_deprecated=true");
const data = await res.json();
const deprecated = (data.strategies || []).filter(
(s: DeprecatedStrategy) => s.status === "deprecated"
);
setStrategies(deprecated);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
const handleRestore = async (sid: string, name: string) => {
if (!confirm(`确认重新启用策略「${name}」?将继续使用原有余额和历史数据。`)) return;
setRestoring(sid);
try {
await authFetch(`/api/strategies/${sid}/restore`, { method: "POST" });
await fetchData();
} catch (e) {
console.error(e);
} finally {
setRestoring(null);
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-64">
<div className="text-slate-400 text-sm animate-pulse">...</div>
</div>
);
}
return (
<div className="p-4 max-w-5xl mx-auto">
<div className="flex items-center justify-between mb-5">
<div>
<h1 className="text-lg font-bold text-slate-800"></h1>
<p className="text-slate-500 text-xs mt-0.5"></p>
</div>
<Link
href="/strategy-plaza"
className="text-xs text-blue-600 hover:underline"
>
广
</Link>
</div>
{strategies.length === 0 ? (
<div className="text-center text-slate-400 text-sm py-16"></div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{strategies.map((s) => {
const isProfit = s.net_usdt >= 0;
const is24hProfit = s.pnl_usdt_24h >= 0;
const balancePct = ((s.current_balance / s.initial_balance) * 100).toFixed(1);
return (
<div key={s.strategy_id} className="rounded-xl border border-slate-200 bg-white overflow-hidden opacity-80">
{/* Header */}
<div className="px-4 py-3 border-b border-slate-100 flex items-center justify-between bg-slate-50">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-slate-600 text-sm">{s.display_name}</h3>
<span className="text-[10px] text-slate-400 bg-slate-200 px-1.5 py-0.5 rounded-full"></span>
</div>
<span className="text-[10px] text-slate-400">{s.symbol.replace("USDT", "")}</span>
</div>
{/* PnL */}
<div className="px-4 pt-3 pb-2">
<div className="flex items-end justify-between mb-2">
<div>
<div className="text-[10px] text-slate-400 mb-0.5"></div>
<div className="text-xl font-bold text-slate-700">
{s.current_balance.toLocaleString()}
<span className="text-xs font-normal text-slate-400 ml-1">USDT</span>
</div>
</div>
<div className="text-right">
<div className="text-[10px] text-slate-400 mb-0.5"></div>
<div className={`text-lg font-bold ${isProfit ? "text-emerald-600" : "text-red-500"}`}>
{isProfit ? "+" : ""}{s.net_usdt.toLocaleString()} U
</div>
</div>
</div>
{/* Balance bar */}
<div className="mb-3">
<div className="flex justify-between text-[10px] text-slate-400 mb-1">
<span>{balancePct}%</span>
<span>{s.initial_balance.toLocaleString()} USDT </span>
</div>
<div className="w-full bg-slate-100 rounded-full h-1.5">
<div
className={`h-1.5 rounded-full ${isProfit ? "bg-emerald-400" : "bg-red-300"}`}
style={{ width: `${Math.min(100, Math.max(0, (s.current_balance / s.initial_balance) * 100))}%` }}
/>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-2 mb-3">
<div className="bg-slate-50 rounded-lg p-2 text-center">
<div className="text-[10px] text-slate-400"></div>
<div className="text-sm font-bold text-slate-600">{s.win_rate}%</div>
</div>
<div className="bg-slate-50 rounded-lg p-2 text-center">
<div className="text-[10px] text-slate-400">R</div>
<div className={`text-sm font-bold ${s.net_r >= 0 ? "text-emerald-600" : "text-red-500"}`}>
{s.net_r >= 0 ? "+" : ""}{s.net_r}R
</div>
</div>
<div className="bg-slate-50 rounded-lg p-2 text-center">
<div className="text-[10px] text-slate-400"></div>
<div className="text-sm font-bold text-slate-700">{s.trade_count}</div>
</div>
</div>
</div>
{/* Footer */}
<div className="px-4 py-2.5 border-t border-slate-100 flex items-center justify-between bg-slate-50/60">
<div className="flex items-center gap-1">
{is24hProfit ? <TrendingUp size={12} className="text-emerald-500" /> : <TrendingDown size={12} className="text-red-400" />}
<span className="text-[10px] text-slate-500">
{formatTime(s.deprecated_at)}
</span>
</div>
<button
onClick={() => handleRestore(s.strategy_id, s.display_name)}
disabled={restoring === s.strategy_id}
className="flex items-center gap-1 text-[11px] px-2.5 py-1 rounded-lg bg-blue-50 text-blue-600 hover:bg-blue-100 disabled:opacity-50 transition-colors font-medium"
>
<RotateCcw size={11} />
{restoring === s.strategy_id ? "启用中..." : "重新启用"}
</button>
</div>
</div>
);
})}
</div>
)}
</div>
);
}

View File

@ -4,6 +4,7 @@ import { useEffect, useState, useCallback } from "react";
import { authFetch } from "@/lib/auth"; import { authFetch } from "@/lib/auth";
import { useAuth } from "@/lib/auth"; import { useAuth } from "@/lib/auth";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation";
import { import {
TrendingUp, TrendingUp,
TrendingDown, TrendingDown,
@ -12,11 +13,16 @@ import {
AlertCircle, AlertCircle,
CheckCircle, CheckCircle,
PauseCircle, PauseCircle,
Plus,
Settings,
Trash2,
PlusCircle,
} from "lucide-react"; } from "lucide-react";
interface StrategyCard { interface StrategyCard {
id: string; strategy_id: string;
display_name: string; display_name: string;
symbol: string;
status: "running" | "paused" | "error"; status: "running" | "paused" | "error";
started_at: number; started_at: number;
initial_balance: number; initial_balance: number;
@ -82,128 +88,238 @@ function StatusBadge({ status }: { status: string }) {
); );
} }
function StrategyCardComponent({ s }: { s: StrategyCard }) { // ── AddBalanceModal ────────────────────────────────────────────────────────────
const isProfit = s.net_usdt >= 0; function AddBalanceModal({
const is24hProfit = s.pnl_usdt_24h >= 0; strategy,
const balancePct = ((s.current_balance / s.initial_balance) * 100).toFixed(1); onClose,
onSuccess,
}: {
strategy: StrategyCard;
onClose: () => void;
onSuccess: () => void;
}) {
const [amount, setAmount] = useState(1000);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async () => {
if (amount <= 0) { setError("金额必须大于0"); return; }
setSubmitting(true);
setError("");
try {
const res = await authFetch(`/api/strategies/${strategy.strategy_id}/add-balance`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ amount }),
});
if (!res.ok) throw new Error("追加失败");
onSuccess();
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : "未知错误");
} finally {
setSubmitting(false);
}
};
return ( return (
<Link href={`/strategy-plaza/${s.id}`}> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden hover:border-blue-300 hover:shadow-md transition-all cursor-pointer group"> <div className="bg-white rounded-xl shadow-xl p-5 w-80">
{/* Header */} <h3 className="font-semibold text-slate-800 text-sm mb-1"></h3>
<div className="px-4 py-3 border-b border-slate-100 flex items-center justify-between"> <p className="text-[11px] text-slate-500 mb-3">{strategy.display_name}</p>
<div className="flex items-center gap-2"> <div className="mb-3">
<h3 className="font-semibold text-slate-800 text-sm group-hover:text-blue-700 transition-colors"> <label className="text-xs text-slate-600 mb-1 block"> (USDT)</label>
{s.display_name} <input
</h3> type="number"
<StatusBadge status={s.status} /> value={amount}
</div> min={100}
<span className="text-[10px] text-slate-400 flex items-center gap-1"> step={100}
<Clock size={9} /> onChange={(e) => setAmount(parseFloat(e.target.value) || 0)}
{formatDuration(s.started_at)} className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm"
</span> />
<p className="text-[10px] text-slate-400 mt-1">
{(strategy.initial_balance + amount).toLocaleString()} USDT /
{(strategy.current_balance + amount).toLocaleString()} USDT
</p>
</div> </div>
{error && <p className="text-xs text-red-500 mb-2">{error}</p>}
{/* Main PnL */} <div className="flex gap-2">
<div className="px-4 pt-3 pb-2"> <button onClick={onClose} className="flex-1 py-2 rounded-lg border border-slate-200 text-sm text-slate-600 hover:bg-slate-50"></button>
<div className="flex items-end justify-between mb-2"> <button
<div> onClick={handleSubmit}
<div className="text-[10px] text-slate-400 mb-0.5"></div> disabled={submitting}
<div className="text-xl font-bold text-slate-800"> className="flex-1 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
{s.current_balance.toLocaleString()} >
<span className="text-xs font-normal text-slate-400 ml-1">USDT</span> {submitting ? "追加中..." : "确认追加"}
</div> </button>
</div>
<div className="text-right">
<div className="text-[10px] text-slate-400 mb-0.5"></div>
<div className={`text-lg font-bold ${isProfit ? "text-emerald-600" : "text-red-500"}`}>
{isProfit ? "+" : ""}{s.net_usdt.toLocaleString()} U
</div>
</div>
</div>
{/* Balance Bar */}
<div className="mb-3">
<div className="flex justify-between text-[10px] text-slate-400 mb-1">
<span>{balancePct}%</span>
<span>{s.initial_balance.toLocaleString()} USDT </span>
</div>
<div className="w-full bg-slate-100 rounded-full h-1.5">
<div
className={`h-1.5 rounded-full ${isProfit ? "bg-emerald-400" : "bg-red-400"}`}
style={{ width: `${Math.min(100, Math.max(0, (s.current_balance / s.initial_balance) * 100))}%` }}
/>
</div>
</div>
{/* Stats Row */}
<div className="grid grid-cols-3 gap-2 mb-3">
<div className="bg-slate-50 rounded-lg p-2 text-center">
<div className="text-[10px] text-slate-400"></div>
<div className={`text-sm font-bold ${s.win_rate >= 50 ? "text-emerald-600" : s.win_rate >= 45 ? "text-amber-600" : "text-red-500"}`}>
{s.win_rate}%
</div>
</div>
<div className="bg-slate-50 rounded-lg p-2 text-center">
<div className="text-[10px] text-slate-400">R</div>
<div className={`text-sm font-bold ${s.net_r >= 0 ? "text-emerald-600" : "text-red-500"}`}>
{s.net_r >= 0 ? "+" : ""}{s.net_r}R
</div>
</div>
<div className="bg-slate-50 rounded-lg p-2 text-center">
<div className="text-[10px] text-slate-400"></div>
<div className="text-sm font-bold text-slate-700">{s.trade_count}</div>
</div>
</div>
{/* Avg win/loss */}
<div className="flex gap-2 mb-3">
<div className="flex-1 bg-emerald-50 rounded-lg px-2.5 py-1.5">
<span className="text-[10px] text-emerald-600"></span>
<span className="float-right text-[10px] font-bold text-emerald-600">+{s.avg_win_r}R</span>
</div>
<div className="flex-1 bg-red-50 rounded-lg px-2.5 py-1.5">
<span className="text-[10px] text-red-500"></span>
<span className="float-right text-[10px] font-bold text-red-500">{s.avg_loss_r}R</span>
</div>
</div>
</div>
{/* Footer */}
<div className="px-4 py-2.5 border-t border-slate-100 flex items-center justify-between bg-slate-50/60">
<div className="flex items-center gap-1">
{is24hProfit ? (
<TrendingUp size={12} className="text-emerald-500" />
) : (
<TrendingDown size={12} className="text-red-500" />
)}
<span className={`text-[10px] font-medium ${is24hProfit ? "text-emerald-600" : "text-red-500"}`}>
24h {is24hProfit ? "+" : ""}{s.pnl_usdt_24h.toLocaleString()} U
</span>
</div>
<div className="flex items-center gap-1 text-[10px] text-slate-400">
<Activity size={9} />
{s.open_positions > 0 ? (
<span className="text-amber-600 font-medium">{s.open_positions}</span>
) : (
<span>: {formatTime(s.last_trade_at)}</span>
)}
</div>
</div> </div>
</div> </div>
</Link> </div>
); );
} }
// ── StrategyCardComponent ──────────────────────────────────────────────────────
function StrategyCardComponent({
s,
onDeprecate,
onAddBalance,
}: {
s: StrategyCard;
onDeprecate: (s: StrategyCard) => void;
onAddBalance: (s: StrategyCard) => void;
}) {
const isProfit = s.net_usdt >= 0;
const is24hProfit = s.pnl_usdt_24h >= 0;
const balancePct = ((s.current_balance / s.initial_balance) * 100).toFixed(1);
const symbolShort = s.symbol?.replace("USDT", "") || "";
return (
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden hover:border-blue-300 hover:shadow-md transition-all group">
{/* Header */}
<div className="px-4 py-3 border-b border-slate-100 flex items-center justify-between">
<div className="flex items-center gap-2">
<Link href={`/strategy-plaza/${s.strategy_id}`}>
<h3 className="font-semibold text-slate-800 text-sm group-hover:text-blue-700 transition-colors cursor-pointer hover:underline">
{s.display_name}
</h3>
</Link>
<StatusBadge status={s.status} />
{symbolShort && (
<span className="text-[10px] text-slate-400 bg-slate-100 px-1.5 py-0.5 rounded-full">{symbolShort}</span>
)}
</div>
<span className="text-[10px] text-slate-400 flex items-center gap-1">
<Clock size={9} />
{formatDuration(s.started_at)}
</span>
</div>
{/* Main PnL */}
<div className="px-4 pt-3 pb-2">
<div className="flex items-end justify-between mb-2">
<div>
<div className="text-[10px] text-slate-400 mb-0.5"></div>
<div className="text-xl font-bold text-slate-800">
{s.current_balance.toLocaleString()}
<span className="text-xs font-normal text-slate-400 ml-1">USDT</span>
</div>
</div>
<div className="text-right">
<div className="text-[10px] text-slate-400 mb-0.5"></div>
<div className={`text-lg font-bold ${isProfit ? "text-emerald-600" : "text-red-500"}`}>
{isProfit ? "+" : ""}{s.net_usdt.toLocaleString()} U
</div>
</div>
</div>
{/* Balance Bar */}
<div className="mb-3">
<div className="flex justify-between text-[10px] text-slate-400 mb-1">
<span>{balancePct}%</span>
<span>{s.initial_balance.toLocaleString()} USDT </span>
</div>
<div className="w-full bg-slate-100 rounded-full h-1.5">
<div
className={`h-1.5 rounded-full ${isProfit ? "bg-emerald-400" : "bg-red-400"}`}
style={{ width: `${Math.min(100, Math.max(0, (s.current_balance / s.initial_balance) * 100))}%` }}
/>
</div>
</div>
{/* Stats Row */}
<div className="grid grid-cols-3 gap-2 mb-3">
<div className="bg-slate-50 rounded-lg p-2 text-center">
<div className="text-[10px] text-slate-400"></div>
<div className={`text-sm font-bold ${s.win_rate >= 50 ? "text-emerald-600" : s.win_rate >= 45 ? "text-amber-600" : "text-red-500"}`}>
{s.win_rate}%
</div>
</div>
<div className="bg-slate-50 rounded-lg p-2 text-center">
<div className="text-[10px] text-slate-400">R</div>
<div className={`text-sm font-bold ${s.net_r >= 0 ? "text-emerald-600" : "text-red-500"}`}>
{s.net_r >= 0 ? "+" : ""}{s.net_r}R
</div>
</div>
<div className="bg-slate-50 rounded-lg p-2 text-center">
<div className="text-[10px] text-slate-400"></div>
<div className="text-sm font-bold text-slate-700">{s.trade_count}</div>
</div>
</div>
{/* Avg win/loss */}
<div className="flex gap-2 mb-3">
<div className="flex-1 bg-emerald-50 rounded-lg px-2.5 py-1.5">
<span className="text-[10px] text-emerald-600"></span>
<span className="float-right text-[10px] font-bold text-emerald-600">+{s.avg_win_r}R</span>
</div>
<div className="flex-1 bg-red-50 rounded-lg px-2.5 py-1.5">
<span className="text-[10px] text-red-500"></span>
<span className="float-right text-[10px] font-bold text-red-500">{s.avg_loss_r}R</span>
</div>
</div>
</div>
{/* Footer */}
<div className="px-4 py-2.5 border-t border-slate-100 flex items-center justify-between bg-slate-50/60">
<div className="flex items-center gap-1">
{is24hProfit ? (
<TrendingUp size={12} className="text-emerald-500" />
) : (
<TrendingDown size={12} className="text-red-500" />
)}
<span className={`text-[10px] font-medium ${is24hProfit ? "text-emerald-600" : "text-red-500"}`}>
24h {is24hProfit ? "+" : ""}{s.pnl_usdt_24h.toLocaleString()} U
</span>
</div>
<div className="flex items-center gap-1 text-[10px] text-slate-400">
<Activity size={9} />
{s.open_positions > 0 ? (
<span className="text-amber-600 font-medium">{s.open_positions}</span>
) : (
<span>: {formatTime(s.last_trade_at)}</span>
)}
</div>
</div>
{/* Action Buttons */}
<div className="px-4 py-2.5 border-t border-slate-100 flex gap-2">
<Link
href={`/strategy-plaza/${s.strategy_id}/edit`}
className="flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-lg border border-slate-200 text-[11px] text-slate-600 hover:bg-slate-50 transition-colors"
>
<Settings size={11} />
</Link>
<button
onClick={() => onAddBalance(s)}
className="flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-lg border border-blue-200 text-[11px] text-blue-600 hover:bg-blue-50 transition-colors"
>
<PlusCircle size={11} />
</button>
<button
onClick={() => onDeprecate(s)}
className="flex items-center justify-center gap-1 px-2.5 py-1.5 rounded-lg border border-red-200 text-[11px] text-red-500 hover:bg-red-50 transition-colors"
>
<Trash2 size={11} />
</button>
</div>
</div>
);
}
// ── Main Page ─────────────────────────────────────────────────────────────────
export default function StrategyPlazaPage() { export default function StrategyPlazaPage() {
useAuth(); useAuth();
const router = useRouter();
const [strategies, setStrategies] = useState<StrategyCard[]>([]); const [strategies, setStrategies] = useState<StrategyCard[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null); const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const [addBalanceTarget, setAddBalanceTarget] = useState<StrategyCard | null>(null);
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
try { try {
const res = await authFetch("/api/strategy-plaza"); const res = await authFetch("/api/strategies");
const data = await res.json(); const data = await res.json();
setStrategies(data.strategies || []); setStrategies(data.strategies || []);
setLastUpdated(new Date()); setLastUpdated(new Date());
@ -220,6 +336,20 @@ export default function StrategyPlazaPage() {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [fetchData]); }, [fetchData]);
const handleDeprecate = async (s: StrategyCard) => {
if (!confirm(`确认废弃策略「${s.display_name}」?\n\n废弃后策略停止运行数据永久保留可在废弃列表中重新启用。`)) return;
try {
await authFetch(`/api/strategies/${s.strategy_id}/deprecate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ confirm: true }),
});
await fetchData();
} catch (e) {
console.error(e);
}
};
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center min-h-64"> <div className="flex items-center justify-center min-h-64">
@ -234,25 +364,57 @@ export default function StrategyPlazaPage() {
<div className="flex items-center justify-between mb-5"> <div className="flex items-center justify-between mb-5">
<div> <div>
<h1 className="text-lg font-bold text-slate-800">广</h1> <h1 className="text-lg font-bold text-slate-800">广</h1>
<p className="text-slate-500 text-xs mt-0.5"></p> <p className="text-slate-500 text-xs mt-0.5"></p>
</div>
<div className="flex items-center gap-3">
{lastUpdated && (
<div className="text-[10px] text-slate-400 flex items-center gap-1">
<span className="inline-block w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
{lastUpdated.toLocaleTimeString("zh-CN", { timeZone: "Asia/Shanghai", hour12: false })}
</div>
)}
<button
onClick={() => router.push("/strategy-plaza/create")}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-blue-600 text-white text-xs font-medium hover:bg-blue-700 transition-colors"
>
<Plus size={13} />
</button>
</div> </div>
{lastUpdated && (
<div className="text-[10px] text-slate-400 flex items-center gap-1">
<span className="inline-block w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
{lastUpdated.toLocaleTimeString("zh-CN", { timeZone: "Asia/Shanghai", hour12: false })}
</div>
)}
</div> </div>
{/* Strategy Cards */} {/* Strategy Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{strategies.map((s) => ( {strategies.map((s) => (
<StrategyCardComponent key={s.id} s={s} /> <StrategyCardComponent
key={s.strategy_id}
s={s}
onDeprecate={handleDeprecate}
onAddBalance={setAddBalanceTarget}
/>
))} ))}
</div> </div>
{strategies.length === 0 && ( {strategies.length === 0 && (
<div className="text-center text-slate-400 text-sm py-16"></div> <div className="text-center text-slate-400 text-sm py-16">
<p className="mb-3"></p>
<button
onClick={() => router.push("/strategy-plaza/create")}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl bg-blue-600 text-white text-sm hover:bg-blue-700"
>
<Plus size={14} />
</button>
</div>
)}
{/* Add Balance Modal */}
{addBalanceTarget && (
<AddBalanceModal
strategy={addBalanceTarget}
onClose={() => setAddBalanceTarget(null)}
onSuccess={fetchData}
/>
)} )}
</div> </div>
); );

View File

@ -7,7 +7,7 @@ import { useAuth } from "@/lib/auth";
import { import {
LayoutDashboard, Info, LayoutDashboard, Info,
Menu, X, Zap, LogIn, UserPlus, Menu, X, Zap, LogIn, UserPlus,
ChevronLeft, ChevronRight, Activity, LogOut, Monitor, LineChart, Bolt ChevronLeft, ChevronRight, Activity, LogOut, Monitor, LineChart, Bolt, Archive
} from "lucide-react"; } from "lucide-react";
const navItems = [ const navItems = [
@ -15,6 +15,7 @@ const navItems = [
{ href: "/trades", label: "成交流", icon: Activity }, { href: "/trades", label: "成交流", icon: Activity },
{ href: "/live", label: "⚡ 实盘交易", icon: Bolt, section: "── 实盘 ──" }, { href: "/live", label: "⚡ 实盘交易", icon: Bolt, section: "── 实盘 ──" },
{ href: "/strategy-plaza", label: "策略广场", icon: Zap, section: "── 策略 ──" }, { href: "/strategy-plaza", label: "策略广场", icon: Zap, section: "── 策略 ──" },
{ href: "/strategy-plaza/deprecated", label: "废弃策略", icon: Archive },
{ href: "/server", label: "服务器", icon: Monitor }, { href: "/server", label: "服务器", icon: Monitor },
{ href: "/about", label: "说明", icon: Info }, { href: "/about", label: "说明", icon: Info },
]; ];

View File

@ -0,0 +1,541 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { authFetch } from "@/lib/auth";
import { useAuth } from "@/lib/auth";
import Link from "next/link";
import {
ComposedChart, Area, Line, XAxis, YAxis, Tooltip, ResponsiveContainer,
ReferenceLine, CartesianGrid, Legend
} from "recharts";
type Symbol = "BTC" | "ETH" | "XRP" | "SOL";
interface IndicatorRow {
ts: number;
cvd_fast: number;
cvd_mid: number;
cvd_day: number;
atr_5m: number;
vwap_30m: number;
price: number;
score: number;
signal: string | null;
}
interface LatestIndicator {
ts: number;
cvd_fast: number;
cvd_mid: number;
cvd_day: number;
cvd_fast_slope: number;
atr_5m: number;
atr_percentile: number;
vwap_30m: number;
price: number;
p95_qty: number;
p99_qty: number;
score: number;
display_score?: number;
gate_passed?: boolean;
signal: string | null;
tier?: "light" | "standard" | "heavy" | null;
factors?: {
track?: string;
direction?: { score?: number; max?: number; cvd_resonance?: number; p99_flow?: number; accel_bonus?: number };
crowding?: { score?: number; max?: number; lsr_contrarian?: number; top_trader_position?: number };
environment?: { score?: number; max?: number };
auxiliary?: { score?: number; max?: number; coinbase_premium?: number };
gate_passed?: boolean;
block_reason?: string;
gate_block?: string;
obi_raw?: number;
spot_perp_div?: number;
whale_cvd_ratio?: number;
atr_pct_price?: number;
alt_score_ref?: number;
} | null;
}
const WINDOWS = [
{ label: "1h", value: 60 },
{ label: "4h", value: 240 },
{ label: "12h", value: 720 },
{ label: "24h", value: 1440 },
];
function bjtStr(ms: number) {
const d = new Date(ms + 8 * 3600 * 1000);
return `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}`;
}
function bjtFull(ms: number) {
const d = new Date(ms + 8 * 3600 * 1000);
return `${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")} ${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}:${String(d.getUTCSeconds()).padStart(2, "0")}`;
}
function fmt(v: number, decimals = 1): string {
if (Math.abs(v) >= 1000000) return `${(v / 1000000).toFixed(1)}M`;
if (Math.abs(v) >= 1000) return `${(v / 1000).toFixed(1)}K`;
return v.toFixed(decimals);
}
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 (
<div className="flex items-center gap-2">
<span className="text-[10px] text-slate-500 w-6 shrink-0">{label}</span>
<div className="flex-1 h-1.5 rounded-full bg-slate-100 overflow-hidden">
<div className={`h-full ${colorClass}`} style={{ width: `${ratio}%` }} />
</div>
<span className="text-[10px] font-mono text-slate-600 w-8 text-right">{score}/{max}</span>
</div>
);
}
const ALT_GATE_THRESHOLDS: Record<string, { vol: string; obi: string; spd: string; whale: string }> = {
ETH: { vol: "0.3%", obi: "0.35", spd: "0.5%", whale: "$50k" },
XRP: { vol: "0.25%", obi: "0.40", spd: "0.6%", whale: "$30k" },
SOL: { vol: "0.4%", obi: "0.45", spd: "0.8%", whale: "$20k" },
};
function ALTGateCard({ symbol, factors }: { symbol: Symbol; factors: LatestIndicator["factors"] }) {
if (!factors || symbol === "BTC") return null;
const thresholds = ALT_GATE_THRESHOLDS[symbol] ?? ALT_GATE_THRESHOLDS["ETH"];
const passed = factors.gate_passed ?? true;
const blockReason = factors.gate_block;
return (
<div className={`rounded-xl border px-3 py-2 mt-2 ${passed ? "border-purple-200 bg-purple-50" : "border-red-200 bg-red-50"}`}>
<div className="flex items-center justify-between mb-1.5">
<p className="text-[10px] font-semibold text-purple-800">🔒 {symbol} Gate-Control</p>
<span className={`text-[10px] font-bold px-2 py-0.5 rounded ${passed ? "bg-emerald-100 text-emerald-700" : "bg-red-100 text-red-600"}`}>
{passed ? "✅ Gate通过" : "❌ 否决"}
</span>
</div>
<div className="grid grid-cols-4 gap-1.5">
<div className="bg-white rounded px-2 py-1">
<p className="text-[10px] text-slate-400"></p>
<p className="text-xs font-mono text-slate-800">{((factors.atr_pct_price ?? 0) * 100).toFixed(3)}%</p>
<p className="text-[9px] text-slate-400"> {thresholds.vol}</p>
</div>
<div className="bg-white rounded px-2 py-1">
<p className="text-[10px] text-slate-400">OBI</p>
<p className={`text-xs font-mono ${(factors.obi_raw ?? 0) >= 0 ? "text-emerald-600" : "text-red-500"}`}>
{((factors.obi_raw ?? 0) * 100).toFixed(2)}%
</p>
<p className="text-[9px] text-slate-400">±{thresholds.obi}</p>
</div>
<div className="bg-white rounded px-2 py-1">
<p className="text-[10px] text-slate-400"></p>
<p className={`text-xs font-mono ${(factors.spot_perp_div ?? 0) >= 0 ? "text-emerald-600" : "text-red-500"}`}>
{((factors.spot_perp_div ?? 0) * 10000).toFixed(2)}bps
</p>
<p className="text-[9px] text-slate-400">±{thresholds.spd}</p>
</div>
<div className="bg-white rounded px-2 py-1">
<p className="text-[10px] text-slate-400"></p>
<p className="text-xs font-mono text-slate-800">{thresholds.whale}</p>
<p className="text-[9px] text-slate-400"></p>
</div>
</div>
{blockReason && (
<p className="text-[10px] text-red-600 mt-1.5 bg-red-50 rounded px-2 py-1">
: <span className="font-mono">{blockReason}</span>
</p>
)}
</div>
);
}
function BTCGateCard({ factors }: { factors: LatestIndicator["factors"] }) {
if (!factors) return null;
return (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 mt-2">
<div className="flex items-center justify-between mb-1.5">
<p className="text-[10px] font-semibold text-amber-800"> BTC Gate-Control</p>
<span className={`text-[10px] font-bold px-2 py-0.5 rounded ${factors.gate_passed ? "bg-emerald-100 text-emerald-700" : "bg-red-100 text-red-600"}`}>
{factors.gate_passed ? "✅ Gate通过" : "❌ 否决"}
</span>
</div>
<div className="grid grid-cols-4 gap-1.5">
<div className="bg-white rounded px-2 py-1">
<p className="text-[10px] text-slate-400"></p>
<p className="text-xs font-mono text-slate-800">{((factors.atr_pct_price ?? 0) * 100).toFixed(3)}%</p>
<p className="text-[9px] text-slate-400"> 0.2%</p>
</div>
<div className="bg-white rounded px-2 py-1">
<p className="text-[10px] text-slate-400">OBI</p>
<p className={`text-xs font-mono ${(factors.obi_raw ?? 0) >= 0 ? "text-emerald-600" : "text-red-500"}`}>
{((factors.obi_raw ?? 0) * 100).toFixed(2)}%
</p>
<p className="text-[9px] text-slate-400"></p>
</div>
<div className="bg-white rounded px-2 py-1">
<p className="text-[10px] text-slate-400"></p>
<p className={`text-xs font-mono ${(factors.spot_perp_div ?? 0) >= 0 ? "text-emerald-600" : "text-red-500"}`}>
{((factors.spot_perp_div ?? 0) * 10000).toFixed(2)}bps
</p>
<p className="text-[9px] text-slate-400">spot-perp</p>
</div>
<div className="bg-white rounded px-2 py-1">
<p className="text-[10px] text-slate-400">CVD</p>
<p className={`text-xs font-mono ${(factors.whale_cvd_ratio ?? 0) >= 0 ? "text-emerald-600" : "text-red-500"}`}>
{((factors.whale_cvd_ratio ?? 0) * 100).toFixed(2)}%
</p>
<p className="text-[9px] text-slate-400">&gt;$100k</p>
</div>
</div>
{factors.block_reason && (
<p className="text-[10px] text-red-600 mt-1.5 bg-red-50 rounded px-2 py-1">
: <span className="font-mono">{factors.block_reason}</span>
</p>
)}
</div>
);
}
function IndicatorCards({ symbol, strategy }: { symbol: Symbol; strategy: string }) {
const [data, setData] = useState<LatestIndicator | null>(null);
useEffect(() => {
const fetch = async () => {
try {
const res = await authFetch(`/api/signals/latest?strategy=${strategy}`);
if (!res.ok) return;
const json = await res.json();
setData(json[symbol] || null);
} catch {}
};
fetch();
const iv = setInterval(fetch, 5000);
return () => clearInterval(iv);
}, [symbol, strategy]);
if (!data) return <div className="text-center text-slate-400 text-sm py-4">...</div>;
const isBTC = symbol === "BTC";
const priceVsVwap = data.price > data.vwap_30m ? "上方" : "下方";
return (
<div className="space-y-3">
<div className="grid grid-cols-3 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">CVD_fast (30m)</p>
<p className={`font-mono font-bold text-sm ${data.cvd_fast >= 0 ? "text-emerald-600" : "text-red-500"}`}>
{fmt(data.cvd_fast)}
</p>
<p className="text-[10px] text-slate-400">
: <span className={data.cvd_fast_slope >= 0 ? "text-emerald-600" : "text-red-500"}>
{data.cvd_fast_slope >= 0 ? "↑" : "↓"}{fmt(Math.abs(data.cvd_fast_slope))}
</span>
</p>
</div>
<div className="bg-white rounded-lg border border-slate-200 px-2.5 py-2">
<p className="text-[10px] text-slate-400">CVD_mid (4h)</p>
<p className={`font-mono font-bold text-sm ${data.cvd_mid >= 0 ? "text-emerald-600" : "text-red-500"}`}>
{fmt(data.cvd_mid)}
</p>
<p className="text-[10px] text-slate-400">{data.cvd_mid > 0 ? "多" : "空"}</p>
</div>
<div className="bg-white rounded-lg border border-slate-200 px-2.5 py-2">
<p className="text-[10px] text-slate-400">CVD共振</p>
<p className={`font-mono font-bold text-sm ${data.cvd_fast >= 0 && data.cvd_mid >= 0 ? "text-emerald-600" : data.cvd_fast < 0 && data.cvd_mid < 0 ? "text-red-500" : "text-slate-400"}`}>
{data.cvd_fast >= 0 && data.cvd_mid >= 0 ? "✅ 多头共振" : data.cvd_fast < 0 && data.cvd_mid < 0 ? "✅ 空头共振" : "⚠️ 分歧"}
</p>
<p className="text-[10px] text-slate-400">V5.3</p>
</div>
</div>
<div className="grid grid-cols-4 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">ATR</p>
<p className="font-mono font-semibold text-sm text-slate-800">${fmt(data.atr_5m, 2)}</p>
<p className="text-[10px]">
<span className={data.atr_percentile > 60 ? "text-amber-600 font-semibold" : "text-slate-400"}>
{data.atr_percentile.toFixed(0)}%{data.atr_percentile > 60 ? "🔥" : ""}
</span>
</p>
</div>
<div className="bg-white rounded-lg border border-slate-200 px-2.5 py-2">
<p className="text-[10px] text-slate-400">VWAP</p>
<p className="font-mono font-semibold text-sm text-slate-800">${data.vwap_30m.toLocaleString("en-US", { maximumFractionDigits: 1 })}</p>
<p className="text-[10px]">
<span className={data.price > data.vwap_30m ? "text-emerald-600" : "text-red-500"}>{priceVsVwap}</span>
</p>
</div>
<div className="bg-white rounded-lg border border-slate-200 px-2.5 py-2">
<p className="text-[10px] text-slate-400">P95</p>
<p className="font-mono font-semibold text-sm text-slate-800">{data.p95_qty.toFixed(4)}</p>
<p className="text-[10px] text-slate-400"></p>
</div>
<div className="bg-white rounded-lg border border-slate-200 px-2.5 py-2">
<p className="text-[10px] text-slate-400">P99</p>
<p className="font-mono font-semibold text-sm text-amber-600">{data.p99_qty.toFixed(4)}</p>
<p className="text-[10px] text-slate-400"></p>
</div>
</div>
<div className={`rounded-xl border px-3 py-2.5 ${
data.signal === "LONG" ? "border-emerald-300 bg-emerald-50" :
data.signal === "SHORT" ? "border-red-300 bg-red-50" :
"border-slate-200 bg-slate-50"
}`}>
<div className="flex items-center justify-between">
<div>
<p className="text-[10px] text-slate-500">
{isBTC ? "BTC Gate-Control" : "ALT 四层评分"}
{" · "}{"v53"}
</p>
<p className={`font-bold text-base ${
data.signal === "LONG" ? "text-emerald-700" :
data.signal === "SHORT" ? "text-red-600" :
"text-slate-400"
}`}>
{data.signal === "LONG" ? "🟢 做多" : data.signal === "SHORT" ? "🔴 做空" : "⚪ 无信号"}
</p>
</div>
<div className="text-right">
{isBTC ? (
<>
<p className="font-mono font-bold text-lg text-slate-800">
{data.display_score ?? data.factors?.alt_score_ref ?? data.score}/100
<span className="text-[10px] font-normal text-slate-400 ml-1"></span>
</p>
<p className="text-[10px] text-slate-500">
{(data.gate_passed ?? data.factors?.gate_passed) ? (data.tier === "standard" ? "标准" : "不开仓") : "Gate否决"}
</p>
</>
) : (
<>
<p className="font-mono font-bold text-lg text-slate-800">{data.score}/100</p>
<p className="text-[10px] text-slate-500">{data.tier === "heavy" ? "加仓" : data.tier === "standard" ? "标准" : "不开仓"}</p>
</>
)}
</div>
</div>
<div className="mt-2 space-y-1">
<LayerScore label="方向" score={data.factors?.direction?.score ?? 0} max={55} colorClass="bg-blue-600" />
<LayerScore label="拥挤" score={data.factors?.crowding?.score ?? 0} max={25} colorClass="bg-violet-600" />
<LayerScore label="环境" score={data.factors?.environment?.score ?? 0} max={15} colorClass="bg-emerald-600" />
<LayerScore label="辅助" score={data.factors?.auxiliary?.score ?? 0} max={5} colorClass="bg-slate-500" />
</div>
</div>
{!isBTC && data.factors && <ALTGateCard symbol={symbol} factors={data.factors} />}
{isBTC && data.factors && <BTCGateCard factors={data.factors} />}
</div>
);
}
interface SignalRecord {
ts: number;
score: number;
signal: string;
}
function SignalHistory({ symbol, strategy }: { symbol: Symbol; strategy: string }) {
const [data, setData] = useState<SignalRecord[]>([]);
useEffect(() => {
const fetchData = async () => {
try {
const res = await authFetch(`/api/signals/signal-history?symbol=${symbol}&limit=20&strategy=${strategy}`);
if (!res.ok) return;
const json = await res.json();
setData(json.data || []);
} catch {}
};
fetchData();
const iv = setInterval(fetchData, 15000);
return () => clearInterval(iv);
}, [symbol, strategy]);
if (data.length === 0) return null;
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"> ({strategy})</h3>
</div>
<div className="divide-y divide-slate-100 max-h-48 overflow-y-auto">
{data.map((s, i) => (
<div key={i} className="px-3 py-1.5 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={`text-xs font-bold ${s.signal === "LONG" ? "text-emerald-600" : "text-red-500"}`}>
{s.signal === "LONG" ? "🟢 LONG" : "🔴 SHORT"}
</span>
<span className="text-[10px] text-slate-400">{bjtFull(s.ts)}</span>
</div>
<div className="flex items-center gap-1.5">
<span className="font-mono text-xs text-slate-700">{s.score}</span>
<span className={`text-[10px] px-1 py-0.5 rounded ${
s.score >= 85 ? "bg-red-100 text-red-700" :
s.score >= 75 ? "bg-blue-100 text-blue-700" :
"bg-slate-100 text-slate-600"
}`}>
{s.score >= 85 ? "加仓" : s.score >= 75 ? "标准" : "不开仓"}
</span>
</div>
</div>
))}
</div>
</div>
);
}
function CVDChart({ symbol, minutes, strategy }: { symbol: Symbol; minutes: number; strategy: string }) {
const [data, setData] = useState<IndicatorRow[]>([]);
const [loading, setLoading] = useState(true);
const fetchData = useCallback(async (silent = false) => {
try {
const res = await authFetch(`/api/signals/indicators?symbol=${symbol}&minutes=${minutes}&strategy=${strategy}`);
if (!res.ok) return;
const json = await res.json();
setData(json.data || []);
if (!silent) setLoading(false);
} catch {}
}, [symbol, minutes, strategy]);
useEffect(() => {
setLoading(true);
fetchData();
const iv = setInterval(() => fetchData(true), 30000);
return () => clearInterval(iv);
}, [fetchData]);
const chartData = data.map(d => ({
time: bjtStr(d.ts),
fast: parseFloat(d.cvd_fast?.toFixed(2) || "0"),
mid: parseFloat(d.cvd_mid?.toFixed(2) || "0"),
price: d.price,
}));
const prices = chartData.map(d => d.price).filter(v => v > 0);
const pMin = prices.length ? Math.min(...prices) : 0;
const pMax = prices.length ? Math.max(...prices) : 0;
const pPad = (pMax - pMin) * 0.3 || pMax * 0.001;
if (loading) return <div className="flex items-center justify-center h-48 text-slate-400 text-sm">...</div>;
if (data.length === 0) return <div className="flex items-center justify-center h-48 text-slate-400 text-sm"> V5.3 signal-engine </div>;
return (
<ResponsiveContainer width="100%" height={220}>
<ComposedChart data={chartData} margin={{ top: 4, right: 60, bottom: 0, left: 8 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
<XAxis dataKey="time" tick={{ fill: "#94a3b8", fontSize: 10 }} tickLine={false} interval="preserveStartEnd" />
<YAxis yAxisId="cvd" tick={{ fill: "#94a3b8", fontSize: 10 }} tickLine={false} axisLine={false} width={55} />
<YAxis yAxisId="price" orientation="right" tick={{ fill: "#f59e0b", fontSize: 10 }} tickLine={false} axisLine={false} width={65}
domain={[Math.floor(pMin - pPad), Math.ceil(pMax + pPad)]}
tickFormatter={(v: number) => v >= 1000 ? `$${(v / 1000).toFixed(1)}k` : `$${v.toFixed(0)}`}
/>
<Tooltip
formatter={(v: any, name: any) => {
if (name === "price") return [`$${Number(v).toLocaleString()}`, "币价"];
if (name === "fast") return [fmt(Number(v)), "CVD_fast(30m)"];
return [fmt(Number(v)), "CVD_mid(4h)"];
}}
contentStyle={{ background: "#fff", border: "1px solid #e2e8f0", borderRadius: 8, fontSize: 11 }}
/>
<Legend wrapperStyle={{ fontSize: 11 }} />
<ReferenceLine yAxisId="cvd" y={0} stroke="#94a3b8" strokeDasharray="4 2" />
<Area yAxisId="cvd" type="monotone" dataKey="fast" name="fast" stroke="#2563eb" fill="#eff6ff" strokeWidth={1.5} dot={false} connectNulls />
<Line yAxisId="cvd" type="monotone" dataKey="mid" name="mid" stroke="#7c3aed" strokeWidth={1.5} dot={false} connectNulls strokeDasharray="6 3" />
<Line yAxisId="price" type="monotone" dataKey="price" name="price" stroke="#f59e0b" strokeWidth={1.5} dot={false} connectNulls strokeDasharray="4 2" />
</ComposedChart>
</ResponsiveContainer>
);
}
export default function SignalsView({ strategy }: { strategy: string }) {
const { isLoggedIn, loading } = useAuth();
const [symbol, setSymbol] = useState<Symbol>("ETH");
const [minutes, setMinutes] = useState(240);
if (loading) return <div className="flex items-center justify-center h-64 text-slate-400">...</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>
<div className="flex gap-2">
<Link href="/login" className="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm"></Link>
<Link href="/register" className="border border-slate-300 text-slate-600 px-4 py-2 rounded-lg text-sm"></Link>
</div>
</div>
);
return (
<div className="space-y-3">
<div className="flex items-center justify-between flex-wrap gap-2">
<div>
<h1 className="text-lg font-bold text-slate-900"> V5.3</h1>
<p className="text-slate-500 text-[10px]">
55/25/15/5 · ALT双轨 + BTC gate-control ·
{symbol === "BTC" ? " 🔵 BTC轨gate-control" : " 🟣 ALT轨ETH/XRP/SOL"}
</p>
</div>
<div className="flex gap-1">
{(["BTC", "ETH", "XRP", "SOL"] as Symbol[]).map(s => (
<button key={s} onClick={() => setSymbol(s)}
className={`px-3 py-1 rounded-lg border text-xs font-medium transition-colors ${symbol === s ? (s === "BTC" ? "bg-amber-500 text-white border-amber-500" : "bg-blue-600 text-white border-blue-600") : "border-slate-200 text-slate-600 hover:border-blue-400"}`}>
{s}{s === "BTC" ? " 🔵" : ""}
</button>
))}
</div>
</div>
<IndicatorCards symbol={symbol} strategy={strategy} />
<SignalHistory symbol={symbol} strategy={strategy} />
<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 flex items-center justify-between flex-wrap gap-1">
<div>
<h3 className="font-semibold text-slate-800 text-xs">CVD三轨 + </h3>
<p className="text-[10px] text-slate-400">=fast(30m) · =mid(4h) · =</p>
</div>
<div className="flex gap-1">
{WINDOWS.map(w => (
<button key={w.value} onClick={() => setMinutes(w.value)}
className={`px-2 py-1 rounded border text-xs transition-colors ${minutes === w.value ? "bg-slate-800 text-white border-slate-800" : "border-slate-200 text-slate-500 hover:border-slate-400"}`}>
{w.label}
</button>
))}
</div>
</div>
<div className="px-3 py-2">
<CVDChart symbol={symbol} minutes={minutes} strategy={strategy} />
</div>
</div>
<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.3 </h3>
</div>
<div className="px-3 py-2 space-y-2 text-[11px] text-slate-600">
<div className="p-2 bg-purple-50 rounded-lg border border-purple-100">
<span className="font-bold text-purple-800">🟣 ALT轨ETH/XRP/SOL 线</span>
<div className="mt-1 space-y-1">
<p><span className="font-semibold">1 55</span> CVD共振30分fast+mid同向+ P99大单对齐20分 + 5CVD双重计分问题</p>
<p><span className="font-semibold">2 25</span> LSR反向拥挤15分=+ 10</p>
<p><span className="font-semibold">3 15</span> OI变化率vs撤离</p>
<p><span className="font-semibold">4 5</span> Coinbase Premium</p>
</div>
</div>
<div className="p-2 bg-amber-50 rounded-lg border border-amber-100">
<span className="font-bold text-amber-800">🔵 BTC轨 Gate-Control逻辑</span>
<div className="mt-1 space-y-1">
<p><span className="font-semibold"></span>ATR/Price 0.2%</p>
<p><span className="font-semibold">OBI否决</span>簿100ms</p>
<p><span className="font-semibold"></span>spot与perp价差超阈值时否决1s</p>
<p><span className="font-semibold">CVD</span>&gt;$100k成交额净CVD15</p>
</div>
</div>
<div className="pt-1 border-t border-slate-100">
<span className="text-blue-600 font-medium"></span>&lt;75 · 75-84 · 85 · 10
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,534 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { authFetch } from "@/lib/auth";
import { useAuth } from "@/lib/auth";
import Link from "next/link";
import { ArrowLeft, Save, Info } from "lucide-react";
// ─── Types ────────────────────────────────────────────────────────────────────
export interface StrategyFormData {
display_name: string;
symbol: string;
direction: string;
initial_balance: number;
cvd_fast_window: string;
cvd_slow_window: string;
weight_direction: number;
weight_env: number;
weight_aux: number;
weight_momentum: number;
entry_score: number;
// 门1 波动率
gate_vol_enabled: boolean;
vol_atr_pct_min: number;
// 门2 CVD共振
gate_cvd_enabled: boolean;
// 门3 鲸鱼否决
gate_whale_enabled: boolean;
whale_usd_threshold: number;
whale_flow_pct: number;
// 门4 OBI否决
gate_obi_enabled: boolean;
obi_threshold: number;
// 门5 期现背离
gate_spot_perp_enabled: boolean;
spot_perp_threshold: number;
sl_atr_multiplier: number;
tp1_ratio: number;
tp2_ratio: number;
timeout_minutes: number;
flip_threshold: number;
description: string;
}
export const DEFAULT_FORM: StrategyFormData = {
display_name: "",
symbol: "BTCUSDT",
direction: "both",
initial_balance: 10000,
cvd_fast_window: "30m",
cvd_slow_window: "4h",
weight_direction: 55,
weight_env: 25,
weight_aux: 15,
weight_momentum: 5,
entry_score: 75,
gate_vol_enabled: true,
vol_atr_pct_min: 0.002,
gate_cvd_enabled: true,
gate_whale_enabled: true,
whale_usd_threshold: 50000,
whale_flow_pct: 0.5,
gate_obi_enabled: true,
obi_threshold: 0.35,
gate_spot_perp_enabled: false,
spot_perp_threshold: 0.005,
sl_atr_multiplier: 1.5,
tp1_ratio: 0.75,
tp2_ratio: 1.5,
timeout_minutes: 240,
flip_threshold: 80,
description: "",
};
// ─── Per-symbol 推荐值 ────────────────────────────────────────────────────────
// 来自 v53.json symbol_gates与 signal_engine.py 默认值保持一致
export const SYMBOL_RECOMMENDED: Record<string, Partial<StrategyFormData>> = {
BTCUSDT: {
vol_atr_pct_min: 0.002, // ATR需>价格0.2%
whale_usd_threshold: 100000, // 鲸鱼单>10万USD
whale_flow_pct: 0.5, // BTC鲸鱼流量>50%才否决
obi_threshold: 0.30, // OBI阈值宽松BTC流动性好
spot_perp_threshold: 0.003, // 期现溢价<0.3%
},
ETHUSDT: {
vol_atr_pct_min: 0.003, // ETH波动需更大
whale_usd_threshold: 50000,
whale_flow_pct: 0.5,
obi_threshold: 0.35,
spot_perp_threshold: 0.005,
},
SOLUSDT: {
vol_atr_pct_min: 0.004, // SOL波动更剧烈需更高阈值
whale_usd_threshold: 20000,
whale_flow_pct: 0.5,
obi_threshold: 0.45, // SOL OBI噪音多需更严
spot_perp_threshold: 0.008,
},
XRPUSDT: {
vol_atr_pct_min: 0.0025,
whale_usd_threshold: 30000,
whale_flow_pct: 0.5,
obi_threshold: 0.40,
spot_perp_threshold: 0.006,
},
};
export function applySymbolDefaults(form: StrategyFormData, symbol: string): StrategyFormData {
const rec = SYMBOL_RECOMMENDED[symbol] || SYMBOL_RECOMMENDED["BTCUSDT"];
return { ...form, symbol, ...rec };
}
// ─── Helper Components ────────────────────────────────────────────────────────
function FieldLabel({ label, hint }: { label: string; hint?: string }) {
return (
<div className="flex items-center gap-1 mb-1">
<span className="text-xs font-medium text-slate-600">{label}</span>
{hint && (
<span className="group relative">
<Info size={11} className="text-slate-400 cursor-help" />
<span className="hidden group-hover:block absolute left-4 top-0 z-10 w-48 text-[10px] bg-slate-800 text-white rounded px-2 py-1">
{hint}
</span>
</span>
)}
</div>
);
}
function NumberInput({
value, onChange, min, max, step = 1, disabled = false
}: {
value: number; onChange: (v: number) => void;
min: number; max: number; step?: number; disabled?: boolean;
}) {
return (
<input
type="number"
value={value}
min={min}
max={max}
step={step}
disabled={disabled}
onChange={(e) => {
const v = parseFloat(e.target.value);
if (!isNaN(v)) onChange(Math.min(max, Math.max(min, v)));
}}
className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-300 disabled:bg-slate-50 disabled:text-slate-400"
/>
);
}
function SelectInput({
value, onChange, options
}: {
value: string; onChange: (v: string) => void;
options: { label: string; value: string }[];
}) {
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-300 bg-white"
>
{options.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
);
}
function GateRow({
label, hint, enabled, onToggle, children
}: {
label: string; hint: string; enabled: boolean; onToggle: () => void; children: React.ReactNode;
}) {
return (
<div className={`border rounded-lg p-3 transition-colors ${enabled ? "border-blue-200 bg-blue-50/30" : "border-slate-200 bg-slate-50/50"}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1">
<span className="text-xs font-medium text-slate-700">{label}</span>
<span className="group relative">
<Info size={11} className="text-slate-400 cursor-help" />
<span className="hidden group-hover:block absolute left-4 top-0 z-10 w-52 text-[10px] bg-slate-800 text-white rounded px-2 py-1">
{hint}
</span>
</span>
</div>
<button
type="button"
onClick={onToggle}
className={`relative inline-flex items-center w-10 h-5 rounded-full transition-colors flex-shrink-0 ${enabled ? "bg-blue-500" : "bg-slate-300"}`}
>
<span className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform ${enabled ? "translate-x-5" : "translate-x-0"}`} />
</button>
</div>
{enabled && <div className="mt-2">{children}</div>}
</div>
);
}
// ─── Main Form Component ──────────────────────────────────────────────────────
interface StrategyFormProps {
mode: "create" | "edit";
initialData: StrategyFormData;
strategyId?: string;
onSuccess?: (id: string) => void;
isBalanceEditable?: boolean;
}
export default function StrategyForm({ mode, initialData, strategyId, onSuccess, isBalanceEditable = true }: StrategyFormProps) {
useAuth();
const router = useRouter();
const [form, setForm] = useState<StrategyFormData>(initialData);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const set = <K extends keyof StrategyFormData>(key: K, value: StrategyFormData[K]) => {
setForm((prev) => ({ ...prev, [key]: value }));
};
const weightsTotal = form.weight_direction + form.weight_env + form.weight_aux + form.weight_momentum;
const weightsOk = weightsTotal === 100;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!weightsOk) {
setError(`权重合计必须等于 100当前为 ${weightsTotal}`);
return;
}
if (!form.display_name.trim()) {
setError("策略名称不能为空");
return;
}
setError("");
setSubmitting(true);
try {
const payload: Record<string, unknown> = { ...form };
if (!payload.description) delete payload.description;
let res: Response;
if (mode === "create") {
res = await authFetch("/api/strategies", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
} else {
// Edit: only send changed fields (excluding symbol & initial_balance)
const { symbol: _s, initial_balance: _b, ...editPayload } = payload;
void _s; void _b;
res = await authFetch(`/api/strategies/${strategyId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(editPayload),
});
}
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail?.[0]?.msg || err.detail || "请求失败");
}
const data = await res.json();
const newId = data.strategy?.strategy_id || strategyId || "";
if (onSuccess) onSuccess(newId);
else router.push(`/strategy-plaza/${newId}`);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "未知错误");
} finally {
setSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* ── 基础信息 ── */}
<div className="bg-white border border-slate-200 rounded-xl p-4">
<h3 className="text-sm font-semibold text-slate-700 mb-3"></h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<FieldLabel label="策略名称" hint="自由命名最多50字符" />
<input
type="text"
value={form.display_name}
onChange={(e) => set("display_name", e.target.value)}
placeholder="例如我的BTC激进策略"
maxLength={50}
className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-300"
/>
</div>
<div>
<FieldLabel label="交易对" />
<SelectInput
value={form.symbol}
onChange={(v) => {
if (mode === "create") {
// 新建时切换币种自动填入推荐值
setForm((prev) => applySymbolDefaults(prev, v));
} else {
set("symbol", v);
}
}}
options={[
{ label: "BTC/USDT", value: "BTCUSDT" },
{ label: "ETH/USDT", value: "ETHUSDT" },
{ label: "SOL/USDT", value: "SOLUSDT" },
{ label: "XRP/USDT", value: "XRPUSDT" },
]}
/>
</div>
<div>
<FieldLabel label="交易方向" />
<SelectInput
value={form.direction}
onChange={(v) => set("direction", v)}
options={[
{ label: "多空双向", value: "both" },
{ label: "只做多", value: "long_only" },
{ label: "只做空", value: "short_only" },
]}
/>
</div>
<div>
<FieldLabel label="初始资金 (USDT)" hint="最少 1,000 USDT" />
<NumberInput
value={form.initial_balance}
onChange={(v) => set("initial_balance", v)}
min={1000}
max={1000000}
step={1000}
disabled={mode === "edit" && !isBalanceEditable}
/>
</div>
<div className="md:col-span-2">
<FieldLabel label="策略描述(可选)" />
<input
type="text"
value={form.description}
onChange={(e) => set("description", e.target.value)}
placeholder="简短描述这个策略的思路"
maxLength={200}
className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-300"
/>
</div>
</div>
</div>
{/* ── CVD 窗口 ── */}
<div className="bg-white border border-slate-200 rounded-xl p-4">
<h3 className="text-sm font-semibold text-slate-700 mb-3">CVD </h3>
<div className="grid grid-cols-2 gap-4">
<div>
<FieldLabel label="快线 CVD" hint="短周期CVD捕捉近期买卖力量" />
<SelectInput
value={form.cvd_fast_window}
onChange={(v) => set("cvd_fast_window", v)}
options={[
{ label: "5m超短线", value: "5m" },
{ label: "15m短线", value: "15m" },
{ label: "30m中短线", value: "30m" },
]}
/>
</div>
<div>
<FieldLabel label="慢线 CVD" hint="长周期CVD反映趋势方向" />
<SelectInput
value={form.cvd_slow_window}
onChange={(v) => set("cvd_slow_window", v)}
options={[
{ label: "30m", value: "30m" },
{ label: "1h推荐", value: "1h" },
{ label: "4h趋势", value: "4h" },
]}
/>
</div>
</div>
</div>
{/* ── 四层权重 ── */}
<div className="bg-white border border-slate-200 rounded-xl p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-slate-700"></h3>
<span className={`text-xs font-bold px-2 py-0.5 rounded-full ${weightsOk ? "bg-emerald-100 text-emerald-700" : "bg-red-100 text-red-600"}`}>
: {weightsTotal}/100
</span>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<FieldLabel label="方向得分权重" hint="CVD方向信号的权重建议50-65" />
<NumberInput value={form.weight_direction} onChange={(v) => set("weight_direction", v)} min={10} max={80} />
</div>
<div>
<FieldLabel label="环境得分权重" hint="市场环境OI/FR/资金费率)的权重" />
<NumberInput value={form.weight_env} onChange={(v) => set("weight_env", v)} min={5} max={60} />
</div>
<div>
<FieldLabel label="辅助因子权重" hint="清算/现货溢价等辅助信号的权重" />
<NumberInput value={form.weight_aux} onChange={(v) => set("weight_aux", v)} min={0} max={40} />
</div>
<div>
<FieldLabel label="动量权重" hint="短期价格动量信号的权重" />
<NumberInput value={form.weight_momentum} onChange={(v) => set("weight_momentum", v)} min={0} max={20} />
</div>
</div>
</div>
{/* ── 入场阈值 ── */}
<div className="bg-white border border-slate-200 rounded-xl p-4">
<h3 className="text-sm font-semibold text-slate-700 mb-3"></h3>
<div className="grid grid-cols-2 gap-4">
<div>
<FieldLabel label="入场最低总分" hint="信号总分超过此值才开仓默认75越高越严格" />
<NumberInput value={form.entry_score} onChange={(v) => set("entry_score", v)} min={60} max={95} />
</div>
</div>
</div>
{/* ── 五道 Gate ── */}
<div className="bg-white border border-slate-200 rounded-xl p-4">
<h3 className="text-sm font-semibold text-slate-700 mb-3">Gate</h3>
<div className="space-y-3">
{/* 推荐值提示 */}
{(() => {
const rec = SYMBOL_RECOMMENDED[form.symbol];
if (!rec) return null;
return (
<div className="text-xs text-slate-500 bg-slate-50 rounded-lg px-3 py-2">
📌 {form.symbol.replace("USDT","")} ATR%{((rec.vol_atr_pct_min??0)*100).toFixed(2)}%${((rec.whale_usd_threshold??50000)/1000).toFixed(0)}kOBI阈值{rec.obi_threshold}
</div>
);
})()}
<GateRow
label="门1波动率门 (ATR%价格)"
hint="ATR占价格比例低于阈值时不开仓过滤低波动时段"
enabled={form.gate_vol_enabled}
onToggle={() => set("gate_vol_enabled", !form.gate_vol_enabled)}
>
<FieldLabel label="ATR% 最低阈值" hint={`推荐BTC=0.002, ETH=0.003, SOL=0.004, XRP=0.0025(当前${form.symbol.replace("USDT","")}推荐${SYMBOL_RECOMMENDED[form.symbol]?.vol_atr_pct_min??0.002}`} />
<NumberInput value={form.vol_atr_pct_min} onChange={(v) => set("vol_atr_pct_min", v)} min={0.0001} max={0.02} step={0.0005} />
</GateRow>
<GateRow
label="门2CVD共振方向门"
hint="要求快慢两条CVD同向双线共振否则视为无方向不开仓"
enabled={form.gate_cvd_enabled}
onToggle={() => set("gate_cvd_enabled", !form.gate_cvd_enabled)}
/>
<GateRow
label="门3鲸鱼否决门"
hint="检测大单方向ALT用USD金额阈值BTC用鲸鱼CVD流量比例"
enabled={form.gate_whale_enabled}
onToggle={() => set("gate_whale_enabled", !form.gate_whale_enabled)}
>
<FieldLabel label="大单USD阈值 (ALT)" hint={`推荐BTC=10万, ETH=5万, SOL=2万, XRP=3万当前推荐$${((SYMBOL_RECOMMENDED[form.symbol]?.whale_usd_threshold??50000)/1000).toFixed(0)}k`} />
<NumberInput value={form.whale_usd_threshold} onChange={(v) => set("whale_usd_threshold", v)} min={1000} max={1000000} step={5000} />
<FieldLabel label="鲸鱼CVD流量阈值 (BTC)" hint="0~1BTC鲸鱼净方向比例超过此值才否决推荐0.5" />
<NumberInput value={form.whale_flow_pct} onChange={(v) => set("whale_flow_pct", v)} min={0} max={1} step={0.05} />
</GateRow>
<GateRow
label="门4订单簿失衡门 (OBI)"
hint="要求订单簿方向与信号一致OBI绝对值超过阈值才否决反向信号"
enabled={form.gate_obi_enabled}
onToggle={() => set("gate_obi_enabled", !form.gate_obi_enabled)}
>
<FieldLabel label="OBI 否决阈值" hint={`推荐BTC=0.30(宽松), ETH=0.35, SOL=0.45(严格)(当前推荐${SYMBOL_RECOMMENDED[form.symbol]?.obi_threshold??0.35}`} />
<NumberInput value={form.obi_threshold} onChange={(v) => set("obi_threshold", v)} min={0.1} max={0.9} step={0.05} />
</GateRow>
<GateRow
label="门5期现背离门"
hint="要求现货与永续溢价低于阈值,过滤套利异常时段(默认关闭)"
enabled={form.gate_spot_perp_enabled}
onToggle={() => set("gate_spot_perp_enabled", !form.gate_spot_perp_enabled)}
>
<FieldLabel label="溢价率阈值" hint={`推荐BTC=0.003, ETH=0.005, SOL=0.008(当前推荐${SYMBOL_RECOMMENDED[form.symbol]?.spot_perp_threshold??0.005}`} />
<NumberInput value={form.spot_perp_threshold} onChange={(v) => set("spot_perp_threshold", v)} min={0.0005} max={0.01} step={0.0005} />
</GateRow>
</div>
</div>
{/* ── 风控参数 ── */}
<div className="bg-white border border-slate-200 rounded-xl p-4">
<h3 className="text-sm font-semibold text-slate-700 mb-3"></h3>
<div className="grid grid-cols-2 gap-4">
<div>
<FieldLabel label="SL 宽度 (× ATR)" hint="止损距离 = SL倍数 × ATR默认1.5" />
<NumberInput value={form.sl_atr_multiplier} onChange={(v) => set("sl_atr_multiplier", v)} min={0.5} max={3.0} step={0.1} />
</div>
<div>
<FieldLabel label="TP1 目标 (× RD)" hint="第一止盈 = TP1倍数 × 风险距离默认0.75" />
<NumberInput value={form.tp1_ratio} onChange={(v) => set("tp1_ratio", v)} min={0.3} max={2.0} step={0.05} />
</div>
<div>
<FieldLabel label="TP2 目标 (× RD)" hint="第二止盈 = TP2倍数 × 风险距离默认1.5" />
<NumberInput value={form.tp2_ratio} onChange={(v) => set("tp2_ratio", v)} min={0.5} max={4.0} step={0.1} />
</div>
<div>
<FieldLabel label="超时时间 (分钟)" hint="持仓超过此时间自动平仓默认240min" />
<NumberInput value={form.timeout_minutes} onChange={(v) => set("timeout_minutes", v)} min={30} max={1440} step={30} />
</div>
<div>
<FieldLabel label="反转平仓阈值 (分)" hint="对手方向信号分≥此值时平仓默认80" />
<NumberInput value={form.flip_threshold} onChange={(v) => set("flip_threshold", v)} min={60} max={95} />
</div>
</div>
</div>
{/* ── Error & Submit ── */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg px-4 py-2 text-sm text-red-600">
{error}
</div>
)}
<div className="flex gap-3">
<Link
href="/strategy-plaza"
className="flex items-center gap-2 px-4 py-2.5 rounded-xl border border-slate-200 text-sm text-slate-600 hover:bg-slate-50 transition-colors"
>
<ArrowLeft size={15} />
</Link>
<button
type="submit"
disabled={submitting || !weightsOk}
className="flex items-center gap-2 px-6 py-2.5 rounded-xl bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Save size={15} />
{submitting ? "保存中..." : mode === "create" ? "创建并启动" : "保存参数"}
</button>
</div>
</form>
);
}