Compare commits
13 Commits
main
...
feature/v5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cd053c345 | ||
|
|
569c192448 | ||
|
|
07bf1a8a58 | ||
|
|
cb34b1cb39 | ||
|
|
a4bb7828f8 | ||
|
|
89a6809c20 | ||
|
|
2e4c05b2e0 | ||
|
|
06f900b89b | ||
|
|
d3784aaf79 | ||
|
|
06552c2b75 | ||
|
|
f8f13a48d5 | ||
|
|
9d44885188 | ||
|
|
7be7b5b4c0 |
684
backend/main.py
684
backend/main.py
@ -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
327
backend/migrate_v54.py
Normal 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()
|
||||||
155
backend/migrate_v54b_gates.py
Normal file
155
backend/migrate_v54b_gates.py
Normal 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_min(ATR%价格阈值,如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_threshold(ALT大单USD)+ whale_flow_pct(BTC 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()
|
||||||
@ -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 config(V5.4),fallback 到 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_min(ATR%价格,如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_pct(BTC)
|
||||||
|
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})"
|
||||||
|
|
||||||
# 门2:CVD共振(方向门)
|
# 门2:CVD共振(方向门,可关闭)
|
||||||
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_ratio,ALT用大单对立)
|
# 门3:鲸鱼否决(BTC用whale_cvd_ratio,ALT用大单对立,可关闭)
|
||||||
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:
|
||||||
# ALT:recent_large_trades 里有对立大单则否决
|
# ALT:recent_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)"
|
||||||
|
|
||||||
# 门4:OBI否决(实时WS优先,fallback DB)
|
# 门4:OBI否决(实时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:
|
||||||
|
|||||||
593
docs/arbitrage-engine-full-spec.md
Normal file
593
docs/arbitrage-engine-full-spec.md
Normal 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 18(GCP Cloud SQL,内网 IP 10.106.0.3,数据库名 arb_engine)
|
||||||
|
- **认证**:JWT(HS256,secret=`arb-engine-jwt-secret-v2-2026`)
|
||||||
|
- **进程管理**:PM2
|
||||||
|
- **WebSocket**:`websockets` 库,连接币安 WebSocket stream
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
- **框架**:Next.js 14(App Router)
|
||||||
|
- **UI 组件**:shadcn/ui + Tailwind CSS + Radix UI + Lucide Icons
|
||||||
|
- **图表**:Recharts
|
||||||
|
- **主题**:默认暗色,主色 slate + cyan
|
||||||
|
- **HTTP 客户端**:fetch(原生)
|
||||||
|
|
||||||
|
### 基础设施
|
||||||
|
- **服务器**:GCP asia-northeast1-b,Ubuntu,Tailscale IP 100.105.186.73
|
||||||
|
- **Cloud SQL**:GCP,内网 10.106.0.3,公网 34.85.117.248,PostgreSQL 18
|
||||||
|
- **PM2 路径**:`/home/fzq1228/Projects/ops-dashboard/node_modules/pm2/bin/pm2`
|
||||||
|
- **项目路径**:`/home/fzq1228/Projects/arbitrage-engine/`
|
||||||
|
- **前端端口**:4333(arb-web)
|
||||||
|
- **后端端口**:4332(arb-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 策略(legacy,status=deprecated)**:
|
||||||
|
- `00000000-0000-0000-0000-000000000053` → v53 Standard(BTCUSDT+ETHUSDT+SOLUSDT+XRPUSDT,30m/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=VWAP,2=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` / null(null=无信号) |
|
||||||
|
| 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=主动卖出 |
|
||||||
|
|
||||||
|
**数据量**:
|
||||||
|
- BTCUSDT:2026-02-05 起,约 8949 万条
|
||||||
|
- ETHUSDT:2026-02-25 起,约 3297 万条
|
||||||
|
- SOLUSDT/XRPUSDT:2026-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 期现背离 | 现货-永续价差在阈值内(或该门禁用) | 期现背离过大 |
|
||||||
|
|
||||||
|
门2(CVD共振)同时决定信号方向:两个 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)→ 触发 TP1:status 改为 `tp1_hit`,移动止损到保本价
|
||||||
|
2. TP1 已触发后,`price >= tp2_price` → 全仓 TP2:status=`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/strategies,signal_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.py(WebSocket 实时价格监控)
|
||||||
|
↓ 触及 TP1/TP2/SL 或超时
|
||||||
|
paper_trades 平仓(status/exit_price/pnl_r 更新)
|
||||||
|
↓
|
||||||
|
FastAPI(main.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/
|
||||||
|
```
|
||||||
@ -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
|
||||||
|
|||||||
367
frontend/app/strategy-plaza/[id]/PaperGeneric.tsx
Normal file
367
frontend/app/strategy-plaza/[id]/PaperGeneric.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
446
frontend/app/strategy-plaza/[id]/SignalsGeneric.tsx
Normal file
446
frontend/app/strategy-plaza/[id]/SignalsGeneric.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
frontend/app/strategy-plaza/[id]/edit/page.tsx
Normal file
82
frontend/app/strategy-plaza/[id]/edit/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
24
frontend/app/strategy-plaza/create/page.tsx
Normal file
24
frontend/app/strategy-plaza/create/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
196
frontend/app/strategy-plaza/deprecated/page.tsx
Normal file
196
frontend/app/strategy-plaza/deprecated/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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 },
|
||||||
];
|
];
|
||||||
|
|||||||
541
frontend/components/SignalsView.tsx
Normal file
541
frontend/components/SignalsView.tsx
Normal 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">>$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分 + 加速奖励5分。删除独立确认层,解决CVD双重计分问题。</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>:>$100k成交额净CVD,15分钟滚动窗口实时计算</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="pt-1 border-t border-slate-100">
|
||||||
|
<span className="text-blue-600 font-medium">档位:</span><75不开仓 · 75-84标准 · ≥85加仓 · 冷却10分钟
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
534
frontend/components/StrategyForm.tsx
Normal file
534
frontend/components/StrategyForm.tsx
Normal 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)}k,OBI阈值{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="门2:CVD共振方向门"
|
||||||
|
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~1,BTC鲸鱼净方向比例超过此值才否决,推荐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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user