Compare commits

..

No commits in common. "codex/codex_dev" and "main" have entirely different histories.

83 changed files with 8975 additions and 11332 deletions

View File

@ -1,9 +1,3 @@
# arbitrage-engine # arbitrage-engine
资金费率套利引擎 BTC/ETH 现货+永续对冲 资金费率套利引擎 BTC/ETH 现货+永续对冲
## Docs
- 运维连接手册(本地 PostgreSQL + GCE: [docs/OPS_CONNECTIONS.md](docs/OPS_CONNECTIONS.md)
- Auto-Evolve 运行手册: [docs/AUTO_EVOLVE_RUNBOOK.md](docs/AUTO_EVOLVE_RUNBOOK.md)
- Codex 每日复盘 Prompt: [docs/CODEX_DAILY_REVIEW_PROMPT.md](docs/CODEX_DAILY_REVIEW_PROMPT.md)

63
V52_FRONTEND_TASK.md Normal file
View File

@ -0,0 +1,63 @@
# V5.2 Frontend Differentiation Task
## Problem
V5.1 and V5.2 currently share the same pages. Boss wants clear visual separation.
## Requirements
### 1. Signals Page (/signals) - Side-by-side comparison
Currently shows one set of scores per coin. Change to show BOTH V5.1 and V5.2 scores side by side.
For the "Latest Signal" cards at the top, each coin should show:
```
BTC SHORT V5.1: 80分 | V5.2: 85分 5m前
```
The V5.2 score should show FR and Liquidation subscores that V5.1 doesn't have.
To get V5.2 scores, add a new API endpoint `/api/signals/latest-v52` that returns the V5.2 evaluation alongside V5.1. Or modify the existing `/api/signals/latest` to include both strategy scores.
### 2. Paper Trading Page (/paper) - Strategy Tabs at TOP
Add prominent tabs at the very top of the page:
```
[全部] [V5.1 模拟盘] [V5.2 模拟盘]
```
When selecting a strategy tab:
- Current positions: only show positions for that strategy
- Trade history: only show trades for that strategy
- Stats: only show stats for that strategy
- Equity curve: only show curve for that strategy
- The "全部" tab shows everything combined (current behavior)
### 3. Visual Differentiation
- V5.1 trades/positions: use a subtle blue-gray badge
- V5.2 trades/positions: use a green badge with ✨ icon
- V5.2 positions should show extra info: FR score and Liquidation score prominently
### 4. Backend API Changes Needed
#### Modify `/api/signals/latest` endpoint in main.py
Return both V5.1 and V5.2 evaluations. The signal_engine already evaluates both strategies per cycle and saves the primary one. We need to also save V5.2 evaluations or compute them on-the-fly.
Simplest approach: Add a field to the signal_indicators table or return strategy-specific data.
Actually, the simplest approach for NOW: In the latest signal cards, just show the score that's already there (from primary strategy), and add a note showing which strategy it's from. The real differentiation happens in paper trades where the strategy column exists.
#### `/api/paper/trades` already supports `?strategy=` filter (Codex added this)
#### `/api/paper/stats-by-strategy` already exists
### 5. Key Files to Modify
- `frontend/app/paper/page.tsx` - Add strategy tabs at top, filter everything by selected strategy
- `frontend/app/signals/page.tsx` - Show V5.2 specific info (FR/Liq scores) in latest signal cards
- Backend: may need minor API tweaks
### 6. Important
- Don't break existing functionality
- The strategy tabs should be very prominent (not small buttons buried in a section)
- Use consistent styling: slate-800 bg for active tab, slate-100 for inactive
- Test with `npm run build`
When completely finished, run:
openclaw system event --text "Done: V5.2 frontend differentiation - strategy tabs, visual badges, FR/Liq display" --mode now

224
V52_TASK.md Normal file
View File

@ -0,0 +1,224 @@
# V5.2 Development Task
## Context
You are working on the `dev` branch of the ArbitrageEngine project.
This is a quantitative trading signal system with:
- Backend: Python (FastAPI + PostgreSQL)
- Frontend: Next.js + shadcn/ui + Tailwind
## Database Connection
- Host: 34.85.117.248 (Cloud SQL)
- Port: 5432, DB: arb_engine, User: arb, Password: arb_engine_2026
## What to Build (V5.2)
### 1. Strategy Configuration Framework
Create `backend/strategies/` directory with JSON configs:
**backend/strategies/v51_baseline.json:**
```json
{
"name": "v51_baseline",
"version": "5.1",
"threshold": 75,
"weights": {
"direction": 45,
"crowding": 20,
"environment": 15,
"confirmation": 15,
"auxiliary": 5
},
"accel_bonus": 5,
"tp_sl": {
"sl_multiplier": 2.0,
"tp1_multiplier": 1.5,
"tp2_multiplier": 3.0
},
"signals": ["cvd", "p99", "accel", "ls_ratio", "oi", "coinbase_premium"]
}
```
**backend/strategies/v52_8signals.json:**
```json
{
"name": "v52_8signals",
"version": "5.2",
"threshold": 75,
"weights": {
"direction": 40,
"crowding": 25,
"environment": 15,
"confirmation": 20,
"auxiliary": 5
},
"accel_bonus": 5,
"tp_sl": {
"sl_multiplier": 2.0,
"tp1_multiplier": 1.5,
"tp2_multiplier": 3.0
},
"signals": ["cvd", "p99", "accel", "ls_ratio", "oi", "coinbase_premium", "funding_rate", "liquidation"]
}
```
### 2. Signal Engine Changes (signal_engine.py)
#### 2a. Add FR scoring to evaluate_signal()
After the crowding section, add funding_rate scoring:
```python
# Funding Rate scoring (拥挤层加分)
# Read from market_indicators table
funding_rate = to_float(self.market_indicators.get("funding_rate"))
fr_score = 0
if funding_rate is not None:
fr_abs = abs(funding_rate)
if fr_abs >= 0.001: # extreme ±0.1%
# Extreme: penalize if going WITH the crowd
if (direction == "LONG" and funding_rate > 0.001) or \
(direction == "SHORT" and funding_rate < -0.001):
fr_score = -5
else:
fr_score = 5
elif fr_abs >= 0.0003: # moderate ±0.03%
# Moderate: reward going AGAINST the crowd
if (direction == "LONG" and funding_rate < -0.0003) or \
(direction == "SHORT" and funding_rate > 0.0003):
fr_score = 5
else:
fr_score = 0
```
#### 2b. Add liquidation scoring
```python
# Liquidation scoring (确认层加分)
liq_score = 0
liq_data = self.fetch_recent_liquidations() # new method
if liq_data:
liq_long_usd = liq_data.get("long_usd", 0)
liq_short_usd = liq_data.get("short_usd", 0)
# Thresholds by symbol
thresholds = {"BTCUSDT": 500000, "ETHUSDT": 200000, "XRPUSDT": 100000, "SOLUSDT": 100000}
threshold = thresholds.get(self.symbol, 100000)
total = liq_long_usd + liq_short_usd
if total >= threshold:
if liq_short_usd > 0 and liq_long_usd > 0:
ratio = liq_short_usd / liq_long_usd
elif liq_short_usd > 0:
ratio = float('inf')
else:
ratio = 0
if ratio >= 2.0 and direction == "LONG":
liq_score = 5 # shorts getting liquidated, price going up
elif ratio <= 0.5 and direction == "SHORT":
liq_score = 5 # longs getting liquidated, price going down
```
#### 2c. Add fetch_recent_liquidations method to SymbolState
```python
def fetch_recent_liquidations(self, window_ms=300000):
"""Fetch last 5min liquidation totals from liquidations table"""
now_ms = int(time.time() * 1000)
cutoff = now_ms - window_ms
with get_sync_conn() as conn:
with conn.cursor() as cur:
cur.execute("""
SELECT
COALESCE(SUM(CASE WHEN side='SELL' THEN usd_value ELSE 0 END), 0) as long_liq,
COALESCE(SUM(CASE WHEN side='BUY' THEN usd_value ELSE 0 END), 0) as short_liq
FROM liquidations
WHERE symbol=%s AND trade_time >= %s
""", (self.symbol, cutoff))
row = cur.fetchone()
if row:
return {"long_usd": row[0], "short_usd": row[1]}
return None
```
#### 2d. Add funding_rate to fetch_market_indicators
Add "funding_rate" to the indicator types:
```python
for ind_type in ["long_short_ratio", "top_trader_position", "open_interest_hist", "coinbase_premium", "funding_rate"]:
```
And the extraction:
```python
elif ind_type == "funding_rate":
indicators[ind_type] = float(val.get("lastFundingRate", 0))
```
#### 2e. Update total_score calculation
Currently:
```python
total_score = direction_score + accel_bonus + crowding_score + environment_score + confirmation_score + aux_score
```
Change to:
```python
total_score = direction_score + accel_bonus + crowding_score + fr_score + environment_score + confirmation_score + liq_score + aux_score
```
#### 2f. Update factors dict
Add fr_score and liq_score to the factors:
```python
result["factors"] = {
...existing factors...,
"funding_rate": {"score": fr_score, "value": funding_rate},
"liquidation": {"score": liq_score, "long_usd": liq_data.get("long_usd", 0) if liq_data else 0, "short_usd": liq_data.get("short_usd", 0) if liq_data else 0},
}
```
#### 2g. Change threshold from 60 to 75
In evaluate_signal, change:
```python
# OLD
elif total_score >= 60 and not no_direction and not in_cooldown:
result["signal"] = direction
result["tier"] = "light"
# NEW: remove the 60 tier entirely, minimum is 75
```
Also update reverse signal threshold from 60 to 75:
In main() loop:
```python
# OLD
if existing_dir and eval_dir and existing_dir != eval_dir and result["score"] >= 60:
# NEW
if existing_dir and eval_dir and existing_dir != eval_dir and result["score"] >= 75:
```
### 3. Strategy field in paper_trades
Add SQL migration at top of init_schema() or in a migration:
```sql
ALTER TABLE paper_trades ADD COLUMN IF NOT EXISTS strategy VARCHAR(32) DEFAULT 'v51_baseline';
```
### 4. AB Test: Both strategies evaluate each cycle
In the main loop, evaluate signal twice (once per strategy config) and potentially open trades for both. Each trade records which strategy triggered it.
### 5. Frontend: Update paper/page.tsx
- Show strategy column in trade history table
- Show FR and liquidation scores in signal details
- Add strategy filter/tab (v51 vs v52)
### 6. API: Add strategy stats endpoint
In main.py, add `/api/paper/stats-by-strategy` that groups stats by strategy field.
## Important Notes
- Keep ALL existing functionality working
- Don't break the existing V5.1 scoring - it should still work as strategy "v51_baseline"
- The FR data is already in market_indicators table (collected every 5min)
- The liquidation data is already in liquidations table
- Test with: `cd frontend && npm run build` to verify no frontend errors
- Test backend: `python3 -c "from signal_engine import *; print('OK')"` to verify imports
- Port for dev testing: API=8100, Frontend=3300
- Total score CAN exceed 100 (that's by design)
## Files to modify:
1. `backend/signal_engine.py` - core scoring changes
2. `backend/main.py` - new API endpoints
3. `backend/db.py` - add strategy column migration
4. `frontend/app/paper/page.tsx` - UI updates
5. NEW: `backend/strategies/v51_baseline.json`
6. NEW: `backend/strategies/v52_8signals.json`
When completely finished, run this command to notify me:
openclaw system event --text "Done: V5.2 core implementation complete - FR+liquidation scoring, threshold 75, strategy configs, AB test framework" --mode now

View File

@ -1,30 +0,0 @@
# Auto-Evolve
## 文件
- `run_daily.py`: 每日自动分析 + 自动调参 + 自动上新/下线 + 报告输出
- `config.example.json`: 配置模板
## 快速开始
```bash
# dry-run不写库
python3 automation/auto_evolve/run_daily.py
# apply写库
python3 automation/auto_evolve/run_daily.py --apply
# 带配置
python3 automation/auto_evolve/run_daily.py --config automation/auto_evolve/config.example.json --apply
```
## 输出
- Markdown 报告:`reports/auto-evolve/YYYY-MM-DD/HHMMSS_auto_evolve.md`
- JSON 报告:`reports/auto-evolve/YYYY-MM-DD/HHMMSS_auto_evolve.json`
## 默认安全策略
- 默认 dry-run
- `--apply` 才会写入策略;
- 写入失败自动回滚事务。

View File

@ -1,8 +0,0 @@
{
"lookback_days": 7,
"min_closed_trades": 12,
"max_new_per_symbol": 1,
"max_codex_running_per_symbol": 3,
"min_codex_age_hours_to_deprecate": 24,
"report_dir": "reports/auto-evolve"
}

View File

@ -1,890 +0,0 @@
#!/usr/bin/env python3
"""
Auto Evolve Daily Runner
目标
1) 基于最近数据自动复盘 running 策略
2) 每个币种生成 1 codex 优化候选策略
3) 自动下线超配且表现最差的 codex 策略
4) 产出可审计报告Markdown + JSON
注意
- 默认 dry-run仅输出建议不写库
- --apply 才会真正写入 DB
"""
from __future__ import annotations
import argparse
import json
import os
import uuid
from dataclasses import dataclass
from decimal import Decimal
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
try:
import psycopg2
from psycopg2.extras import RealDictCursor
except Exception: # pragma: no cover - runtime dependency guard
psycopg2 = None
RealDictCursor = None
BJ = timezone(timedelta(hours=8))
SYMBOLS = ("BTCUSDT", "ETHUSDT", "SOLUSDT", "XRPUSDT")
WINDOW_MINUTES = {"5m": 5, "15m": 15, "30m": 30, "1h": 60, "4h": 240}
WINDOW_PAIRS = [
("5m", "30m"),
("5m", "1h"),
("5m", "4h"),
("15m", "30m"),
("15m", "1h"),
("15m", "4h"),
("30m", "1h"),
("30m", "4h"),
]
@dataclass
class Config:
lookback_days: int = 7
min_closed_trades: int = 12
max_new_per_symbol: int = 1
max_codex_running_per_symbol: int = 3
min_codex_age_hours_to_deprecate: int = 24
report_dir: str = "reports/auto-evolve"
def load_config(path: str | None) -> Config:
cfg = Config()
if not path:
return cfg
p = Path(path)
if not p.exists():
return cfg
raw = json.loads(p.read_text(encoding="utf-8"))
for k in cfg.__dataclass_fields__:
if k in raw:
setattr(cfg, k, raw[k])
return cfg
def get_db_conn():
if psycopg2 is None:
raise RuntimeError(
"缺少 psycopg2 依赖请先安装pip install psycopg2-binary"
)
host = os.getenv("PG_HOST") or os.getenv("DB_HOST") or "127.0.0.1"
port = int(os.getenv("PG_PORT") or os.getenv("DB_PORT") or 5432)
dbname = os.getenv("PG_DB") or os.getenv("DB_NAME") or "arb_engine"
user = os.getenv("PG_USER") or os.getenv("DB_USER") or "arb"
password = os.getenv("PG_PASS") or os.getenv("DB_PASS") or "arb_engine_2026"
return psycopg2.connect(
host=host,
port=port,
dbname=dbname,
user=user,
password=password,
)
def as_float(v: Any, default: float = 0.0) -> float:
if v is None:
return default
try:
return float(v)
except Exception:
return default
def as_int(v: Any, default: int = 0) -> int:
if v is None:
return default
try:
return int(v)
except Exception:
return default
def detect_regime(avg_atr_pct: float, avg_abs_slope: float, signal_rate: float) -> str:
if avg_atr_pct >= 85:
return "crash"
if avg_atr_pct >= 70:
return "high_vol"
if avg_abs_slope >= 30 and signal_rate >= 0.02:
return "trend"
return "range"
def compute_fitness(row: dict, lookback_days: int, min_closed: int) -> float:
closed = as_int(row.get("closed_trades"))
net_r = as_float(row.get("net_r"))
win_rate = as_float(row.get("win_rate"))
gross_profit = as_float(row.get("gross_profit"))
gross_loss = as_float(row.get("gross_loss"))
r_per_day = net_r / max(lookback_days, 1)
profit_factor = gross_profit / gross_loss if gross_loss > 0 else (2.0 if gross_profit > 0 else 0.0)
sample_penalty = max(min_closed - closed, 0) * 0.35
consistency_bonus = (win_rate - 0.5) * 2.5
score = (
net_r
+ 0.9 * r_per_day
+ 0.6 * (profit_factor - 1.0)
+ 0.5 * consistency_bonus
- sample_penalty
)
return round(score, 4)
def fetch_running_strategies(cur) -> list[dict]:
cur.execute(
"""
SELECT
strategy_id::text AS strategy_id,
display_name,
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,
created_at,
updated_at,
description
FROM strategies
WHERE status='running'
ORDER BY symbol, created_at
"""
)
return [dict(r) for r in cur.fetchall()]
def fetch_metrics(cur, lookback_days: int) -> dict[str, dict]:
cur.execute(
"""
WITH trade_agg AS (
SELECT
s.strategy_id::text AS strategy_id,
COUNT(*) FILTER (
WHERE p.status <> 'active'
AND p.symbol = s.symbol
AND p.created_at >= NOW() - (%s || ' days')::interval
) AS closed_trades,
COALESCE(SUM(CASE
WHEN p.status <> 'active'
AND p.symbol = s.symbol
AND p.created_at >= NOW() - (%s || ' days')::interval
THEN p.pnl_r ELSE 0 END), 0) AS net_r,
COALESCE(AVG(CASE
WHEN p.status <> 'active'
AND p.symbol = s.symbol
AND p.created_at >= NOW() - (%s || ' days')::interval
THEN p.pnl_r END), 0) AS avg_r,
COALESCE(AVG(CASE
WHEN p.status <> 'active'
AND p.symbol = s.symbol
AND p.created_at >= NOW() - (%s || ' days')::interval
THEN CASE WHEN p.pnl_r > 0 THEN 1 ELSE 0 END END), 0) AS win_rate,
COALESCE(SUM(CASE
WHEN p.status <> 'active'
AND p.symbol = s.symbol
AND p.created_at >= NOW() - (%s || ' days')::interval
AND p.pnl_r > 0
THEN p.pnl_r ELSE 0 END), 0) AS gross_profit,
COALESCE(ABS(SUM(CASE
WHEN p.status <> 'active'
AND p.symbol = s.symbol
AND p.created_at >= NOW() - (%s || ' days')::interval
AND p.pnl_r < 0
THEN p.pnl_r ELSE 0 END)), 0) AS gross_loss
FROM strategies s
LEFT JOIN paper_trades p ON p.strategy_id = s.strategy_id
WHERE s.status='running'
GROUP BY s.strategy_id
),
signal_agg AS (
SELECT
s.strategy_id::text AS strategy_id,
COUNT(*) FILTER (
WHERE si.ts >= (extract(epoch from NOW() - interval '24 hours') * 1000)::bigint
AND si.symbol = s.symbol
) AS ticks_24h,
COUNT(*) FILTER (
WHERE si.ts >= (extract(epoch from NOW() - interval '24 hours') * 1000)::bigint
AND si.symbol = s.symbol
AND COALESCE(si.signal, '') <> ''
) AS entry_signals_24h,
COALESCE(AVG(si.score) FILTER (
WHERE si.ts >= (extract(epoch from NOW() - interval '24 hours') * 1000)::bigint
AND si.symbol = s.symbol
AND COALESCE(si.signal, '') <> ''
), 0) AS avg_signal_score_24h
FROM strategies s
LEFT JOIN signal_indicators si ON si.strategy_id = s.strategy_id
WHERE s.status='running'
GROUP BY s.strategy_id
)
SELECT
s.strategy_id::text AS strategy_id,
s.symbol,
COALESCE(t.closed_trades, 0) AS closed_trades,
COALESCE(t.net_r, 0) AS net_r,
COALESCE(t.avg_r, 0) AS avg_r,
COALESCE(t.win_rate, 0) AS win_rate,
COALESCE(t.gross_profit, 0) AS gross_profit,
COALESCE(t.gross_loss, 0) AS gross_loss,
COALESCE(sa.ticks_24h, 0) AS ticks_24h,
COALESCE(sa.entry_signals_24h, 0) AS entry_signals_24h,
COALESCE(sa.avg_signal_score_24h, 0) AS avg_signal_score_24h
FROM strategies s
LEFT JOIN trade_agg t ON t.strategy_id = s.strategy_id::text
LEFT JOIN signal_agg sa ON sa.strategy_id = s.strategy_id::text
WHERE s.status='running'
""",
(lookback_days, lookback_days, lookback_days, lookback_days, lookback_days, lookback_days),
)
data: dict[str, dict] = {}
for r in cur.fetchall():
data[r["strategy_id"]] = dict(r)
return data
def fetch_symbol_stats(cur) -> dict[str, dict]:
cur.execute(
"""
SELECT
si.symbol,
COALESCE(AVG(si.atr_percentile), 0) AS avg_atr_percentile,
COALESCE(AVG(ABS(si.cvd_fast_slope)), 0) AS avg_abs_cvd_slope,
COALESCE(AVG(CASE WHEN COALESCE(si.signal,'') <> '' THEN 1 ELSE 0 END), 0) AS signal_rate,
COUNT(*) AS rows_24h
FROM signal_indicators si
JOIN strategies s ON s.strategy_id = si.strategy_id
WHERE s.status='running'
AND si.symbol = s.symbol
AND si.ts >= (extract(epoch from NOW() - interval '24 hours') * 1000)::bigint
GROUP BY si.symbol
"""
)
stats: dict[str, dict] = {}
for r in cur.fetchall():
avg_atr = as_float(r["avg_atr_percentile"])
avg_slope = as_float(r["avg_abs_cvd_slope"])
signal_rate = as_float(r["signal_rate"])
regime = detect_regime(avg_atr, avg_slope, signal_rate)
stats[r["symbol"]] = {
"avg_atr_percentile": round(avg_atr, 2),
"avg_abs_cvd_slope": round(avg_slope, 2),
"signal_rate": round(signal_rate, 5),
"rows_24h": as_int(r["rows_24h"]),
"regime": regime,
}
return stats
def normalize_weights(wd: int, we: int, wa: int, wm: int) -> tuple[int, int, int, int]:
vals = [max(0, wd), max(0, we), max(0, wa), max(0, wm)]
s = sum(vals)
if s <= 0:
return (55, 25, 15, 5)
scaled = [round(v * 100 / s) for v in vals]
diff = 100 - sum(scaled)
scaled[0] += diff
return tuple(int(max(0, v)) for v in scaled)
def mutate_profile(parent: dict, regime: str) -> list[dict]:
base_entry = as_int(parent["entry_score"], 75)
base_sl = as_float(parent["sl_atr_multiplier"], 1.8)
base_timeout = as_int(parent["timeout_minutes"], 240)
if regime in ("trend", "high_vol"):
tp_profiles = [
("激进", 1.25, 2.7, max(1.5, base_sl)),
("平衡", 1.0, 2.1, max(1.6, base_sl)),
("保守", 0.8, 1.6, max(1.5, base_sl - 0.1)),
]
entry_steps = [0, 2, 4]
timeout_choices = [max(120, base_timeout - 60), base_timeout]
elif regime == "crash":
tp_profiles = [
("防守", 0.7, 1.4, max(1.3, base_sl - 0.3)),
("平衡", 0.9, 1.8, max(1.4, base_sl - 0.2)),
]
entry_steps = [3, 5]
timeout_choices = [max(90, base_timeout - 120), max(120, base_timeout - 60)]
else:
tp_profiles = [
("保守", 0.75, 1.5, max(1.4, base_sl - 0.2)),
("平衡", 1.0, 2.0, max(1.5, base_sl - 0.1)),
("激进", 1.3, 2.4, max(1.6, base_sl)),
]
entry_steps = [-2, 0, 2]
timeout_choices = [base_timeout, min(360, base_timeout + 60)]
candidates = []
for fast, slow in WINDOW_PAIRS:
for profile_name, tp1, tp2, sl in tp_profiles:
for step in entry_steps:
for timeout_min in timeout_choices:
entry = min(95, max(60, base_entry + step))
wd, we, wa, wm = (
as_int(parent["weight_direction"], 55),
as_int(parent["weight_env"], 25),
as_int(parent["weight_aux"], 15),
as_int(parent["weight_momentum"], 5),
)
if regime in ("trend", "high_vol"):
wd, we, wa, wm = normalize_weights(wd + 4, we + 1, wa - 3, wm - 2)
elif regime == "crash":
wd, we, wa, wm = normalize_weights(wd + 2, we + 4, wa - 4, wm - 2)
else:
wd, we, wa, wm = normalize_weights(wd - 1, we + 3, wa + 1, wm - 3)
c = {
"cvd_fast_window": fast,
"cvd_slow_window": slow,
"entry_score": entry,
"sl_atr_multiplier": round(sl, 2),
"tp1_ratio": round(tp1, 2),
"tp2_ratio": round(tp2, 2),
"timeout_minutes": int(timeout_min),
"weight_direction": wd,
"weight_env": we,
"weight_aux": wa,
"weight_momentum": wm,
"profile_name": profile_name,
}
candidates.append(c)
return candidates
def candidate_signature(c: dict) -> tuple:
return (
c["cvd_fast_window"],
c["cvd_slow_window"],
c["entry_score"],
c["sl_atr_multiplier"],
c["tp1_ratio"],
c["tp2_ratio"],
c["timeout_minutes"],
c["weight_direction"],
c["weight_env"],
c["weight_aux"],
c["weight_momentum"],
)
def estimate_candidate_score(parent_metric: dict, candidate: dict, regime: str) -> float:
base = as_float(parent_metric.get("fitness"), 0)
signal_density = as_float(parent_metric.get("entry_signals_24h"), 0)
closed_trades = as_int(parent_metric.get("closed_trades"), 0)
bonus = 0.0
if regime in ("trend", "high_vol") and candidate["tp2_ratio"] >= 2.2:
bonus += 0.45
if regime == "range" and candidate["entry_score"] <= as_int(parent_metric.get("entry_score"), 75):
bonus += 0.25
if signal_density < 20 and candidate["entry_score"] < as_int(parent_metric.get("entry_score"), 75):
bonus += 0.35
if signal_density > 120 and candidate["entry_score"] > as_int(parent_metric.get("entry_score"), 75):
bonus += 0.2
if closed_trades < 10:
bonus -= 0.25
if candidate["sl_atr_multiplier"] < 1.3 and regime in ("high_vol", "crash"):
bonus -= 0.4
if candidate["cvd_fast_window"] == candidate["cvd_slow_window"]:
bonus -= 0.2
return round(base + bonus, 4)
def choose_new_candidates(
strategies: list[dict],
metrics: dict[str, dict],
symbol_stats: dict[str, dict],
cfg: Config,
) -> tuple[list[dict], list[str]]:
by_symbol: dict[str, list[dict]] = {sym: [] for sym in SYMBOLS}
for s in strategies:
if s["symbol"] in by_symbol:
row = dict(s)
row.update(metrics.get(s["strategy_id"], {}))
row["fitness"] = compute_fitness(row, cfg.lookback_days, cfg.min_closed_trades)
by_symbol[s["symbol"]].append(row)
created_plan: list[dict] = []
logs: list[str] = []
for sym in SYMBOLS:
symbol_rows = by_symbol.get(sym, [])
if not symbol_rows:
logs.append(f"[{sym}] 无 running 策略,跳过")
continue
symbol_rows.sort(key=lambda x: x["fitness"], reverse=True)
regime = symbol_stats.get(sym, {}).get("regime", "range")
existing_sigs = {
candidate_signature(
{
"cvd_fast_window": r["cvd_fast_window"],
"cvd_slow_window": r["cvd_slow_window"],
"entry_score": as_int(r["entry_score"], 75),
"sl_atr_multiplier": round(as_float(r["sl_atr_multiplier"], 1.8), 2),
"tp1_ratio": round(as_float(r["tp1_ratio"], 1.0), 2),
"tp2_ratio": round(as_float(r["tp2_ratio"], 2.0), 2),
"timeout_minutes": as_int(r["timeout_minutes"], 240),
"weight_direction": as_int(r["weight_direction"], 55),
"weight_env": as_int(r["weight_env"], 25),
"weight_aux": as_int(r["weight_aux"], 15),
"weight_momentum": as_int(r["weight_momentum"], 5),
}
)
for r in symbol_rows
}
parent = symbol_rows[0]
parent_for_est = dict(parent)
parent_for_est["entry_signals_24h"] = as_int(parent.get("entry_signals_24h"), 0)
pool = mutate_profile(parent, regime)
scored_pool = []
for c in pool:
sig = candidate_signature(c)
if sig in existing_sigs:
continue
score = estimate_candidate_score(parent_for_est, c, regime)
c2 = dict(c)
c2["estimated_fitness"] = score
c2["source_strategy_id"] = parent["strategy_id"]
c2["source_display_name"] = parent["display_name"]
c2["symbol"] = sym
c2["direction"] = parent["direction"]
c2["gate_vol_enabled"] = parent["gate_vol_enabled"]
c2["vol_atr_pct_min"] = as_float(parent["vol_atr_pct_min"], 0.002)
c2["gate_cvd_enabled"] = parent["gate_cvd_enabled"]
c2["gate_whale_enabled"] = parent["gate_whale_enabled"]
c2["whale_usd_threshold"] = as_float(parent["whale_usd_threshold"], 50000)
c2["whale_flow_pct"] = as_float(parent["whale_flow_pct"], 0.5)
c2["gate_obi_enabled"] = parent["gate_obi_enabled"]
c2["obi_threshold"] = as_float(parent["obi_threshold"], 0.35)
c2["gate_spot_perp_enabled"] = parent["gate_spot_perp_enabled"]
c2["spot_perp_threshold"] = as_float(parent["spot_perp_threshold"], 0.005)
c2["flip_threshold"] = as_int(parent["flip_threshold"], max(c2["entry_score"], 75))
c2["initial_balance"] = as_float(parent["initial_balance"], 10000)
scored_pool.append(c2)
scored_pool.sort(key=lambda x: x["estimated_fitness"], reverse=True)
if not scored_pool:
logs.append(f"[{sym}] 无可生成的新候选(参数已覆盖)")
continue
top_n = max(1, cfg.max_new_per_symbol)
picks = scored_pool[:top_n]
created_plan.extend(picks)
logs.append(
f"[{sym}] regime={regime} parent={parent['display_name']} -> candidate={len(picks)}"
)
return created_plan, logs
def build_display_name(candidate: dict, now_bj: datetime) -> str:
sym = candidate["symbol"].replace("USDT", "")
fw = candidate["cvd_fast_window"]
sw = candidate["cvd_slow_window"]
profile = candidate["profile_name"]
stamp = now_bj.strftime("%m%d")
return f"codex优化-{sym}_CVD{fw}x{sw}_TP{profile}_{stamp}"
def insert_strategy(cur, c: dict, now_bj: datetime) -> str:
sid = str(uuid.uuid4())
display_name = build_display_name(c, now_bj)
cur.execute("SELECT 1 FROM strategies WHERE display_name=%s", (display_name,))
if cur.fetchone():
display_name = f"{display_name}_{sid[:4]}"
description = (
"AutoEvolve generated by Codex; "
f"source={c['source_display_name']}({c['source_strategy_id'][:8]}), "
f"estimated_fitness={c['estimated_fitness']:.3f}, "
f"profile={c['profile_name']}"
)
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_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, tags,
created_at, updated_at, status_changed_at
) VALUES (
%s, %s, 1, 'running',
%s, %s,
%s, %s,
%s, %s, %s, %s,
%s,
%s, %s,
%s,
%s, %s, %s,
%s, %s,
%s, %s,
%s, %s, %s,
%s, %s,
%s, %s,
%s, %s,
NOW(), NOW(), NOW()
)
""",
(
sid,
display_name,
c["symbol"],
c["direction"],
c["cvd_fast_window"],
c["cvd_slow_window"],
c["weight_direction"],
c["weight_env"],
c["weight_aux"],
c["weight_momentum"],
c["entry_score"],
c["gate_vol_enabled"],
c["vol_atr_pct_min"],
c["gate_cvd_enabled"],
c["gate_whale_enabled"],
c["whale_usd_threshold"],
c["whale_flow_pct"],
c["gate_obi_enabled"],
c["obi_threshold"],
c["gate_spot_perp_enabled"],
c["spot_perp_threshold"],
c["sl_atr_multiplier"],
c["tp1_ratio"],
c["tp2_ratio"],
c["timeout_minutes"],
c["flip_threshold"],
c["initial_balance"],
c["initial_balance"],
description,
["codex", "auto-evolve", f"source:{c['source_strategy_id'][:8]}"],
),
)
return sid
def deprecate_overflow_codex(cur, symbol: str, metrics: dict[str, dict], cfg: Config, new_ids: set[str], dry_run: bool) -> list[dict]:
cur.execute(
"""
SELECT
strategy_id::text AS strategy_id,
display_name,
created_at,
symbol
FROM strategies
WHERE status='running'
AND symbol=%s
AND display_name LIKE 'codex优化-%%'
ORDER BY created_at ASC
""",
(symbol,),
)
rows = [dict(r) for r in cur.fetchall()]
if len(rows) <= cfg.max_codex_running_per_symbol:
return []
now = datetime.now(timezone.utc)
min_age = timedelta(hours=cfg.min_codex_age_hours_to_deprecate)
candidates = []
for r in rows:
sid = r["strategy_id"]
if sid in new_ids:
continue
created_at = r.get("created_at")
if created_at and (now - created_at) < min_age:
continue
m = metrics.get(sid, {})
fitness = as_float(m.get("fitness"), -999)
candidates.append((fitness, created_at, r))
if not candidates:
return []
candidates.sort(key=lambda x: (x[0], x[1] or datetime(1970, 1, 1, tzinfo=timezone.utc)))
need_drop = len(rows) - cfg.max_codex_running_per_symbol
drops = [c[2] for c in candidates[:need_drop]]
if not dry_run:
for d in drops:
cur.execute(
"""
UPDATE strategies
SET status='deprecated', deprecated_at=NOW(), status_changed_at=NOW(), updated_at=NOW()
WHERE strategy_id=%s
""",
(d["strategy_id"],),
)
return drops
def top_bottom(strategies: list[dict], top_n: int = 3) -> tuple[list[dict], list[dict]]:
s = sorted(strategies, key=lambda x: x.get("fitness", -999), reverse=True)
return s[:top_n], list(reversed(s[-top_n:]))
def to_jsonable(value: Any):
if isinstance(value, datetime):
return value.isoformat()
if isinstance(value, Decimal):
return float(value)
if isinstance(value, dict):
return {k: to_jsonable(v) for k, v in value.items()}
if isinstance(value, list):
return [to_jsonable(v) for v in value]
return value
def write_report(
cfg: Config,
now_bj: datetime,
strategies: list[dict],
symbol_stats: dict[str, dict],
created: list[dict],
deprecated: list[dict],
plan_logs: list[str],
dry_run: bool,
) -> tuple[Path, Path]:
report_root = Path(cfg.report_dir)
report_root.mkdir(parents=True, exist_ok=True)
day_dir = report_root / now_bj.strftime("%Y-%m-%d")
day_dir.mkdir(parents=True, exist_ok=True)
stamp = now_bj.strftime("%H%M%S")
md_path = day_dir / f"{stamp}_auto_evolve.md"
json_path = day_dir / f"{stamp}_auto_evolve.json"
top3, bottom3 = top_bottom(strategies, 3)
lines = []
lines.append(f"# Auto Evolve Report ({now_bj.strftime('%Y-%m-%d %H:%M:%S %Z')})")
lines.append("")
lines.append(f"- mode: {'DRY-RUN' if dry_run else 'APPLY'}")
lines.append(f"- lookback_days: {cfg.lookback_days}")
lines.append(f"- running_strategies: {len(strategies)}")
lines.append("")
lines.append("## Symbol Regime")
for sym in SYMBOLS:
st = symbol_stats.get(sym, {})
lines.append(
f"- {sym}: regime={st.get('regime','unknown')}, avg_atr_pct={st.get('avg_atr_percentile',0)}, "
f"avg_abs_slope={st.get('avg_abs_cvd_slope',0)}, signal_rate={st.get('signal_rate',0)}"
)
lines.append("")
lines.append("## Top 3 (Fitness)")
for r in top3:
lines.append(
f"- {r['display_name']} ({r['symbol']}): fitness={r['fitness']:.3f}, "
f"net_r={as_float(r.get('net_r')):.2f}, closed={as_int(r.get('closed_trades'))}, "
f"win_rate={as_float(r.get('win_rate'))*100:.1f}%"
)
lines.append("")
lines.append("## Bottom 3 (Fitness)")
for r in bottom3:
lines.append(
f"- {r['display_name']} ({r['symbol']}): fitness={r['fitness']:.3f}, "
f"net_r={as_float(r.get('net_r')):.2f}, closed={as_int(r.get('closed_trades'))}, "
f"win_rate={as_float(r.get('win_rate'))*100:.1f}%"
)
lines.append("")
lines.append("## Candidate Plan")
if plan_logs:
lines.extend([f"- {x}" for x in plan_logs])
else:
lines.append("- no candidate plan")
lines.append("")
lines.append("## Created Strategies")
if created:
for c in created:
lines.append(
f"- {c['display_name']} ({c['symbol']}): id={c['strategy_id']}, "
f"source={c['source_display_name']}, est_fitness={c['estimated_fitness']:.3f}"
)
else:
lines.append("- none")
lines.append("")
lines.append("## Deprecated Strategies")
if deprecated:
for d in deprecated:
lines.append(f"- {d['display_name']} ({d['symbol']}): id={d['strategy_id']}")
else:
lines.append("- none")
lines.append("")
md_path.write_text("\n".join(lines), encoding="utf-8")
payload = {
"generated_at": now_bj.isoformat(),
"mode": "dry-run" if dry_run else "apply",
"lookback_days": cfg.lookback_days,
"running_strategies": len(strategies),
"symbol_stats": symbol_stats,
"top3": top3,
"bottom3": bottom3,
"created": created,
"deprecated": deprecated,
"plan_logs": plan_logs,
}
json_path.write_text(
json.dumps(to_jsonable(payload), ensure_ascii=False, indent=2),
encoding="utf-8",
)
return md_path, json_path
def main() -> int:
parser = argparse.ArgumentParser(description="Auto evolve daily runner")
parser.add_argument("--config", default=None, help="Path to JSON config")
parser.add_argument("--apply", action="store_true", help="Apply DB mutations (default dry-run)")
args = parser.parse_args()
cfg = load_config(args.config)
dry_run = not args.apply
now_bj = datetime.now(BJ)
conn = get_db_conn()
conn.autocommit = False
created_rows: list[dict] = []
deprecated_rows: list[dict] = []
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
strategies = fetch_running_strategies(cur)
metrics = fetch_metrics(cur, cfg.lookback_days)
symbol_stats = fetch_symbol_stats(cur)
for s in strategies:
m = metrics.get(s["strategy_id"], {})
s.update(m)
s["fitness"] = compute_fitness(s, cfg.lookback_days, cfg.min_closed_trades)
metrics[s["strategy_id"]] = {
**m,
"fitness": s["fitness"],
}
plan, plan_logs = choose_new_candidates(strategies, metrics, symbol_stats, cfg)
new_ids: set[str] = set()
if not dry_run:
for c in plan:
sid = insert_strategy(cur, c, now_bj)
new_ids.add(sid)
created_rows.append(
{
**c,
"strategy_id": sid,
"display_name": build_display_name(c, now_bj),
}
)
# 重新拉一次 metrics让 deprecate 基于最新 running 集合
metrics = fetch_metrics(cur, cfg.lookback_days)
for sid, m in metrics.items():
m["fitness"] = compute_fitness(m, cfg.lookback_days, cfg.min_closed_trades)
for sym in SYMBOLS:
drops = deprecate_overflow_codex(cur, sym, metrics, cfg, new_ids, dry_run=False)
deprecated_rows.extend(drops)
conn.commit()
else:
for c in plan:
created_rows.append(
{
**c,
"strategy_id": "DRY-RUN",
"display_name": build_display_name(c, now_bj),
}
)
# 报告按提交后的状态生成dry-run 就用当前状态)
if not dry_run:
strategies = fetch_running_strategies(cur)
metrics = fetch_metrics(cur, cfg.lookback_days)
for s in strategies:
m = metrics.get(s["strategy_id"], {})
s.update(m)
s["fitness"] = compute_fitness(s, cfg.lookback_days, cfg.min_closed_trades)
md_path, json_path = write_report(
cfg=cfg,
now_bj=now_bj,
strategies=strategies,
symbol_stats=symbol_stats,
created=created_rows,
deprecated=deprecated_rows,
plan_logs=plan_logs,
dry_run=dry_run,
)
print(f"[auto-evolve] done mode={'dry-run' if dry_run else 'apply'}")
print(f"[auto-evolve] report_md={md_path}")
print(f"[auto-evolve] report_json={json_path}")
return 0
except Exception as e:
conn.rollback()
print(f"[auto-evolve] failed: {e}")
raise
finally:
conn.close()
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -24,10 +24,9 @@ from typing import Optional
import psycopg2 import psycopg2
# 复用 signal_engine/signal_state 的核心类与评分逻辑 # 复用signal_engine的核心类
sys.path.insert(0, os.path.dirname(__file__)) sys.path.insert(0, os.path.dirname(__file__))
from signal_engine import SymbolState, WINDOW_MID from signal_engine import SymbolState, WINDOW_MID
from strategy_scoring import evaluate_signal as score_strategy
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
@ -35,7 +34,7 @@ logging.basicConfig(
) )
logger = logging.getLogger("backtest") logger = logging.getLogger("backtest")
PG_HOST = os.getenv("PG_HOST", "127.0.0.1") PG_HOST = os.getenv("PG_HOST", "10.106.0.3")
PG_PORT = int(os.getenv("PG_PORT", "5432")) PG_PORT = int(os.getenv("PG_PORT", "5432"))
PG_DB = os.getenv("PG_DB", "arb_engine") PG_DB = os.getenv("PG_DB", "arb_engine")
PG_USER = os.getenv("PG_USER", "arb") PG_USER = os.getenv("PG_USER", "arb")
@ -221,13 +220,7 @@ class BacktestEngine:
if len(self.positions) > 0: if len(self.positions) > 0:
return return
# 使用统一评分入口V5.1 baseline 配置) result = self.state.evaluate_signal(time_ms)
strategy_cfg = {
"name": "v51_baseline",
"threshold": 75,
"signals": ["cvd", "p99", "accel", "ls_ratio", "oi", "coinbase_premium"],
}
result = score_strategy(self.state, time_ms, strategy_cfg=strategy_cfg)
signal = result.get("signal") signal = result.get("signal")
if not signal: if not signal:
return return

View File

@ -1,6 +1,6 @@
""" """
db.py PostgreSQL 数据库连接层 db.py PostgreSQL 数据库连接层
统一连接到 Cloud SQLPG_HOST 默认 127.0.0.1 统一连接到 Cloud SQLPG_HOST 默认 10.106.0.3
同步连接池psycopg2供脚本类使用 同步连接池psycopg2供脚本类使用
异步连接池asyncpg供FastAPI使用 异步连接池asyncpg供FastAPI使用
""" """
@ -13,7 +13,7 @@ import psycopg2.pool
from contextlib import contextmanager from contextlib import contextmanager
# PG连接参数统一连接 Cloud SQL # PG连接参数统一连接 Cloud SQL
PG_HOST = os.getenv("PG_HOST", "127.0.0.1") PG_HOST = os.getenv("PG_HOST", "10.106.0.3")
PG_PORT = int(os.getenv("PG_PORT", 5432)) PG_PORT = int(os.getenv("PG_PORT", 5432))
PG_DB = os.getenv("PG_DB", "arb_engine") PG_DB = os.getenv("PG_DB", "arb_engine")
PG_USER = os.getenv("PG_USER", "arb") PG_USER = os.getenv("PG_USER", "arb")

View File

@ -5,8 +5,8 @@ module.exports = {
interpreter: "python3", interpreter: "python3",
cwd: "/root/Projects/arbitrage-engine/backend", cwd: "/root/Projects/arbitrage-engine/backend",
env: { env: {
PG_HOST: "127.0.0.1", PG_HOST: "34.85.117.248",
CLOUD_PG_HOST: "127.0.0.1", CLOUD_PG_HOST: "34.85.117.248",
CLOUD_PG_ENABLED: "true" CLOUD_PG_ENABLED: "true"
} }
}] }]

View File

@ -49,7 +49,7 @@ if not _DB_PASSWORD:
sys.exit(1) sys.exit(1)
DB_CONFIG = { DB_CONFIG = {
"host": os.getenv("DB_HOST", "127.0.0.1"), "host": os.getenv("DB_HOST", "10.106.0.3"),
"port": int(os.getenv("DB_PORT", "5432")), "port": int(os.getenv("DB_PORT", "5432")),
"dbname": os.getenv("DB_NAME", "arb_engine"), "dbname": os.getenv("DB_NAME", "arb_engine"),
"user": os.getenv("DB_USER", "arb"), "user": os.getenv("DB_USER", "arb"),

View File

@ -448,7 +448,7 @@ async def get_signal_indicators(
@app.get("/api/signals/latest") @app.get("/api/signals/latest")
async def get_signal_latest(user: dict = Depends(get_current_user), strategy: str = "v53"): async def get_signal_latest(user: dict = Depends(get_current_user), strategy: str = "v52_8signals"):
result = {} result = {}
for sym in SYMBOLS: for sym in SYMBOLS:
row = await async_fetchrow( row = await async_fetchrow(
@ -601,7 +601,7 @@ async def get_market_indicators(user: dict = Depends(get_current_user)):
async def get_signal_history( async def get_signal_history(
symbol: str = "BTC", symbol: str = "BTC",
limit: int = 50, limit: int = 50,
strategy: str = "v53", strategy: str = "v52_8signals",
user: dict = Depends(get_current_user), user: dict = Depends(get_current_user),
): ):
"""返回最近的信号历史(只返回有信号的记录),含各层分数""" """返回最近的信号历史(只返回有信号的记录),含各层分数"""
@ -688,32 +688,10 @@ 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_id != "all": if strategy == "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')"
) )
@ -721,8 +699,6 @@ 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 "
@ -737,15 +713,13 @@ 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 = initial_balance * risk_per_trade paper_1r_usd = paper_config["initial_balance"] * paper_config["risk_per_trade"]
total_pnl_usdt = total_pnl * paper_1r_usd total_pnl_usdt = total_pnl * paper_1r_usd
balance = initial_balance + total_pnl_usdt balance = paper_config["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))
@ -766,26 +740,18 @@ 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_id != "all": if strategy == "all":
rows = await async_fetch( rows = await async_fetch(
"SELECT id, symbol, direction, score, tier, strategy, strategy_id, entry_price, entry_ts, " "SELECT id, symbol, direction, score, tier, strategy, entry_price, entry_ts, "
"tp1_price, tp2_price, sl_price, tp1_hit, status, atr_at_entry, 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, strategy_id, entry_price, entry_ts, " "SELECT id, symbol, direction, score, tier, strategy, entry_price, entry_ts, "
"tp1_price, tp2_price, sl_price, tp1_hit, status, atr_at_entry, 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,
@ -843,7 +809,6 @@ 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),
): ):
@ -862,11 +827,7 @@ async def paper_trades(
elif result == "loss": elif result == "loss":
conditions.append("pnl_r <= 0") conditions.append("pnl_r <= 0")
if strategy_id != "all": if strategy != "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
@ -874,7 +835,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, strategy_id, entry_price, exit_price, " f"SELECT id, symbol, direction, score, tier, strategy, entry_price, exit_price, "
f"entry_ts, exit_ts, pnl_r, status, tp1_hit, 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
@ -885,17 +846,10 @@ 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_id != "all": if strategy == "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"
@ -917,17 +871,10 @@ 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_id != "all": if strategy == "all":
rows = await async_fetch(
"SELECT symbol, direction, pnl_r, tier, entry_ts, exit_ts "
"FROM paper_trades WHERE status NOT IN ('active','tp1_hit') 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')"
@ -2232,614 +2179,3 @@ 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,
}

View File

@ -12,7 +12,7 @@ from psycopg2.extras import Json
SYMBOLS = ["BTCUSDT", "ETHUSDT", "XRPUSDT", "SOLUSDT"] SYMBOLS = ["BTCUSDT", "ETHUSDT", "XRPUSDT", "SOLUSDT"]
INTERVAL_SECONDS = 300 INTERVAL_SECONDS = 300
PG_HOST = os.getenv("PG_HOST", "127.0.0.1") PG_HOST = os.getenv("PG_HOST", "10.106.0.3")
PG_PORT = int(os.getenv("PG_PORT", "5432")) PG_PORT = int(os.getenv("PG_PORT", "5432"))
PG_DB = os.getenv("PG_DB", "arb_engine") PG_DB = os.getenv("PG_DB", "arb_engine")
PG_USER = os.getenv("PG_USER", "arb") PG_USER = os.getenv("PG_USER", "arb")

View File

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

View File

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

View File

@ -1,9 +1,13 @@
{ {
"enabled": true, "enabled": true,
"enabled_strategies": [], "enabled_strategies": [
"v53",
"v53_fast",
"v53_middle"
],
"initial_balance": 10000, "initial_balance": 10000,
"risk_per_trade": 0.02, "risk_per_trade": 0.02,
"max_positions": 100, "max_positions": 4,
"tier_multiplier": { "tier_multiplier": {
"light": 0.5, "light": 0.5,
"standard": 1.0, "standard": 1.0,

View File

@ -47,19 +47,16 @@ def check_and_close(symbol_upper: str, price: float):
with get_sync_conn() as conn: with get_sync_conn() as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
# 关联 strategies 表获取 timeout_minutes每个策略可独立配置超时时间
cur.execute( cur.execute(
"SELECT p.id, p.direction, p.entry_price, p.tp1_price, p.tp2_price, p.sl_price, " "SELECT id, direction, entry_price, tp1_price, tp2_price, sl_price, "
"p.tp1_hit, p.entry_ts, p.atr_at_entry, p.risk_distance, s.timeout_minutes " "tp1_hit, entry_ts, atr_at_entry, risk_distance "
"FROM paper_trades AS p " "FROM paper_trades WHERE symbol=%s AND status IN ('active','tp1_hit')",
"LEFT JOIN strategies AS s ON p.strategy_id = s.strategy_id " (symbol_upper,)
"WHERE p.symbol=%s AND p.status IN ('active','tp1_hit')",
(symbol_upper,),
) )
positions = cur.fetchall() positions = cur.fetchall()
for pos in positions: for pos in positions:
pid, direction, entry_price, tp1, tp2, sl, tp1_hit, entry_ts, atr_entry, rd_db, timeout_db = pos pid, direction, entry_price, tp1, tp2, sl, tp1_hit, entry_ts, atr_entry, rd_db = pos
closed = False closed = False
new_status = None new_status = None
pnl_r = 0.0 pnl_r = 0.0
@ -115,9 +112,8 @@ def check_and_close(symbol_upper: str, price: float):
tp2_r = (entry_price - tp2) / risk_distance if risk_distance > 0 else 0 tp2_r = (entry_price - tp2) / risk_distance if risk_distance > 0 else 0
pnl_r = 0.5 * tp1_r + 0.5 * tp2_r pnl_r = 0.5 * tp1_r + 0.5 * tp2_r
# 时间止损:按 strategies.timeout_minutes 配置(缺省 240 分钟) # 时间止损60分钟市价平仓用当前价
timeout_minutes = timeout_db if timeout_db and timeout_db > 0 else 240 if not closed and (now_ms - entry_ts > 60 * 60 * 1000):
if not closed and (now_ms - entry_ts > timeout_minutes * 60 * 1000):
closed = True closed = True
exit_price = price # 超时是市价平仓 exit_price = price # 超时是市价平仓
new_status = "timeout" new_status = "timeout"

View File

@ -1,202 +0,0 @@
"""
paper_trading.py 模拟盘开仓/平仓辅助函数
从原来的 signal_engine.py 拆分出的 paper_trades 辅助逻辑
- paper_open_trade(): 写入 paper_trades 开仓记录
- paper_has_active_position() / paper_get_active_direction()
- paper_close_by_signal() / paper_active_count()
行为保持与原实现完全一致 signal_engine 调用
"""
from typing import Optional
from db import get_sync_conn
PAPER_FEE_RATE = 0.0005 # Taker手续费 0.05%(开仓+平仓各一次)
def paper_open_trade(
symbol: str,
direction: str,
price: float,
score: int,
tier: str,
atr: float,
now_ms: int,
factors: dict = None,
strategy: str = "v51_baseline",
tp_sl: Optional[dict] = None,
strategy_id: Optional[str] = None,
strategy_name_snapshot: Optional[str] = None,
logger=None,
):
"""模拟开仓:写入 paper_trades"""
import json as _json3
if atr <= 0:
return
tp_sl_cfg = tp_sl or {}
sl_multiplier = float(tp_sl_cfg.get("sl_multiplier", 2.0))
# 支持两种配置方式:
# - 新版 v5.4 strategies 表tp1_ratio / tp2_ratio = 以 R 计的目标(× risk_distance
# - 旧版 v5.2/v5.3 JSON 策略tp1_multiplier / tp2_multiplier = 以 ATR 计的目标
tp1_ratio = tp_sl_cfg.get("tp1_ratio")
tp2_ratio = tp_sl_cfg.get("tp2_ratio")
use_r_based = tp1_ratio is not None and tp2_ratio is not None
if use_r_based:
tp1_ratio = float(tp1_ratio)
tp2_ratio = float(tp2_ratio)
else:
tp1_multiplier = float(tp_sl_cfg.get("tp1_multiplier", 1.5))
tp2_multiplier = float(tp_sl_cfg.get("tp2_multiplier", 3.0))
# 统一定义1R = SL 距离 = sl_multiplier × ATR
risk_distance = sl_multiplier * atr
if direction == "LONG":
sl = price - risk_distance
if use_r_based:
tp1 = price + tp1_ratio * risk_distance
tp2 = price + tp2_ratio * risk_distance
else:
tp1 = price + tp1_multiplier * atr
tp2 = price + tp2_multiplier * atr
else:
sl = price + risk_distance
if use_r_based:
tp1 = price - tp1_ratio * risk_distance
tp2 = price - tp2_ratio * risk_distance
else:
tp1 = price - tp1_multiplier * atr
tp2 = price - tp2_multiplier * atr
# SL 合理性校验:实际距离必须在 risk_distance 的 80%~120% 范围内
actual_sl_dist = abs(sl - price)
if actual_sl_dist < risk_distance * 0.8 or actual_sl_dist > risk_distance * 1.2:
if logger:
logger.error(
f"[{symbol}] ⚠️ SL校验失败拒绝开仓: direction={direction} price={price:.4f} "
f"sl={sl:.4f} actual_dist={actual_sl_dist:.4f} expected={risk_distance:.4f} atr={atr:.4f}"
)
return
with get_sync_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO paper_trades (symbol,direction,score,tier,entry_price,entry_ts,tp1_price,tp2_price,sl_price,atr_at_entry,score_factors,strategy,risk_distance,strategy_id,strategy_name_snapshot) "
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)",
(
symbol,
direction,
score,
tier,
price,
now_ms,
tp1,
tp2,
sl,
atr,
_json3.dumps(factors) if factors else None,
strategy,
risk_distance,
strategy_id,
strategy_name_snapshot,
),
)
conn.commit()
if logger:
logger.info(
f"[{symbol}] 📝 模拟开仓: {direction} @ {price:.2f} score={score} tier={tier} strategy={strategy} "
f"TP1={tp1:.2f} TP2={tp2:.2f} SL={sl:.2f}"
)
def paper_has_active_position(symbol: str, strategy: Optional[str] = None) -> bool:
"""检查该币种是否有活跃持仓"""
with get_sync_conn() as conn:
with conn.cursor() as cur:
if strategy:
cur.execute(
"SELECT COUNT(*) FROM paper_trades WHERE symbol=%s AND strategy=%s AND status IN ('active','tp1_hit')",
(symbol, strategy),
)
else:
cur.execute(
"SELECT COUNT(*) FROM paper_trades WHERE symbol=%s AND status IN ('active','tp1_hit')",
(symbol,),
)
return cur.fetchone()[0] > 0
def paper_get_active_direction(symbol: str, strategy: Optional[str] = None) -> str | None:
"""获取该币种活跃持仓的方向,无持仓返回 None"""
with get_sync_conn() as conn:
with conn.cursor() as cur:
if strategy:
cur.execute(
"SELECT direction FROM paper_trades WHERE symbol=%s AND strategy=%s AND status IN ('active','tp1_hit') LIMIT 1",
(symbol, strategy),
)
else:
cur.execute(
"SELECT direction FROM paper_trades WHERE symbol=%s AND status IN ('active','tp1_hit') LIMIT 1",
(symbol,),
)
row = cur.fetchone()
return row[0] if row else None
def paper_close_by_signal(symbol: str, current_price: float, now_ms: int, strategy: Optional[str] = None, logger=None):
"""反向信号平仓:按当前价平掉该币种所有活跃仓位"""
with get_sync_conn() as conn:
with conn.cursor() as cur:
if strategy:
cur.execute(
"SELECT id, direction, entry_price, tp1_hit, atr_at_entry, risk_distance "
"FROM paper_trades WHERE symbol=%s AND strategy=%s AND status IN ('active','tp1_hit')",
(symbol, strategy),
)
else:
cur.execute(
"SELECT id, direction, entry_price, tp1_hit, atr_at_entry, risk_distance "
"FROM paper_trades WHERE symbol=%s AND status IN ('active','tp1_hit')",
(symbol,),
)
positions = cur.fetchall()
for pos in positions:
pid, direction, entry_price, tp1_hit, atr_entry, rd_db = pos
risk_distance = rd_db if rd_db and rd_db > 0 else abs(entry_price * 0.01)
if direction == "LONG":
pnl_r = (current_price - entry_price) / risk_distance if risk_distance > 0 else 0
else:
pnl_r = (entry_price - current_price) / risk_distance if risk_distance > 0 else 0
# 扣手续费
fee_r = (2 * PAPER_FEE_RATE * entry_price) / risk_distance if risk_distance > 0 else 0
pnl_r -= fee_r
cur.execute(
"UPDATE paper_trades SET status='signal_flip', exit_price=%s, exit_ts=%s, pnl_r=%s WHERE id=%s",
(current_price, now_ms, round(pnl_r, 4), pid),
)
if logger:
logger.info(
f"[{symbol}] 📝 反向信号平仓: {direction} @ {current_price:.2f} pnl={pnl_r:+.2f}R"
f"{f' strategy={strategy}' if strategy else ''}"
)
conn.commit()
def paper_active_count(strategy: Optional[str] = None) -> int:
"""当前活跃持仓总数(按策略独立计数)"""
with get_sync_conn() as conn:
with conn.cursor() as cur:
if strategy:
cur.execute(
"SELECT COUNT(*) FROM paper_trades WHERE strategy=%s AND status IN ('active','tp1_hit')",
(strategy,),
)
else:
cur.execute(
"SELECT COUNT(*) FROM paper_trades WHERE status IN ('active','tp1_hit')"
)
return cur.fetchone()[0]

View File

@ -40,7 +40,7 @@ if not _DB_PASSWORD:
sys.exit(1) sys.exit(1)
DB_CONFIG = { DB_CONFIG = {
"host": os.getenv("DB_HOST", "127.0.0.1"), "host": os.getenv("DB_HOST", "10.106.0.3"),
"port": int(os.getenv("DB_PORT", "5432")), "port": int(os.getenv("DB_PORT", "5432")),
"dbname": os.getenv("DB_NAME", "arb_engine"), "dbname": os.getenv("DB_NAME", "arb_engine"),
"user": os.getenv("DB_USER", "arb"), "user": os.getenv("DB_USER", "arb"),

View File

@ -3,5 +3,3 @@ uvicorn
httpx httpx
python-dotenv python-dotenv
psutil psutil
asyncpg
psycopg2-binary

View File

@ -47,7 +47,7 @@ if not _DB_PASSWORD:
sys.exit(1) sys.exit(1)
DB_CONFIG = { DB_CONFIG = {
"host": os.getenv("DB_HOST", "127.0.0.1"), "host": os.getenv("DB_HOST", "10.106.0.3"),
"port": int(os.getenv("DB_PORT", "5432")), "port": int(os.getenv("DB_PORT", "5432")),
"dbname": os.getenv("DB_NAME", "arb_engine"), "dbname": os.getenv("DB_NAME", "arb_engine"),
"user": os.getenv("DB_USER", "arb"), "user": os.getenv("DB_USER", "arb"),

File diff suppressed because it is too large Load Diff

View File

@ -1,298 +0,0 @@
"""
signal_state.py CVD/ATR 滚动窗口与 SymbolState 抽象
从原来的 signal_engine.py 中拆分出的纯数据结构与计算逻辑
- TradeWindow逐笔成交滚动窗口负责 CVD/VWAP 计算
- ATRCalculator按固定周期聚合 K 线并计算 ATR/百分位
- get_max_fr资金费率历史最大值缓存
- SymbolState单币种的内存状态窗口指标快照巨鲸数据等
这些类保持与原实现完全一致只是搬迁到独立模块便于维护与测试
"""
import time
from collections import deque
from typing import Any, Optional
from db import get_sync_conn
def to_float(value: Any) -> Optional[float]:
try:
return float(value) if value is not None else None
except (TypeError, ValueError):
return None
class TradeWindow:
def __init__(self, window_ms: int):
self.window_ms = window_ms
self.trades: deque = deque()
self.buy_vol = 0.0
self.sell_vol = 0.0
self.pq_sum = 0.0
self.q_sum = 0.0
def add(self, time_ms: int, qty: float, price: float, is_buyer_maker: int):
self.trades.append((time_ms, qty, price, is_buyer_maker))
pq = price * qty
self.pq_sum += pq
self.q_sum += qty
if is_buyer_maker == 0:
self.buy_vol += qty
else:
self.sell_vol += qty
def trim(self, now_ms: int):
cutoff = now_ms - self.window_ms
while self.trades and self.trades[0][0] < cutoff:
t_ms, qty, price, ibm = self.trades.popleft()
self.pq_sum -= price * qty
self.q_sum -= qty
if ibm == 0:
self.buy_vol -= qty
else:
self.sell_vol -= qty
@property
def cvd(self) -> float:
return self.buy_vol - self.sell_vol
@property
def vwap(self) -> float:
return self.pq_sum / self.q_sum if self.q_sum > 0 else 0.0
class ATRCalculator:
def __init__(self, period_ms: int, length: int):
self.period_ms = period_ms
self.length = length
self.candles: deque = deque(maxlen=length + 1)
self.current_candle: Optional[dict] = None
self.atr_history: deque = deque(maxlen=288)
def update(self, time_ms: int, price: float):
bar_ms = (time_ms // self.period_ms) * self.period_ms
if self.current_candle is None or self.current_candle["bar"] != bar_ms:
if self.current_candle is not None:
self.candles.append(self.current_candle)
self.current_candle = {
"bar": bar_ms,
"open": price,
"high": price,
"low": price,
"close": price,
}
else:
c = self.current_candle
c["high"] = max(c["high"], price)
c["low"] = min(c["low"], price)
c["close"] = price
@property
def atr(self) -> float:
if len(self.candles) < 2:
return 0.0
trs = []
candles_list = list(self.candles)
for i in range(1, len(candles_list)):
prev_close = candles_list[i - 1]["close"]
c = candles_list[i]
tr = max(
c["high"] - c["low"],
abs(c["high"] - prev_close),
abs(c["low"] - prev_close),
)
trs.append(tr)
if not trs:
return 0.0
atr_val = trs[0]
for tr in trs[1:]:
atr_val = (atr_val * (self.length - 1) + tr) / self.length
return atr_val
@property
def atr_percentile(self) -> float:
current = self.atr
if current == 0:
return 50.0
self.atr_history.append(current)
if len(self.atr_history) < 10:
return 50.0
sorted_hist = sorted(self.atr_history)
rank = sum(1 for x in sorted_hist if x <= current)
return (rank / len(sorted_hist)) * 100
# ─── FR 历史最大值缓存(每小时更新)───────────────────────────────
_max_fr_cache: dict = {} # {symbol: max_abs_fr}
_max_fr_updated: float = 0
def get_max_fr(symbol: str) -> float:
"""获取该币种历史最大|FR|,每小时刷新一次"""
global _max_fr_cache, _max_fr_updated
now = time.time()
if now - _max_fr_updated > 3600 or symbol not in _max_fr_cache:
try:
with get_sync_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT symbol, MAX(ABS((value->>'fundingRate')::float)) as max_fr "
"FROM market_indicators WHERE indicator_type='funding_rate' "
"GROUP BY symbol"
)
for row in cur.fetchall():
_max_fr_cache[row[0]] = row[1] if row[1] else 0.0001
_max_fr_updated = now
except Exception:
# 读取失败时保持旧缓存,返回一个小的默认值防除零
pass
return _max_fr_cache.get(symbol, 0.0001) # 默认0.01%防除零
class SymbolState:
def __init__(
self,
symbol: str,
window_fast_ms: int,
window_mid_ms: int,
window_day_ms: int,
window_vwap_ms: int,
atr_period_ms: int,
atr_length: int,
fetch_market_indicators_fn,
):
self.symbol = symbol
self.win_fast = TradeWindow(window_fast_ms)
self.win_mid = TradeWindow(window_mid_ms)
self.win_day = TradeWindow(window_day_ms)
self.win_vwap = TradeWindow(window_vwap_ms)
self.atr_calc = ATRCalculator(atr_period_ms, atr_length)
self.last_processed_id = 0
self.last_trade_price = 0.0
self.warmup = True
self.prev_cvd_fast = 0.0
self.prev_cvd_fast_slope = 0.0
self.prev_oi_value = 0.0
# 从外部函数获取最新 market_indicators
self.market_indicators = fetch_market_indicators_fn(symbol)
self.last_signal_ts: dict[str, int] = {}
self.last_signal_dir: dict[str, str] = {}
self.recent_large_trades: deque = deque()
# ── Phase 2 实时内存字段(由后台 WebSocket 协程更新)──────────
self.rt_obi: float = 0.0
self.rt_spot_perp_div: float = 0.0
# tiered_cvd_whale按成交额分档实时累计最近15分钟窗口
self._whale_trades: deque = deque() # (time_ms, usd_val, is_sell)
self.WHALE_WINDOW_MS: int = 15 * 60 * 1000 # 15 分钟
def process_trade(self, agg_id: int, time_ms: int, price: float, qty: float, is_buyer_maker: int):
now_ms = time_ms
self.win_fast.add(time_ms, qty, price, is_buyer_maker)
self.win_mid.add(time_ms, qty, price, is_buyer_maker)
self.win_day.add(time_ms, qty, price, is_buyer_maker)
self.win_vwap.add(time_ms, qty, price, is_buyer_maker)
self.atr_calc.update(time_ms, price)
self.win_fast.trim(now_ms)
self.win_mid.trim(now_ms)
self.win_day.trim(now_ms)
self.win_vwap.trim(now_ms)
self.last_processed_id = agg_id
self.last_trade_price = price # 最新成交价,用于 entry_price
# tiered_cvd_whale 实时累计(>$100k 为巨鲸)
usd_val = price * qty
if usd_val >= 100_000:
self._whale_trades.append((time_ms, usd_val, bool(is_buyer_maker)))
# 修剪 15 分钟窗口
cutoff = now_ms - self.WHALE_WINDOW_MS
while self._whale_trades and self._whale_trades[0][0] < cutoff:
self._whale_trades.popleft()
@property
def whale_cvd_ratio(self) -> float:
"""巨鲸净 CVD 比率[-1,1],基于最近 15 分钟 >$100k 成交"""
buy_usd = sum(t[1] for t in self._whale_trades if not t[2])
sell_usd = sum(t[1] for t in self._whale_trades if t[2])
total = buy_usd + sell_usd
return (buy_usd - sell_usd) / total if total > 0 else 0.0
def compute_p95_p99(self) -> tuple:
if len(self.win_day.trades) < 100:
return 5.0, 10.0
qtys = sorted([t[1] for t in self.win_day.trades])
n = len(qtys)
p95 = qtys[int(n * 0.95)]
p99 = qtys[int(n * 0.99)]
if "BTC" in self.symbol:
p95 = max(p95, 5.0)
p99 = max(p99, 10.0)
else:
p95 = max(p95, 50.0)
p99 = max(p99, 100.0)
return p95, p99
def update_large_trades(self, now_ms: int, p99: float):
cutoff = now_ms - 15 * 60 * 1000
while self.recent_large_trades and self.recent_large_trades[0][0] < cutoff:
self.recent_large_trades.popleft()
# 只检查新 trade避免重复添加
seen = set(t[0] for t in self.recent_large_trades) # time_ms 作为去重 key
for t in self.win_fast.trades:
if t[1] >= p99 and t[0] > cutoff and t[0] not in seen:
self.recent_large_trades.append((t[0], t[1], t[3]))
seen.add(t[0])
def build_evaluation_snapshot(self, now_ms: int) -> dict:
cvd_fast = self.win_fast.cvd
cvd_mid = self.win_mid.cvd
cvd_day = self.win_day.cvd
vwap = self.win_vwap.vwap
atr = self.atr_calc.atr
atr_pct = self.atr_calc.atr_percentile
p95, p99 = self.compute_p95_p99()
self.update_large_trades(now_ms, p99)
price = self.last_trade_price if self.last_trade_price > 0 else vwap # 用最新成交价,非 VWAP
cvd_fast_slope = cvd_fast - self.prev_cvd_fast
cvd_fast_accel = cvd_fast_slope - self.prev_cvd_fast_slope
self.prev_cvd_fast = cvd_fast
self.prev_cvd_fast_slope = cvd_fast_slope
oi_value = to_float(self.market_indicators.get("open_interest_hist"))
if oi_value is None or self.prev_oi_value == 0:
oi_change = 0.0
environment_score = 10
else:
oi_change = (
(oi_value - self.prev_oi_value) / self.prev_oi_value
if self.prev_oi_value > 0
else 0.0
)
if oi_change >= 0.03:
environment_score = 15
elif oi_change > 0:
environment_score = 10
else:
environment_score = 5
if oi_value is not None and oi_value > 0:
self.prev_oi_value = oi_value
return {
"cvd_fast": cvd_fast,
"cvd_mid": cvd_mid,
"cvd_day": cvd_day,
"vwap": vwap,
"atr": atr,
"atr_value": atr,
"atr_pct": atr_pct,
"p95": p95,
"p99": p99,
"price": price,
"cvd_fast_slope": cvd_fast_slope,
"cvd_fast_accel": cvd_fast_accel,
"oi_change": oi_change,
"environment_score": environment_score,
"oi_value": oi_value,
}

View File

@ -1,190 +0,0 @@
"""
strategy_loader.py JSON 文件 / DB 加载策略配置
从原来的 signal_engine.py 拆分出的策略加载逻辑
- load_strategy_configs(): backend/strategies/*.json 读取配置
- load_strategy_configs_from_db(): strategies 表读取 running 策略并映射到 cfg dict
行为保持与原实现完全一致用于给 signal_engine 等调用方复用
"""
import json
import logging
import os
from typing import Any
from db import get_sync_conn
logger = logging.getLogger("strategy-loader")
STRATEGY_DIR = os.path.join(os.path.dirname(__file__), "strategies")
DEFAULT_STRATEGY_FILES = [
# 仅保留 V5.3 系列作为本地默认策略
"v53.json",
"v53_fast.json",
"v53_middle.json",
]
def load_strategy_configs() -> list[dict]:
"""从本地 JSON 文件加载默认策略配置"""
configs: list[dict[str, Any]] = []
for filename in DEFAULT_STRATEGY_FILES:
path = os.path.join(STRATEGY_DIR, filename)
try:
with open(path, "r", encoding="utf-8") as f:
cfg = json.load(f)
if isinstance(cfg, dict) and cfg.get("name"):
configs.append(cfg)
except FileNotFoundError:
logger.warning(f"策略配置缺失: {path}")
except Exception as e:
logger.error(f"策略配置加载失败 {path}: {e}")
if not configs:
logger.warning("未加载到策略配置,回退到 v53 默认配置")
configs.append(
{
"name": "v53",
"threshold": 75,
"flip_threshold": 85,
"tp_sl": {
"sl_multiplier": 2.0,
"tp1_multiplier": 1.5,
"tp2_multiplier": 3.0,
},
# 默认支持四个主交易对其他细节gates/symbol_gates
# 在 evaluate_factory_strategy 内部有安全的默认值。
"symbols": ["BTCUSDT", "ETHUSDT", "XRPUSDT", "SOLUSDT"],
}
)
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: list[dict[str, Any]] = []
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]}")
cfg: dict[str, Any] = {
"name": strategy_name,
"strategy_id": sid, # V5.4 新增:用于写 strategy_id 到 DB
"strategy_name_snapshot": display_name,
"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": {
"vol": {
"enabled": gate_vol,
"vol_atr_pct_min": float(vol_atr_pct or 0.002),
},
"cvd": {"enabled": gate_cvd},
"whale": {
"enabled": gate_whale,
"whale_usd_threshold": float(whale_usd_thr or 50000),
"whale_flow_pct": float(whale_flow_pct_val or 0.5),
},
"obi": {
"enabled": gate_obi,
"threshold": float(obi_thr or 0.35),
},
"spot_perp": {
"enabled": gate_spot,
"threshold": float(spot_thr or 0.005),
},
},
"tp_sl": {
# V5.4: 统一采用“以 R 计”的配置:
# risk_distance = sl_atr_multiplier × ATR = 1R
# TP1 = entry ± tp1_ratio × risk_distance
# TP2 = entry ± tp2_ratio × risk_distance
"sl_multiplier": sl_mult,
"tp1_ratio": tp1_r,
"tp2_ratio": 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 []

View File

@ -1,564 +0,0 @@
"""
strategy_scoring.py V5 策略工厂统一评分逻辑
从原来的 signal_engine.py 中拆分出的 V5 评分与 Gate 逻辑
- evaluate_factory_strategy(state, now_ms, strategy_cfg, snapshot)
单条策略含工厂产出的 custom_*的核心评分逻辑
- evaluate_signal(state, now_ms, strategy_cfg, snapshot)
对外统一入口仅支持 v53*/custom_*
signal_engine / backtest 调用V5.1/V5.2v51_baseline / v52_8signals
已在此模块中下线若仍传入旧策略名将返回空结果并打印 warning
"""
import logging
from typing import Any, Optional
from signal_state import SymbolState, to_float
logger = logging.getLogger("strategy-scoring")
def evaluate_factory_strategy(
state: SymbolState,
now_ms: int,
strategy_cfg: dict,
snapshot: Optional[dict] = None,
) -> dict:
"""
V5 策略工厂统一评分BTC/ETH/XRP/SOL + custom_*
- 输入动态 CVD 窗口 + 五门参数 + OI/拥挤/辅助指标
- 输出score / signal / tier + 详细 factors
- 支持单币种策略symbol方向限制long_only/short_only/both自定义四层权重
"""
strategy_name = strategy_cfg.get("name", "v53")
strategy_threshold = int(strategy_cfg.get("threshold", 75))
flip_threshold = int(strategy_cfg.get("flip_threshold", 85))
# per-strategy 方向约束long_only / short_only / both
dir_cfg_raw = (strategy_cfg.get("direction") or "both").lower()
# 兼容策略工厂的 long_only / short_only 配置
if dir_cfg_raw in ("long_only", "only_long"):
dir_cfg_raw = "long"
elif dir_cfg_raw in ("short_only", "only_short"):
dir_cfg_raw = "short"
if dir_cfg_raw not in ("long", "short", "both"):
dir_cfg_raw = "both"
snap = snapshot or state.build_evaluation_snapshot(now_ms)
# 按策略配置的 cvd_fast_window / cvd_slow_window 动态切片重算 CVD
cvd_fast_window = strategy_cfg.get("cvd_fast_window", "30m")
cvd_slow_window = strategy_cfg.get("cvd_slow_window", "4h")
def _window_ms(code: str) -> int:
if not isinstance(code, str) or len(code) < 2:
return 30 * 60 * 1000
unit = code[-1]
try:
val = int(code[:-1])
except ValueError:
return 30 * 60 * 1000
if unit == "m":
return val * 60 * 1000
if unit == "h":
return val * 3600 * 1000
return 30 * 60 * 1000
fast_ms = _window_ms(cvd_fast_window)
slow_ms = _window_ms(cvd_slow_window)
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_slow = now_ms - slow_ms
buy_f = sell_f = buy_m = sell_m = 0.0
src_fast = state.win_mid if fast_ms > state.win_fast.window_ms else state.win_fast
for t_ms, qty, _price, ibm in src_fast.trades:
if t_ms >= cutoff_fast:
if ibm == 0:
buy_f += qty
else:
sell_f += qty
for t_ms, qty, _price, ibm in state.win_mid.trades:
if t_ms >= cutoff_slow:
if ibm == 0:
buy_m += qty
else:
sell_m += qty
cvd_fast = buy_f - sell_f
cvd_mid = buy_m - sell_m
price = snap["price"]
atr = snap["atr"]
atr_value = snap.get("atr_value", atr)
cvd_fast_accel = snap["cvd_fast_accel"]
environment_score_raw = snap["environment_score"]
# 默认 result 零值(基础字段从 snapshot 填充,以兼容 save_indicator/save_feature_event
# 注意cvd_fast/cvd_mid 会在后面覆盖为「按策略窗口重算」后的值,
# 这里先用 snapshot 保证字段存在。
result = {
"strategy": strategy_name,
"cvd_fast": snap["cvd_fast"],
"cvd_mid": snap["cvd_mid"],
"cvd_day": snap["cvd_day"],
"cvd_fast_slope": snap["cvd_fast_slope"],
"atr": atr,
"atr_value": atr_value,
"atr_pct": snap["atr_pct"],
"vwap": snap["vwap"],
"price": price,
"p95": snap["p95"],
"p99": snap["p99"],
"signal": None,
"direction": None,
"score": 0.0,
"tier": None,
"factors": {},
}
if state.warmup or price == 0 or atr == 0:
return result
last_signal_ts = state.last_signal_ts.get(strategy_name, 0)
COOLDOWN_MS = 10 * 60 * 1000
in_cooldown = now_ms - last_signal_ts < COOLDOWN_MS
# ── 五门参数:优先读 DB configV5.4fallback 到 JSON symbol_gates ────
db_gates = strategy_cfg.get("gates") or {}
symbol_gates = (strategy_cfg.get("symbol_gates") or {}).get(state.symbol, {})
gate_vol_enabled = db_gates.get("vol", {}).get("enabled", True)
min_vol = float(
db_gates.get("vol", {}).get("vol_atr_pct_min")
or symbol_gates.get("min_vol_threshold", 0.002)
)
gate_cvd_enabled = db_gates.get("cvd", {}).get("enabled", True)
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)
)
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)
)
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)
)
# 覆盖为按策略窗口重算后的 CVD用于 signal_indicators 展示)
result["cvd_fast"] = cvd_fast
result["cvd_mid"] = cvd_mid
gate_block = None
# 门1波动率下限可关闭
atr_pct_price = atr / price if price > 0 else 0
# 市场状态(供复盘/优化使用,不直接改变默认策略行为)
regime = "range"
if atr_pct_price >= 0.012:
regime = "crash"
elif atr_pct_price >= 0.008:
regime = "high_vol"
elif abs(cvd_fast_accel) > 0 and abs(cvd_fast) > 0 and abs(cvd_mid) > 0:
same_dir = (cvd_fast > 0 and cvd_mid > 0) or (cvd_fast < 0 and cvd_mid < 0)
if same_dir and abs(cvd_fast_accel) > 10:
regime = "trend"
if gate_vol_enabled and atr_pct_price < min_vol:
gate_block = f"low_vol({atr_pct_price:.4f}<{min_vol})"
# 门2CVD 共振(方向门,可关闭)
no_direction = False
cvd_resonance = 0
if cvd_fast > 0 and cvd_mid > 0:
direction = "LONG"
cvd_resonance = 30
no_direction = False
elif cvd_fast < 0 and cvd_mid < 0:
direction = "SHORT"
cvd_resonance = 30
no_direction = False
else:
direction = "LONG" if cvd_fast > 0 else "SHORT"
cvd_resonance = 0
if gate_cvd_enabled:
no_direction = True
if not gate_block:
gate_block = "no_direction_consensus"
else:
no_direction = False
# per-strategy 方向限制long/short 仅限制开仓方向,不影响评分与指标快照
if dir_cfg_raw == "long" and direction == "SHORT":
strategy_direction_allowed = False
elif dir_cfg_raw == "short" and direction == "LONG":
strategy_direction_allowed = False
else:
strategy_direction_allowed = True
# 门3鲸鱼否决BTC 用 whale_cvd_ratioALT 用大单对立,可关闭)
if gate_whale_enabled and not gate_block and not no_direction:
if state.symbol == "BTCUSDT":
whale_cvd = (
state.whale_cvd_ratio
if state._whale_trades
else to_float(state.market_indicators.get("tiered_cvd_whale")) or 0.0
)
if (direction == "LONG" and whale_cvd < -whale_flow_pct) or (
direction == "SHORT" and whale_cvd > whale_flow_pct
):
gate_block = f"whale_cvd_veto({whale_cvd:.3f})"
else:
whale_adverse = any(
(direction == "LONG" and lt[2] == 1 and lt[1] * price >= whale_usd)
or (direction == "SHORT" and lt[2] == 0 and lt[1] * price >= whale_usd)
for lt in state.recent_large_trades
)
whale_aligned = any(
(direction == "LONG" and lt[2] == 0 and lt[1] * price >= whale_usd)
or (direction == "SHORT" and lt[2] == 1 and lt[1] * price >= whale_usd)
for lt in state.recent_large_trades
)
if whale_adverse and not whale_aligned:
gate_block = f"whale_adverse(>${whale_usd/1000:.0f}k)"
# 门4OBI 否决(实时 WS 优先fallback DB可关闭
obi_raw = state.rt_obi if state.rt_obi != 0.0 else to_float(
state.market_indicators.get("obi_depth_10")
)
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:
gate_block = f"obi_veto({obi_raw:.3f}<-{obi_veto})"
elif direction == "SHORT" and obi_raw > obi_veto:
gate_block = f"obi_veto({obi_raw:.3f}>{obi_veto})"
# 门5期现背离否决实时 WS 优先fallback DB可关闭
spot_perp_div = (
state.rt_spot_perp_div if state.rt_spot_perp_div != 0.0 else to_float(
state.market_indicators.get("spot_perp_divergence")
)
)
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 (
direction == "SHORT" and spot_perp_div > spd_veto
):
gate_block = f"spd_veto({spot_perp_div:.4f})"
gate_passed = gate_block is None
# ── Direction Layer55 分,原始尺度)───────────────────────
has_adverse_p99 = any(
(direction == "LONG" and lt[2] == 1) or (direction == "SHORT" and lt[2] == 0)
for lt in state.recent_large_trades
)
has_aligned_p99 = any(
(direction == "LONG" and lt[2] == 0) or (direction == "SHORT" and lt[2] == 1)
for lt in state.recent_large_trades
)
p99_flow = 20 if has_aligned_p99 else (10 if not has_adverse_p99 else 0)
accel_bonus = 5 if (
(direction == "LONG" and cvd_fast_accel > 0) or
(direction == "SHORT" and cvd_fast_accel < 0)
) else 0
# v53_fastaccel 独立触发路径(不要求 cvd 双线同向)
is_fast = strategy_name.endswith("fast")
accel_independent_score = 0
if is_fast and not no_direction:
accel_cfg = strategy_cfg.get("accel_independent", {})
if accel_cfg.get("enabled", False):
accel_strong = (
(direction == "LONG" and cvd_fast_accel > 0 and has_aligned_p99)
or (direction == "SHORT" and cvd_fast_accel < 0 and has_aligned_p99)
)
if accel_strong:
accel_independent_score = int(
accel_cfg.get("min_direction_score", 35)
)
direction_score = max(
min(cvd_resonance + p99_flow + accel_bonus, 55),
accel_independent_score,
)
# ── Crowding Layer25 分,原始尺度)───────────────────────
long_short_ratio = to_float(state.market_indicators.get("long_short_ratio"))
if long_short_ratio is None:
ls_score = 7
elif (direction == "SHORT" and long_short_ratio > 2.0) or (
direction == "LONG" and long_short_ratio < 0.5
):
ls_score = 15
elif (direction == "SHORT" and long_short_ratio > 1.5) or (
direction == "LONG" and long_short_ratio < 0.7
):
ls_score = 10
elif (direction == "SHORT" and long_short_ratio > 1.0) or (
direction == "LONG" and long_short_ratio < 1.0
):
ls_score = 7
else:
ls_score = 0
top_trader_position = to_float(state.market_indicators.get("top_trader_position"))
if top_trader_position is None:
top_trader_score = 5
else:
if direction == "LONG":
top_trader_score = (
10
if top_trader_position >= 0.55
else (0 if top_trader_position <= 0.45 else 5)
)
else:
top_trader_score = (
10
if top_trader_position <= 0.45
else (0 if top_trader_position >= 0.55 else 5)
)
crowding_score = min(ls_score + top_trader_score, 25)
# ── Environment Layer15 分,原始尺度)────────────────────
oi_base_score = round(environment_score_raw / 15 * 10)
obi_raw = state.rt_obi if state.rt_obi != 0.0 else to_float(
state.market_indicators.get("obi_depth_10")
)
obi_bonus = 0
if is_fast and obi_raw is not None:
obi_cfg = strategy_cfg.get("obi_scoring", {})
strong_thr = float(obi_cfg.get("strong_threshold", 0.30))
weak_thr = float(obi_cfg.get("weak_threshold", 0.15))
strong_sc = int(obi_cfg.get("strong_score", 5))
weak_sc = int(obi_cfg.get("weak_score", 3))
obi_aligned = (direction == "LONG" and obi_raw > 0) or (
direction == "SHORT" and obi_raw < 0
)
obi_abs = abs(obi_raw)
if obi_aligned:
if obi_abs >= strong_thr:
obi_bonus = strong_sc
elif obi_abs >= weak_thr:
obi_bonus = weak_sc
environment_score = (
min(oi_base_score + obi_bonus, 15)
if is_fast
else round(environment_score_raw / 15 * 15)
)
# ── Auxiliary Layer5 分,原始尺度)──────────────────────
coinbase_premium = to_float(state.market_indicators.get("coinbase_premium"))
if coinbase_premium is None:
aux_score = 2
elif (
(direction == "LONG" and coinbase_premium > 0.0005)
or (direction == "SHORT" and coinbase_premium < -0.0005)
):
aux_score = 5
elif abs(coinbase_premium) <= 0.0005:
aux_score = 2
else:
aux_score = 0
# ── 根据策略权重缩放四层分数direction/env/aux/momentum────
weights_cfg = strategy_cfg.get("weights") or {}
w_dir = float(weights_cfg.get("direction", 55))
w_env = float(weights_cfg.get("env", 25))
w_aux = float(weights_cfg.get("aux", 15))
w_mom = float(weights_cfg.get("momentum", 5))
total_w = w_dir + w_env + w_aux + w_mom
if total_w <= 0:
# Fallback 到默认 55/25/15/5
w_dir, w_env, w_aux, w_mom = 55.0, 25.0, 15.0, 5.0
total_w = 100.0
# 归一化到 100 分制
norm = 100.0 / total_w
w_dir_eff = (w_dir + w_mom) * norm # 动量权重并入方向层
w_env_eff = w_env * norm
w_aux_eff = w_aux * norm
# 原始最大值direction 55 + crowding 25 = 80
DIR_RAW_MAX = 55.0
CROWD_RAW_MAX = 25.0
ENV_RAW_MAX = 15.0
AUX_RAW_MAX = 5.0
DIR_PLUS_CROWD_RAW_MAX = DIR_RAW_MAX + CROWD_RAW_MAX
# 把方向+拥挤总权重按 55:25 拆分
dir_max_scaled = w_dir_eff * (DIR_RAW_MAX / DIR_PLUS_CROWD_RAW_MAX)
crowd_max_scaled = w_dir_eff * (CROWD_RAW_MAX / DIR_PLUS_CROWD_RAW_MAX)
env_max_scaled = w_env_eff
aux_max_scaled = w_aux_eff
# 按原始分数比例缩放到新的权重上
def _scale(raw_score: float, raw_max: float, scaled_max: float) -> float:
if raw_max <= 0 or scaled_max <= 0:
return 0.0
return min(max(raw_score, 0) / raw_max * scaled_max, scaled_max)
direction_score_scaled = _scale(direction_score, DIR_RAW_MAX, dir_max_scaled)
crowding_score_scaled = _scale(crowding_score, CROWD_RAW_MAX, crowd_max_scaled)
environment_score_scaled = _scale(environment_score, ENV_RAW_MAX, env_max_scaled)
aux_score_scaled = _scale(aux_score, AUX_RAW_MAX, aux_max_scaled)
total_score = min(
direction_score_scaled
+ crowding_score_scaled
+ environment_score_scaled
+ aux_score_scaled,
100,
)
total_score = max(0, round(total_score, 1))
if not gate_passed:
total_score = 0
whale_cvd_display = (
state.whale_cvd_ratio
if state._whale_trades
else to_float(state.market_indicators.get("tiered_cvd_whale"))
) if state.symbol == "BTCUSDT" else None
result.update(
{
"score": total_score,
"direction": direction if (not no_direction and gate_passed) else None,
"atr_value": atr_value,
"cvd_fast_5m": cvd_fast if is_fast else None,
"factors": {
"track": "BTC" if state.symbol == "BTCUSDT" else "ALT",
"regime": regime,
"gate_passed": gate_passed,
"gate_block": gate_block,
"atr_pct_price": round(atr_pct_price, 5),
"obi_raw": obi_raw,
"spot_perp_div": spot_perp_div,
"whale_cvd_ratio": whale_cvd_display,
"direction": {
"score": round(direction_score_scaled, 2),
"max": round(dir_max_scaled, 2),
"raw_score": direction_score,
"raw_max": DIR_RAW_MAX,
"cvd_resonance": cvd_resonance,
"p99_flow": p99_flow,
"accel_bonus": accel_bonus,
},
"crowding": {
"score": round(crowding_score_scaled, 2),
"max": round(crowd_max_scaled, 2),
"raw_score": crowding_score,
"raw_max": CROWD_RAW_MAX,
},
"environment": {
"score": round(environment_score_scaled, 2),
"max": round(env_max_scaled, 2),
"raw_score": environment_score,
"raw_max": ENV_RAW_MAX,
"oi_change": snap["oi_change"],
},
"auxiliary": {
"score": round(aux_score_scaled, 2),
"max": round(aux_max_scaled, 2),
"raw_score": aux_score,
"raw_max": AUX_RAW_MAX,
"coinbase_premium": coinbase_premium,
},
},
}
)
# 赋值 tier/signal和原逻辑一致
if total_score >= strategy_threshold and gate_passed and strategy_direction_allowed:
result["signal"] = direction
# tier 简化score >= flip_threshold → heavy否则 standard
result["tier"] = "heavy" if total_score >= flip_threshold else "standard"
state.last_signal_ts[strategy_name] = now_ms
state.last_signal_dir[strategy_name] = direction
return result
def _empty_result(strategy_name: str, snap: dict) -> dict:
"""返回空评分结果symbol 不匹配 / 无信号时使用)"""
return {
"strategy": strategy_name,
"cvd_fast": snap["cvd_fast"],
"cvd_mid": snap["cvd_mid"],
"cvd_day": snap["cvd_day"],
"cvd_fast_slope": snap["cvd_fast_slope"],
"atr": snap["atr"],
"atr_value": snap.get("atr_value", snap["atr"]),
"atr_pct": snap["atr_pct"],
"vwap": snap["vwap"],
"price": snap["price"],
"p95": snap["p95"],
"p99": snap["p99"],
"signal": None,
"direction": None,
"score": 0,
"tier": None,
"factors": {},
}
def evaluate_signal(
state: SymbolState,
now_ms: int,
strategy_cfg: Optional[dict] = None,
snapshot: Optional[dict] = None,
) -> dict:
"""
统一评分入口
- v53*/custom_* evaluate_factory_strategy (V5.3/V5.4 策略工厂)
- 其他策略v51_baseline/v52_8signals 视为已下线返回空结果并记录 warning
"""
strategy_cfg = strategy_cfg or {}
strategy_name = strategy_cfg.get("name", "v53")
# v53 / custom_* 策略:走统一 V5 工厂打分
if strategy_name.startswith("v53") or strategy_name.startswith("custom_"):
snap = snapshot or state.build_evaluation_snapshot(now_ms)
# 单币种策略:如 cfg.symbol 存在,仅在该 symbol 上有效
strategy_symbol = strategy_cfg.get("symbol")
if strategy_symbol and strategy_symbol != state.symbol:
return _empty_result(strategy_name, snap)
allowed_symbols = strategy_cfg.get("symbols", [])
if allowed_symbols and state.symbol not in allowed_symbols:
return _empty_result(strategy_name, snap)
# 直接复用工厂评分核心逻辑,并确保基础字段完整
result = evaluate_factory_strategy(state, now_ms, strategy_cfg, snap)
# 补充缺失的基础字段(以 snapshot 为准)
base = _empty_result(strategy_name, snap)
for k, v in base.items():
result.setdefault(k, v)
return result
# 非 v53/custom_ 策略:视为已下线,返回空结果并记录 warning
snap = snapshot or state.build_evaluation_snapshot(now_ms)
logger.warning(
"[strategy_scoring] strategy '%s' 已下线 (仅支持 v53*/custom_*), 返回空结果",
strategy_name,
)
return _empty_result(strategy_name, snap)

36
docs/AB_TEST_CHECKLIST.md Normal file
View File

@ -0,0 +1,36 @@
# AB测试观测清单2026-03-02 ~ 03-16
## 冻结期规则
- 不改权重、不改阈值、不改评分逻辑
- 如需改动必须打新版本号并分段统计
- 单写入源(小周生产环境)
## 两周后评审项目
### 1. 确认层重复计分审计
- **问题**方向层和确认层都用CVD_fast/CVD_mid同源重复
- **审计方法**:统计确认层=15 vs 确认层=0时的胜率差异
- **如果差异不显著**V5.3降权或重构为"CVD斜率加速+趋势强度"
### 2. 拥挤层 vs FR相关性
- **审计**`corr(FR_score, crowd_score)`
- **如果>0.7**:说明重复表达,降一层权重
### 3. OI持续性审计
- **字段**`oi_persist_n`(连续同向窗口数)— 目前未记录需V5.3加
- **审计**:高分单里`oi_persist_n=1`的胜率是否显著差于`>=2`
- **如果差异明显**:升为正式门槛
### 4. 清算触发率审计(按币种)
- 各币种清算信号触发率
- 触发后净R分布
- 避免某币种几乎不触发/过度触发
### 5. config_hash落库V5.3
- 每笔强制落库:`strategy`, `strategy_version`, `config_hash`, `engine_instance`
- 报表按config_hash分组
## 数据目标
- V5.1500+笔当前282
- V5.2200+笔当前12
- 每策略每币种50+笔

View File

@ -1,203 +0,0 @@
# AI 使用手册Arbitrage Engine
> 面向 Codex / OpenClaw / 其他 AI 助手。
> 目标:让 AI 在本项目中 **安全、可预期、可回溯** 地修改代码,而不是“乱改一通”。
本项目的**唯一权威系统说明**是:`docs/arbitrage-engine-full-spec.md`。
本手册描述AI 进入项目时的阅读顺序、允许/禁止修改的范围,以及每次改动必须遵守的流程。
---
## 1. 进入项目前必须阅读的内容
在对本项目做任何非文档类修改之前AI 必须先做下面几件事:
1. 打开并理解 `docs/arbitrage-engine-full-spec.md` 中至少以下章节:
- 项目概述、技术栈
- 数据库结构(尤其是 `strategies` / `signal_indicators` / `paper_trades`
- 信号引擎signal_engine逻辑
- paper trading 行为与 PnL 计算
2. 浏览以下后端文件的结构(无需逐行记住):
- `backend/main.py`FastAPI 路由与业务分层)
- `backend/signal_engine.py`(信号引擎与策略工厂)
- `backend/paper_monitor.py`(模拟盘平仓逻辑)
3. 浏览前端入口结构:
- `frontend/app/` 下的页面目录(尤其是 `/strategy-plaza` / `/signals` / `/paper`
如发现文档与代码实际行为有冲突,应**优先相信 full-spec**,并在必要时向人类维护者报告。
---
## 2. 修改范围:可以动什么 / 不可以动什么
为减少细节 bug 和隐性破坏,本项目对 AI 的修改范围划分为三类:
- `允许自由修改`:只要本地检查通过即可。
- `允许修改,但需要特别小心`:需要有清晰计划 + 局部验证。
- `禁止修改,除非任务明确要求且更新了 full-spec`
下面按类型具体说明。
### 2.1 允许自由修改的内容(默认安全区)
这些改动一般不会破坏系统核心语义,可以在合理前提下自由进行:
1. **文档与注释**
- 新增或修改 Markdown 文档(除非明确标为“权威规范”,如 full-spec
- 在代码中添加/改进注释,不改变实际逻辑。
2. **测试代码**
- 新增/改进单元测试、集成测试、前端测试。
- 修复由于实现变化导致的测试用例不匹配(前提是确认测试的预期确实已变化)。
3. **前端 UI 层展示**
- 不改变 API 调用与数据结构的前提下:
- 布局调整、视觉优化、文案修改。
- 新增只读型组件(图表、表格、标签等)。
4. **纯新增的本地脚本与工具**
- 放在 `scripts/` 或明确的工具目录里,用于开发、调试、分析(不接入生产 PM2 流程)。
5. **无行为变更的小型重构**
- 拆分过长函数、提取私有辅助函数。
- 增加类型标注、引入更严格的 linters在配置合理的前提下
### 2.2 可以修改但需要特别小心的内容(受控区)
这类改动一旦出错,会引入细节 bug 或统计偏差,但不至于破坏整个系统结构。修改前必须:
1. 在回答中给出**明确的改动计划**(改哪些文件、改变哪些行为)。
2. 改完后尽量做局部验证(至少跑相关命令 / 人工检查相关页面)。
受控区包括:
1. **现有 API 的实现细节**
- 位于 `backend/main.py` 中的 `/api/*` 路由实现。
- 允许:
- 优化 SQL 查询;
- 修复明显的边界条件 bug
- 增加非破坏性字段(向响应 json 增加可选字段)。
- 不允许:
- 静默改变已有字段的含义(例如把 `score` 从 0100 改成 01
- 在无任务说明的前提下移除现有字段。
2. **前端与现有 API 的交互逻辑**
- 包括 `frontend/app/*` 中对 `/api/*` 的调用与数据展示逻辑。
- 修改前需确认:
- 请求路径、查询参数、响应结构与 full-spec 和实际后端实现一致。
3. **信号引擎的非核心逻辑**
- `backend/signal_engine.py` 中:
- 日志输出格式;
- 错误处理与异常保护;
- 非打分核心的辅助逻辑(例如冷启动保护、调试输出等)。
- 修改“评分公式”“门控条件”的逻辑时,属于 **禁止区**(见 2.3)。
4. **paper trading 的展示与统计层**
- 后端统计接口(如 `/api/paper/stats`、`/api/paper/stats-by-strategy`)的实现细节;
- 前端 `/paper` 以及策略广场中与统计展示相关的逻辑。
- 改动时需确认:
- 不改变 `pnl_r` / `status` 的语义;
- 汇总方式与 full-spec 中定义的规则保持一致。
### 2.3 禁止修改的内容(红线区)
以下内容 **默认禁止 AI 修改**
只有在任务说明/人类明确要求的情况下,才可以动,并且必须同步更新 `arbitrage-engine-full-spec.md` 中对应章节。
1. **数据库结构与关键字段语义**
- 表结构(尤其是):
- `strategies`
- `signal_indicators`
- `paper_trades`
- `agg_trades`
- `liquidations`
- `market_indicators`
- 禁止:
- 修改已有字段的含义;
- 删除字段;
- 改名字段;
- 在没有文档更新的前提下添加字段。
- 如确需变更,必须:
- 在任务文档中有清晰需求;
- 编写迁移 SQL 并在 full-spec 中更新结构说明。
2. **核心业务指标的定义与计算方式**
- 包括但不限于:
- `pnl_r` 的计算规则;
- `status` 枚举值含义(`tp` / `sl` / `sl_be` / `timeout` / `signal_flip` 等);
- 胜率、盈亏比、权益曲线的汇总算法;
- 信号评分各层direction/environment/auxiliary/momentum的含义与权重机制。
- 如果任务要求调整这些规则,必须:
- 明确标注新旧行为差异;
- 说明对历史数据/统计的影响;
- 更新 full-spec 中对应章节。
3. **信号引擎核心打分/开仓/平仓逻辑**
- `evaluate_factory_strategy`V5.4 策略工厂核心)中的核心决策逻辑,包括:
- 门控系统5 个 Gate的通过/否决条件;
- 各层得分的具体计算公式;
- `entry_score` / `flip_threshold` 等核心阈值的语义;
- 开仓价/SL/TP 的确定方式;
- `paper_open_trade` / `paper_monitor` 中判定触发 TP1/TP2/SL/timeout 的条件。
- 除非任务明确指出“需要调整策略模型/打分方式”,否则 AI 不应主动更改这些部分。
4. **策略 ID / 名称约定与历史数据映射**
- `strategies.strategy_id`UUID`paper_trades.strategy_id`、`signal_indicators.strategy_id` 的关联关系;
- `strategy` 字段中已有的命名约定(例如 `custom_` 前缀)。
- 禁止:
- 修改已有策略的 `strategy_id`
- 静默重命名已有策略的 `strategy` 文本值;
- 移除 `custom_` 策略路由或相关兼容逻辑。
5. **认证与安全配置**
- `auth.py` 中的认证逻辑和权限控制;
- JWT Secret、数据库连接信息、PM2 配置等运维层配置。
- AI 不应在代码中硬编码新的敏感信息,也不应修改现有的密钥配置。
---
## 3. 每次修改必须遵守的流程AI 视角)
无论修改的是允许区还是受控区AI 在本项目中的工作流程应遵循:
1. **确认任务范围**
- 从用户指令或任务文档中提取:
- 要实现/修复的目标;
- 涉及的页面/API/模块。
2. **查阅相关文档**
- 总是先查 `docs/arbitrage-engine-full-spec.md`
- 如涉及信号引擎/策略工厂/模拟盘,需额外查阅对应的 GUIDE 文档(如果存在)。
3. **列出改动计划**
- 在回答中简要列出:
- 计划修改的文件;
- 每个文件预期的改动类型bugfix/重构/新增功能)。
4. **实施改动(保持最小必要范围)**
- 避免“一次改太多文件”;
- 避免引入与任务无关的重构。
5. **本地验证**
- 尽量运行至少以下检查之一(视任务而定):
- 后端:相关单测 / 手工调用关键 API
- 前端:`npm run build` 或启动本地开发服务器检查关键页面;
- 如无法真实运行,至少从代码结构和文档上做自洽性检查。
6. **更新文档(如必要)**
- 若改动影响 full-spec 中描述的行为/结构,必须同步更新 full-spec
- 若改动属于策略/信号模型的重大变更,应在决策记录中添加条目(例如 `DECISIONS.md`)。
---
## 4. 冲突处理原则
当出现以下冲突时AI 应遵循:
1. **代码 vs full-spec**
- 如果代码实现与 full-spec 的描述不一致:
- 默认以 full-spec 为“期望行为”;
- 优先考虑修代码使其符合 full-spec
- 如判断 full-spec 已过时,应在回答中明确提出,并建议人类确认后更新文档。
2. **用户即时指令 vs 文档约束**
- 如果用户指令要求做的事情与本手册“禁止修改”部分冲突:
- AI 必须在回答中提醒用户这是“红线操作”;
- 只有在用户明确确认且任务需求足够清晰时,才执行,并同步修改 full-spec。
---
## 5. 总结
本手册的核心目的只有一个:
> 让本项目在 AI 长期参与维护的前提下,仍然保持:
> - 核心业务逻辑不被轻易破坏;
> - 历史数据和统计口径具有可比性;
> - 每一次变更都有据可查、可解释。
如果本手册与 `arbitrage-engine-full-spec.md` 或实际代码行为存在不一致,应优先保持三者的一致性,并在必要时征求人类维护者的确认。

File diff suppressed because it is too large Load Diff

View File

@ -1,144 +0,0 @@
# Auto-Evolve 运行手册
## 1. 目标
本手册用于把策略工厂升级为“自动进化”流程:
1. 自动分析(每天)
2. 自动调整(每天每币最多新增 1 条 `codex优化-*`
3. 自动复盘(输出报告)
4. 自动清理(超配时下线表现最差的 codex 策略)
> 注意:自动化默认仅在模拟盘执行。任何实盘动作必须人工确认。
---
## 2. 代码入口
- 自动进化脚本:`automation/auto_evolve/run_daily.py`
- 示例配置:`automation/auto_evolve/config.example.json`
- 报告输出目录:`reports/auto-evolve/YYYY-MM-DD/`
---
## 3. 连接信息(快速)
完整信息见:`docs/OPS_CONNECTIONS.md`
### 3.1 服务器
```bash
gcloud compute ssh instance-20260221-064508 \
--zone asia-northeast1-b \
--project gen-lang-client-0835616737 \
--tunnel-through-iap
```
### 3.2 数据库(服务器本地)
```bash
sudo -u fzq1228 bash -lc "export PGPASSWORD=arb_engine_2026; psql -h 127.0.0.1 -U arb -d arb_engine -c 'SELECT now();'"
```
---
## 4. 手工运行(建议先 dry-run
### 4.0 前置修复symbol 不一致脏数据清理(一次性)
```bash
# 先看统计(不删除)
python3 scripts/fix_signal_symbol_mismatch.py
# 确认后执行删除
python3 scripts/fix_signal_symbol_mismatch.py --apply
```
### 4.1 本地或服务器 dry-run
```bash
cd /home/fzq1228/Projects/arbitrage-engine
python3 automation/auto_evolve/run_daily.py
```
### 4.2 实际执行(写入策略变更)
```bash
cd /home/fzq1228/Projects/arbitrage-engine
python3 automation/auto_evolve/run_daily.py --apply
```
### 4.3 指定配置
```bash
python3 automation/auto_evolve/run_daily.py --config automation/auto_evolve/config.example.json --apply
```
---
## 5. 自动策略逻辑(当前版本)
### 5.1 分析输入
- `strategies`running 策略)
- `paper_trades`(仅统计 `paper_trades.symbol = strategies.symbol`
- `signal_indicators`(仅统计 `signal_indicators.symbol = strategies.symbol`
### 5.2 评分
- `fitness = 净R + 速度因子 + 稳定性因子 + PF因子 - 样本惩罚`
- 样本不足会被惩罚(避免小样本幻觉)
### 5.3 自动调整
- 按币种识别简化 regime`trend/high_vol/crash/range`
- 每币种最多产出 1 条新策略(可配置)
- 复制父策略门控参数,重点微调:
- CVD 窗口
- 四层权重
- entry score
- TP/SL/timeout
### 5.4 自动下线
- 若某币种 `codex优化-*` 运行数超过阈值(默认 3
- 自动下线最差且达到最小存活时间的策略(默认 24h
---
## 6. 每日报告内容
脚本每次执行都会输出:
- Top3 / Bottom3fitness
- 每币种 regime
- 本次新建策略列表
- 本次下线策略列表
- 计划日志(候选生成来源)
文件:
- Markdown`reports/auto-evolve/YYYY-MM-DD/HHMMSS_auto_evolve.md`
- JSON`reports/auto-evolve/YYYY-MM-DD/HHMMSS_auto_evolve.json`
---
## 7. Codex 每日二次复盘(深度搜索)
建议每日在脚本执行后,由 Codex 自动执行第二轮复盘:
1. 读取当天 auto-evolve 报告
2. 联网做深度搜索(最新策略研究、订单流门控、风险控制)
3. 对照本项目现状判断可落地改进项
4. 若有明确提升收益的改动,触发 `work-to-down` 模式落地到代码
5. 生成复盘结论(改了什么、预期收益、风险)
> 这一步建议通过 Codex Automation 调度,不建议手工每天执行。
---
## 8. 安全边界
- 默认不修改历史交易,不回填旧数据。
- 自动化只操作策略参数与策略状态,不触碰用户认证/账密。
- 自动化失败时会回滚事务,不会留下半写入状态。

View File

@ -1,219 +0,0 @@
# Arbitrage Engine 后端运行说明Backend Runtime Guide
> 本文从“后端工程师 / 运维”的角度,描述项目的后端模块、进程拓扑和数据流。
> 详细字段与业务规则仍以 `docs/arbitrage-engine-full-spec.md` 为准。
---
## 1. 后端整体结构概览
后端主要由三类组件组成:
1. **FastAPI HTTP APIarb-api**
- 文件:`backend/main.py`
- 职责:
- 提供 `/api/...` 下的所有 HTTP 接口;
- 负责认证(集成 `auth.py` 的路由);
- 对数据库执行查询和聚合逻辑,为前端和脚本服务;
- 启动少量后台任务(如资金费率快照)。
2. **信号与交易引擎进程**
- `backend/signal_engine.py`:信号引擎(策略评估 + 模拟盘开仓);
- `backend/paper_monitor.py`模拟盘平仓TP/SL/timeout/signal_flip
- `backend/live_executor.py`(未来/暂定):连接实盘执行;
- `backend/risk_guard.py`:风控守护进程;
- `backend/position_sync.py`:实盘仓位同步。
3. **数据采集与维护进程**
- `backend/agg_trades_collector.py`:从币安 WebSocket 收集逐笔成交写入 `agg_trades`
- `backend/market_data_collector.py`收集资金费率、OI、多空比等写入 `market_indicators`
- `backend/liquidation_collector.py`:收集爆仓数据写入 `liquidations`
- `backend/backfill_agg_trades.py`:历史数据回补;
- `backend/fix_historical_pnl.py` 等维护脚本:用于修复历史统计。
这些组件通过 PostgreSQL 共享状态FastAPI 作为对外唯一 HTTP 入口,信号/模拟盘引擎作为“后端大脑”,采集进程负责填充数据湖。
---
## 2. FastAPI APIarb-api
入口文件:`backend/main.py`
运行方式:由 PM2 管理的 `arb-api` 进程(端口 4332
主要职责:
- 提供 `/api/...` REST 接口:
- 资金费率/历史数据(`/api/rates`、`/api/history`、`/api/kline`、`/api/snapshots`
- 信号相关接口(`/api/signals/*`
- 模拟盘接口(`/api/paper/*`
- 策略管理接口(`/api/strategies*`、`/api/strategy-plaza*`
- 实盘状态与控制接口(`/api/live/*`
- 服务器监控接口(`/api/server/status`)。
- 初始化数据库连接:
- 在 `startup` 事件中调用 `init_schema()``ensure_auth_tables()`
- 初始化 asyncpg 连接池(`get_async_pool()`)。
- 启动后台任务:
- `background_snapshot_loop()`:每 2 秒从币安拉资金费率 + 标记价,写入 `rate_snapshots`
开发/调试提示:
- 本地如需仅调试 API 层,可以只启动 `main.py`(例如 `uvicorn main:app --reload`),连接已有 Cloud SQL
- 复杂查询(如 stats / strategy-plaza建议先查阅 `docs/API_CONTRACTS.md`,再看对应路由实现。
---
## 3. 信号与交易引擎进程
### 3.1 signal_engine.py — 信号引擎
文件:`backend/signal_engine.py`
PM2 名称:`signal-engine`
职责(结合 full-spec
- 定时循环(约每 15 秒):
1. 从 `agg_trades` 滑动窗口计算 CVD、ATR、VWAP 等指标;
2. 从 `market_indicators` / `liquidations` 读取宏观指标和爆仓数据;
3. 对每个策略(`strategies` 表中 `status=running` 的记录调用评估函数V5.3 之前 `_evaluate_v53`V5.4 起为 `evaluate_factory_strategy`
4. 生成评分与信号(`score`、`signal`),写入 `signal_indicators`
5. 在满足开仓条件时写入 `paper_trades`,通过 `paper_open_trade()` 等逻辑开模拟仓。
- 与前端/上层接口的关系:
- 前端 `/signals*` 页面只读 `signal_indicators` 和相关汇总接口,不直接调用 signal_engine
- `/paper`、`/strategy-plaza` 通过 `paper_trades``strategies`/`signal_indicators` 组合呈现结果。
注意:
- `signal_engine.py` 是最复杂的单体文件之一,对其进行修改前建议:
- 阅读 `arbitrage-engine-full-spec.md` 中“信号引擎”章节;
- 阅读 `docs/AI_HANDBOOK.md` 中关于“禁止/谨慎修改”的约束;
- 在未来可以为其单独增加 `GUIDE_SIGNAL_ENGINE.md` 做更细粒度说明。
### 3.2 paper_monitor.py — 模拟盘平仓
文件:`backend/paper_monitor.py`
PM2 名称:`paper-monitor`
职责:
- 通过币安 WebSocket 获取 mark price
- 监控所有 `paper_trades``status="active"``tp1_hit` 的持仓;
- 根据价格触发规则:
- 触及 TP1 → 将 status 改为 `tp1_hit`,移动 SL 到保本价;
- 触及 TP2 → status=`tp`,计算 `pnl_r`
- 触及 SL → status=`sl``pnl_r=-1`
- 超时(持仓超过 timeout_minutes→ status=`timeout`,按价差换算 R
- 被反向信号强平 → status=`signal_flip`。
信号引擎负责开仓逻辑paper_monitor 负责平仓逻辑,两者通过 `paper_trades` 协作。
### 3.3 其他进程(概要)
- `live_executor.py`
- 负责将信号转换为实盘订单(当前可能处于试验/非默认启用状态);
- 与 `/api/live/*` 接口、`risk_guard.py` 共同控制实盘行为。
- `risk_guard.py`
- 独立风控守护进程,通过检查日内 R、连续亏损、API 状态等判断是否触发熔断;
- 与 `/api/live/risk-status``/api/live/emergency-close` 等控制接口关联。
- `position_sync.py`
- 定期从交易所拉取真实仓位,对齐本地 `live_positions` 记录;
- 为 `/api/live/reconciliation` 提供比对数据。
这些进程对模拟盘的影响有限,但对实盘 `/live` 页至关重要。
---
## 4. 数据采集与维护脚本
### 4.1 市场数据采集
- `agg_trades_collector.py`
- 订阅币安逐笔成交 WebSocket 流;
- 将成交写入 `agg_trades` 分区表;
- 为 CVD/ATR/VWAP 等指标计算提供原始成交数据。
- `market_data_collector.py`
- 定期从币安/其他来源拉取资金费率、未平仓合约OI、多空比、订单簿不平衡等
- 写入 `market_indicators`
- 供 signal_engine 的环境层/拥挤层/辅助层使用。
- `liquidation_collector.py`
- 拉取爆仓(强平)数据;
- 写入 `liquidations`
- 供 V5.2/V5.3 的 liquidation 层打分使用。
### 4.2 历史数据与修复
- `backfill_agg_trades.py`
- 用于历史数据回补;
- 配合 `/api/server/status``backfill_running` 标记监控运行状态。
- `fix_historical_pnl.py` 等修复脚本:
- 针对旧数据的 PnL 计算错误或结构变更进行一次性修复;
- 修改前请查阅 full-spec 中对应章节,并记录在决策/变更文档中。
---
## 5. 后端数据流(服务视角)
从“服务/进程”的角度看,主数据流可简化为:
1. **行情写入**
- 币安 WebSocket → `agg_trades_collector.py``agg_trades`
- 市场宏观指标 API → `market_data_collector.py``market_indicators`
- 强平数据 → `liquidation_collector.py``liquidations`
- 资金费率快照 → `main.py` 中后台任务 → `rate_snapshots`
2. **信号生成**
- `signal_engine.py` 周期性读取:
- `agg_trades` 滑动窗口;
- `market_indicators`
- `liquidations`
- `strategies` 配置;
- 计算指标与评分 → 写入 `signal_indicators`
- 满足条件时 → 写入 `paper_trades`(开仓记录)。
3. **模拟盘结算**
- `paper_monitor.py` 通过 WebSocket 获取 price
- 根据 `paper_trades` 的 SL/TP/timeout 规则更新 `paper_trades.status``pnl_r`
4. **API 展示与控制**
- `main.py` 通过 `/api/paper/*`、`/api/signals/*`、`/api/strategy-plaza*` 等接口对外提供:
- 信号历史和当前状态;
- 模拟盘持仓、交易历史、统计与权益曲线;
- 策略列表与管理操作。
- `/api/live/*` 接口与 `live_executor.py` / `risk_guard.py` 等进程协作控制实盘。
---
## 6. 本地开发与调试建议
1. **仅调试前端 + API不跑引擎**
- 使用现有 Cloud SQL 作为只读数据源:
- 启动 `backend/main.py`uvicorn 或 PM2
- 启动前端 `frontend`Next.js dev 模式);
- 适用于:
- 调整页面布局与数据展示;
- 优化 `/api/...` 查询逻辑;
- 编写/验证新接口契约。
2. **调试信号引擎/模拟盘**
- 启动 `signal_engine.py``paper_monitor.py` 所需的最小进程集合;
- 确保 `agg_trades` / `market_indicators` 至少有一部分历史数据(可以从线上复制或运行简化版 collector
- 使用日志和 `signal_indicators` / `paper_trades` 查询验证新策略。
3. **调试实盘页面 `/live`**
- 在本地/测试环境建议仅连接测试网账户;
- 启动相关进程:
- `live_executor.py`、`risk_guard.py`、`position_sync.py`(视具体实现和需求而定);
- 通过 `/api/live/*` 接口与 `/live` 页面观察状态变化,避免直接在生产环境实验未验证的逻辑。
4. **变更规则**
- 修改后端关键逻辑(尤其是 signal_engine、risk_guard建议遵守
- 先更新/确认 `arbitrage-engine-full-spec.md` 中对应章节;
- 再确保 `API_CONTRACTS.md` 与前端用法一致;
- 最后通过少量回测/模拟盘运行做 smoke test。
---
本文件的目标是提供一个“后端模块地图”和“进程视角的数据流”,让未来的你(以及 AI 助手)在需要修改/排障时,不必反复从头翻 full-spec 和代码,就能快速知道该看哪几个模块、查哪几张表、重启哪些进程。

View File

@ -1,30 +0,0 @@
# Codex 每日自动复盘 Prompt模板
> 供 Codex Automation 使用。目标:每天自动执行脚本 + 深度复盘 + 自动优化。
```
先执行 [$work-to-down](/Users/fanziqi/.codex/skills/work-to-down/SKILL.md)。
你在项目 /Users/fanziqi/codex/arbitrage-engine 工作。
按顺序完成:
1) 运行自动进化脚本(写库执行):
python3 automation/auto_evolve/run_daily.py --apply
2) 打开最新报告reports/auto-evolve/YYYY-MM-DD/*_auto_evolve.md总结
- 最可能赚钱前三
- 最可能亏钱前三
- 每个币种当前 regime
3) 连接数据库交叉验证关键指标running 策略、24h/7d netR、开仓转化率、active 持仓)。
4) 进行深度联网搜索(优先策略研究/风控/订单流相关的高质量一手资料),输出“可落地改进清单”。
5) 若有明确可提升项:
- 直接修改本仓库代码并自测;
- 优先改动 signal_engine / strategy_scoring / automation/auto_evolve
- 保留风险边界(不破坏生产连接,不做危险删库操作)。
6) 生成当日复盘结论到 reports/auto-evolve/YYYY-MM-DD/HHMMSS_codex_review.md内容包括
- 今日问题
- 今日改进
- 预期收益提升点
- 风险与回滚建议
执行原则:做到为止,不要只给建议,能落地就直接落地。
```

View File

@ -0,0 +1,379 @@
# 全面代码审阅报告
> 生成时间2026-03-03
> 审阅范围全部后端文件15个完整阅读
> 基于 commit `a17c143` 的代码
---
## 摘要
本报告基于对所有后端文件的逐行阅读。发现 **4个致命级别问题**(直接导致实盘无法运行)、**5个高危问题**(全新部署直接报错)、**4个安全漏洞**、**6个架构设计缺陷**。其中若干问题在前一份报告PROBLEM_REPORT.md中已提及但本报告基于完整代码阅读提供了更精确的定位和更全面的覆盖。
---
## 🔴 致命问题(实盘链路完全断裂)
### [F1] live_executor 永远读不到信号
**定位**`live_executor.py:fetch_pending_signals()` + `signal_engine.py:save_indicator()`
**证据链**
1. `signal_engine.py:690-706``save_indicator()` 用 `get_sync_conn()`(即 db.py 的 `PG_HOST=127.0.0.1`)将信号写入**本地 PG 的** `signal_indicators`
2. `live_executor.py:50-55`(已知):`DB_HOST` 默认 `10.106.0.3`Cloud SQL
3. `signal_engine.py:704-705``NOTIFY new_signal` 发送到**本地 PG**live_executor 的 `LISTEN` 连在 Cloud SQL 上
**结论**
| 动作 | 写入位置 |
|------|---------|
| signal_engine 写 `signal_indicators` | 本地 PG127.0.0.1|
| live_executor 的 LISTEN 监听 | Cloud SQL10.106.0.3|
| live_executor 的轮询查 `signal_indicators` | Cloud SQL10.106.0.3|
| Cloud SQL 的 `signal_indicators` 表内容 | **永远为空**(无双写机制)|
live_executor 即便轮询也是查 Cloud SQL 的空表NOTIFY 也发到本地 PG 收不到。**只要实盘进程跑在不同数据库实例上,永远不会执行任何交易。**
---
### [F2] risk_guard 的数据新鲜度检查永远触发熔断
**定位**`risk_guard.py:check_data_freshness()`
**代码逻辑**(从已读内容重建):
```python
# risk_guard 连 Cloud SQLDB_HOST=10.106.0.3
MAX(ts) FROM signal_indicators → NULL表为空
stale_seconds = now - NULL → Python 抛异常或返回极大值
→ 触发 block_all 熔断
```
`/tmp/risk_guard_state.json``block_all=true`live_executor 执行前读此文件Fail-Closed**所有交易被直接拒绝**。
**叠加效果**:即使 F1 问题修复了(信号能传到 Cloud SQLF2 也保证 live_executor 在下单前因 `block_all` 标志放弃执行。
---
### [F3] risk_guard 与 live_executor 必须同机运行,但无任何保障
**定位**`risk_guard.py`(写 `/tmp/risk_guard_state.json`)、`live_executor.py`(读同一路径)
**问题**两个进程通过本地文件系统文件交换状态。若部署在不同机器或不同容器live_executor 读到的要么是旧文件要么是文件不存在Fail-Closed 机制会阻断所有交易。目前无任何文档说明"两进程必须共机",无任何启动脚本检查,无任何报警。
---
### [F4] signal_pusher.py 仍使用 SQLite与 V5 PG 系统完全脱节
**定位**`signal_pusher.py:1-20`
```python
import sqlite3
DB_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "arb.db")
SYMBOLS = ["BTCUSDT", "ETHUSDT"] # ← XRP/SOL 不在监控范围
```
**完整问题列表**
1. 读 `arb.db`SQLiteV5 信号全在 PG 的 `signal_indicators` 表,此脚本从不读取
2. 只覆盖 BTC/ETHXRP/SOL 的信号永远不会被推送
3. **Discord Bot Token 硬编码**(详见 [S1]
4. 是一个一次性运行脚本不是守护进程PM2 管理无意义
5. 查询的 SQLite 表 `signal_logs` 在 V5 体系下已废弃
**结论**signal_pusher.py 是遗留代码,从未迁移到 V5 PG 架构。如果 PM2 中运行的是此文件,通知系统完全失效。
---
## 🔴 高危问题(全新部署直接崩溃)
### [H1] `signal_indicators` 表缺少 `strategy``factors`
**定位**`db.py:205-224`(建表 SQLvs `signal_engine.py:695-701`INSERT 语句)
**SCHEMA_SQL 中的列**
`id, ts, symbol, cvd_fast, cvd_mid, cvd_day, cvd_fast_slope, atr_5m, atr_percentile, vwap_30m, price, p95_qty, p99_qty, buy_vol_1m, sell_vol_1m, score, signal`
**save_indicator() 实际 INSERT 的列**
`ts, symbol, **strategy**, cvd_fast, cvd_mid, cvd_day, cvd_fast_slope, atr_5m, atr_percentile, vwap_30m, price, p95_qty, p99_qty, score, signal, **factors**`
多了 `strategy TEXT``factors JSONB` 两列。`init_schema()` 中也没有对应的 `ALTER TABLE signal_indicators ADD COLUMN IF NOT EXISTS` 补丁(只有对 `paper_trades` 的补丁)。
**后果**:全新环境 `init_schema()`signal_engine 每次写入都报 `column "strategy" of relation "signal_indicators" does not exist`,主循环崩溃。
**补充**`main.py:/api/signals/latest` 的查询也包含 `strategy``factors` 字段,全新部署 API 也会报错。
---
### [H2] `paper_trades` 表缺少 `risk_distance`
**定位**`db.py:286-305`(建表 SQLvs `signal_engine.py:762-781`INSERT 语句)
**SCHEMA_SQL 中的列**(无 `risk_distance`
`id, symbol, direction, score, tier, entry_price, entry_ts, exit_price, exit_ts, tp1_price, tp2_price, sl_price, tp1_hit, status, pnl_r, atr_at_entry, score_factors, created_at`
`init_schema()``ALTER TABLE paper_trades ADD COLUMN IF NOT EXISTS strategy` 补了 `strategy` 列,但**没有补 `risk_distance`**。
`paper_open_trade()` 的 INSERT 包含 `risk_distance``paper_monitor.py:59` 和 `signal_engine.py:800` 也从 DB 读取 `risk_distance`
**后果**:全新部署后,第一次模拟开仓就报 `column "risk_distance" does not exist`。止盈止损计算使用 `rd_db if rd_db and rd_db > 0 else abs(entry_price - sl)` 进行降级,但永远触发不了,因为插入本身就失败了。
---
### [H3] `users` 表双定义,`banned` 和 `discord_id` 字段在新环境缺失
**定位**`db.py:269-276` vs `auth.py:28-37`
| 字段 | db.py SCHEMA_SQL | auth.py AUTH_SCHEMA |
|------|-----------------|---------------------|
| `email` | ✅ | ✅ |
| `password_hash` | ✅ | ✅ |
| `role` | ✅ | ✅ |
| `created_at` | ✅ | ✅ |
| `discord_id` | ❌ | ✅ |
| `banned` | ❌ | ✅ |
`FastAPI startup` 先调 `init_schema()`db.py 版建表),再调 `ensure_auth_tables()`auth.py 版),`CREATE TABLE IF NOT EXISTS` 第二次静默跳过。实际建的是旧版本,缺少 `discord_id``banned`
**后果**:封禁用户功能在新部署上完全失效(`banned` 字段不存在)。
---
### [H4] `/api/kline` 只支持 BTC/ETHXRP/SOL 静默返回错误数据
**定位**`main.py:151-152`
```python
rate_col = "btc_rate" if symbol.upper() == "BTC" else "eth_rate"
price_col = "btc_price" if symbol.upper() == "BTC" else "eth_price"
```
XRP 和 SOL 请求均被路由到 ETH 的数据列。返回的是 ETH 的费率 K 线,但 symbol 标记为 XRP/SOL。前端图表展示完全错误。根本原因`rate_snapshots` 表只有 `btc_rate``eth_rate` 两列,不支持 4 个币种的独立存储。
---
### [H5] `subscriptions.py` 是孤立 SQLite 路由,定义了重名的 `/api/signals/history`
**定位**`subscriptions.py:1-23`
```python
import sqlite3
DB_PATH = "arb.db" # SQLite
@router.get("/api/signals/history") # ← 与 main.py 同名
def signals_history(): ...
```
**三个问题**
1. 路由路径与 `main.py:221``@app.get("/api/signals/history")` 完全相同
2. 查询 SQLite `arb.db`V5 体系已无此数据
3. `main.py` **从未** `include_router(subscriptions.router)`,所以目前是死代码
若将来有人误把 `subscriptions.router` 加进来,会与现有 PG 版本的同名路由冲突FastAPI 会静默使用先注册的那个,导致难以排查的 bug。
---
## 🟠 安全漏洞
### [S1] Discord Bot Token 硬编码在源代码(高危)
**定位**`signal_pusher.py:~25`
```python
DISCORD_TOKEN = os.getenv("DISCORD_BOT_TOKEN", "MTQ3Mjk4NzY1NjczNTU1OTg0Mg.GgeYh5.NYSbivZKBUc5S2iKXeB-hnC33w3SUUPzDDdviM")
```
这是一个**真实的 Discord Bot Token**格式合法base64_encoded_bot_id.timestamp.signature。任何有代码库读权限的人都可以用此 Token 以 bot 身份发消息、读频道历史、修改频道。
**立即行动**:在 Discord 开发者后台吊销此 Token 并重新生成,从代码中删除默认值。
---
### [S2] 数据库密码硬编码(三处)
**定位**
- `db.py:19``os.getenv("PG_PASS", "arb_engine_2026")`
- `live_executor.py:44``os.getenv("DB_PASSWORD", "arb_engine_2026")`
- `risk_guard.py:42``os.getenv("DB_PASSWORD", "arb_engine_2026")`
三处使用同一个默认密码。代码一旦泄露,测试网数据库直接暴露。此外 `db.py:28` 还有 Cloud SQL 的默认密码:`os.getenv("CLOUD_PG_PASS", "arb_engine_2026")`。
---
### [S3] JWT Secret 有已知测试网默认值
**定位**`auth.py`(推断行号约 15-20
```python
_jwt_default = "arb-engine-jwt-secret-v2-2026" if _TRADE_ENV == "testnet" else None
```
`TRADE_ENV` 环境变量未设置(默认 `testnet`JWT secret 使用此已知字符串。所有 JWT token 均可被任何知道此 secret 的人伪造,绕过身份验证。
---
### [S4] CORS 配置暴露两个本地端口
**定位**`main.py:16-20`
```python
allow_origins=["https://arb.zhouyangclaw.com", "http://localhost:3000", "http://localhost:3001"]
```
生产环境保留了 `localhost:3000``localhost:3001`。攻击者如果能在本地运行浏览器页面e.g. XSS 注入到其他本地网站),可以绕过 CORS 跨域限制向 API 发请求。生产环境应移除 localhost origins。
---
## 🟡 架构缺陷
### [A1] 策略 JSON 不支持热重载(与文档声称相反)
**定位**`signal_engine.py:964-966`
```python
def main():
strategy_configs = load_strategy_configs() # ← 只在启动时调用一次!
...
while True:
load_paper_config() # ← 每轮循环,但只加载开关配置
# strategy_configs 从不刷新
```
决策日志(`06-decision-log.md`)声称策略 JSON 支持热修改无需重启,实际上 `strategy_configs` 变量只在 `main()` 开头赋值一次,主循环从不重新调用 `load_strategy_configs()`
**修改 v51_baseline.json 或 v52_8signals.json 后必须重启 signal_engine。**
注:每 60 轮循环确实会 `load_paper_config()` 热加载"哪些策略启用"的开关,但权重/阈值/TP/SL 倍数不会热更新。
---
### [A2] 三套数据库连接配置,极易迁移时漏改
| 进程 | 读取的环境变量 | 默认连接 |
|------|-------------|---------|
| `main.py`, `signal_engine.py`, `market_data_collector.py`, `agg_trades_collector.py`, `liquidation_collector.py`, `paper_monitor.py` | `PG_HOST`db.py | 127.0.0.1 |
| `live_executor.py`, `risk_guard.py`, `position_sync.py` | `DB_HOST` | 10.106.0.3 |
| `market_data_collector.py` 内部 | `PG_HOST` | 127.0.0.1 |
六个进程用 `PG_HOST`,三个进程用 `DB_HOST`,变量名不同,默认值不同,修改时需要同时更新两套 `.env`
---
### [A3] market_indicators 和 liquidations 表不在主 schema 中
**定位**`market_data_collector.py:ensure_table()`、`liquidation_collector.py:ensure_table()`
两张表由各自 collector 进程单独创建,不在 `db.py:SCHEMA_SQL` 里。启动顺序问题:
- 若 `signal_engine``market_data_collector` 先启动,查 `market_indicators` 报表不存在,所有市场指标评分降级为中间值
- 若 `signal_engine``liquidation_collector` 先启动,查 `liquidations` 报错,清算层评分归零
**补充发现**`liquidation_collector.py` 的聚合写入逻辑在 `save_aggregated()` 中写的是 `market_indicators` 表(不是 `liquidations`),但 `ensure_table()` 只创建了 `liquidations` 表。若 `market_data_collector` 未运行过(`market_indicators` 不存在liquidation_collector 的聚合写入也会失败。
---
### [A4] paper_monitor 和 signal_engine 的止盈止损逻辑完全重复
**定位**`signal_engine.py:788-878``paper_check_positions()`)、`paper_monitor.py:44-143``check_and_close()`
两个函数逻辑几乎一模一样(均检查 TP1/TP2/SL/超时)。当前 signal_engine 主循环中注释说"持仓检查由 paper_monitor.py 实时处理",所以 `paper_check_positions()` 是**死函数**(定义了但从不调用)。
**风险**:未来如果有人修改止盈止损逻辑,只改了 paper_monitor.py 或只改了 signal_engine.py两份代码就会产生不一致。
---
### [A5] rate_snapshots 表只存 BTC/ETHXRP/SOL 数据永久丢失
**定位**`db.py:167-177`(建表)、`main.py:42-55`save_snapshot
`rate_snapshots` 表的列硬编码为 `btc_rate, eth_rate, btc_price, eth_price, btc_index_price, eth_index_price`。XRP/SOL 的资金费率数据只从 Binance 实时拉取,不存储,无法做历史分析或 K 线展示。
---
### [A6] `/api/signals/history` 返回的是废弃表的数据
**定位**`main.py:221-230`
```python
SELECT id, symbol, rate, annualized, sent_at, message FROM signal_logs ORDER BY sent_at DESC LIMIT 100
```
`signal_logs` 是 V4 时代用于记录资金费率报警的旧表(`db.py:259-267`),在 V5 体系下不再写入任何数据。这个端点对前端返回的是永久为空的结果,但没有任何错误信息,调用方无从判断是数据为空还是系统正常运行。
---
## 🟢 值得记录的正确设计
以下是审阅过程中发现的值得肯定的设计,供参考:
1. **`position_sync.py` 设计完整**SL 丢失自动重挂、TP1 命中后 SL 移至保本、实际成交价查询、资金费用追踪每8小时结算窗口覆盖了实盘交易的主要边界情况。
2. **risk_guard Fail-Closed 模式正确**`/tmp/risk_guard_state.json` 不存在时live_executor 默认拒绝交易,而不是放行,安全方向正确。
3. **paper_monitor.py 使用 WebSocket 实时价格**:比 signal_engine 15 秒轮询更适合触发止盈止损,不会因为 15 秒间隔错过快速穿越的价格。
4. **agg_trades_collector.py 的数据完整性保障**:每 60 秒做连续性检查,断点处触发 REST 补录,每小时做完整性报告,设计周全。
5. **GCP Secret Manager 集成**live_executor/risk_guard/position_sync 优先从 GCP Secret Manager 加载 API 密钥(`projects/gen-lang-client-0835616737/secrets/BINANCE_*`),生产环境密钥不在代码/环境变量中,安全设计得当。
---
## 📋 修复优先级清单
### 立即(防止实盘上线后资金损失)
| 编号 | 问题 | 修复方向 |
|------|------|---------|
| **S1** | Discord Bot Token 泄露 | 立即在 Discord 开发者后台吊销并重新生成,代码中删除默认值 |
| **F1** | signal_engine 写本地 PGlive_executor 读 Cloud SQL信号永远不传递 | 统一所有进程连接同一 PG 实例,或为 `signal_indicators` 表添加双写逻辑 |
| **F2** | risk_guard 查 Cloud SQL 空表永远触发熔断 | 与 F1 一起解决(统一 DB 连接) |
| **F3** | risk_guard/live_executor 必须共机无文档说明 | 在 PM2 配置和部署文档中明确说明;或改为 DB-based 状态通信 |
| **F4** | signal_pusher 是废弃 SQLite 脚本 | 从 PM2 配置中移除;按需重写成 PG 版本 |
### 本周(防止全新部署报错)
| 编号 | 问题 | 修复方向 |
|------|------|---------|
| **H1** | `signal_indicators``strategy`、`factors` 列 | 在 `SCHEMA_SQL` 中补列;在 `init_schema()` 中加 `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` |
| **H2** | `paper_trades``risk_distance` 列 | 同上,在 `init_schema()` 中补 ALTER |
| **H3** | `users` 表双定义,`banned`/`discord_id` 缺失 | 从 `SCHEMA_SQL` 删除 `users` 建表语句,统一由 `auth.py` 负责;加 ALTER 迁移旧环境 |
| **H4** | `/api/kline` XRP/SOL 返回 ETH 数据 | 要么限制 kline 只支持 BTC/ETH 并在 API 文档中注明;要么扩展 `rate_snapshots` 表结构 |
| **H5** | `subscriptions.py` 孤立 SQLite 代码 | 删除或移至 `archive/` 目录,防止将来误用 |
### 本月(安全加固)
| 编号 | 问题 | 修复方向 |
|------|------|---------|
| **S2** | 数据库密码硬编码 | 移入 `.env` 文件,不进代码仓库;生产环境用 GCP Secret Manager |
| **S3** | JWT Secret 默认值可预测 | 生产部署强制要求 `JWT_SECRET` 环境变量,`_TRADE_ENV=production` 时 None 应直接启动失败 |
| **S4** | CORS 包含 localhost | 生产环境移除 localhost origins |
### 长期(架构改善)
| 编号 | 问题 | 修复方向 |
|------|------|---------|
| **A1** | 策略 JSON 不支持热重载 | 在主循环中定期(如每 60 轮)重新调用 `load_strategy_configs()` |
| **A2** | 三套 DB 连接配置 | 统一用同一套环境变量(建议统一用 `PG_HOST`),所有进程都从 `db.py` 导入连接 |
| **A3** | market_indicators/liquidations 不在主 schema | 将两表定义移入 `SCHEMA_SQL``init_schema()` |
| **A4** | paper_check_positions 死代码 | 删除 signal_engine.py 中的 `paper_check_positions()` 函数(功能由 paper_monitor 承担) |
| **A6** | `/api/signals/history` 返回废弃表数据 | 重定向到查 `signal_indicators` 表,或废弃此端点 |
---
## 附录:文件审阅覆盖情况
| 文件 | 行数 | 本次审阅 |
|------|-----|---------|
| `main.py` | ~500 | ✅ 全文 |
| `db.py` | ~415 | ✅ 全文 |
| `signal_engine.py` | ~1085 | ✅ 全文 |
| `live_executor.py` | ~708 | ✅ 全文 |
| `risk_guard.py` | ~644 | ✅ 全文 |
| `auth.py` | ~389 | ✅ 全文 |
| `position_sync.py` | ~687 | ✅ 全文 |
| `paper_monitor.py` | ~194 | ✅ 全文 |
| `agg_trades_collector.py` | ~400 | ✅ 全文 |
| `market_data_collector.py` | ~300 | ✅ 全文 |
| `liquidation_collector.py` | ~141 | ✅ 全文 |
| `signal_pusher.py` | ~100 | ✅ 全文 |
| `subscriptions.py` | ~24 | ✅ 全文 |
| `trade_config.py` | ~15 | ✅ 全文 |
| `backtest.py` | ~300 | 前100行 + 签名扫描 |
| `admin_cli.py` | ~100 | 签名扫描 |

File diff suppressed because it is too large Load Diff

View File

@ -1,144 +0,0 @@
# 运维连接手册(本地 PostgreSQL + GCE
本手册记录项目线上关键连接信息:
- 怎么连线上服务器;
- 怎么连服务器本地数据库(当前生产数据源);
- 线上项目目录和常用排查命令。
## 0. 固定信息
- GCP Project ID: `gen-lang-client-0835616737`
- 线上 VM: `instance-20260221-064508`
- Zone: `asia-northeast1-b`
- 线上项目目录: `/home/fzq1228/Projects/arbitrage-engine`
- 线上数据库(服务器本地 PostgreSQL
- Host: `127.0.0.1`
- Port: `5432`
- DB: `arb_engine`
- User: `arb`
- Password: `arb_engine_2026`
## 1. 连接线上服务器GCE
### 1.1 先确认账号和项目
```bash
gcloud auth list --filter=status:ACTIVE --format='value(account)'
gcloud config get-value project
```
如需切项目:
```bash
gcloud config set project gen-lang-client-0835616737
```
### 1.2 SSH推荐 IAP
```bash
gcloud compute ssh instance-20260221-064508 \
--zone asia-northeast1-b \
--project gen-lang-client-0835616737 \
--tunnel-through-iap
```
快速连通性测试:
```bash
gcloud compute ssh instance-20260221-064508 \
--zone asia-northeast1-b \
--project gen-lang-client-0835616737 \
--tunnel-through-iap \
--command "hostname; whoami; uptime; pwd"
```
## 2. 连接服务器本地数据库(当前生产)
### 2.1 在服务器内直接 psql
```bash
sudo -u fzq1228 bash -lc "export PGPASSWORD=arb_engine_2026; psql -h 127.0.0.1 -U arb -d arb_engine -c 'SELECT now();'"
```
进入交互式:
```bash
sudo -u fzq1228 bash -lc "export PGPASSWORD=arb_engine_2026; psql -h 127.0.0.1 -U arb -d arb_engine"
```
### 2.2 从本机临时连SSH Tunnel
如果需要在本机 SQL 客户端查看:
```bash
# 本机开隧道
gcloud compute ssh instance-20260221-064508 \
--zone asia-northeast1-b \
--project gen-lang-client-0835616737 \
--tunnel-through-iap \
-- -N -L 9432:127.0.0.1:5432
```
新开终端连本地映射端口:
```bash
PGPASSWORD=arb_engine_2026 psql -h 127.0.0.1 -p 9432 -U arb -d arb_engine -c "SELECT 1;"
```
## 3. 线上项目目录与运行状态
- Repo root: `/home/fzq1228/Projects/arbitrage-engine`
- Backend: `/home/fzq1228/Projects/arbitrage-engine/backend`
- Frontend: `/home/fzq1228/Projects/arbitrage-engine/frontend`
- 运行用户:`fzq1228`
- 进程管理:`pm2`
常用检查:
```bash
cd /home/fzq1228/Projects/arbitrage-engine
pm2 list
pm2 logs --lines 100
```
重点进程:
- `signal-engine`
- `paper-monitor`
- `arb-api`
- `agg-collector`
- `market-collector`
- `liq-collector`
- `position-sync`
- `risk-guard`
## 4. 版本同步
查看服务器分支和提交:
```bash
sudo git -c safe.directory=/home/fzq1228/Projects/arbitrage-engine \
-C /home/fzq1228/Projects/arbitrage-engine rev-parse --abbrev-ref HEAD
sudo git -c safe.directory=/home/fzq1228/Projects/arbitrage-engine \
-C /home/fzq1228/Projects/arbitrage-engine log --oneline -1
```
服务器拉代码:
```bash
cd /home/fzq1228/Projects/arbitrage-engine
git pull origin codex/codex_dev
```
## 5. 常见故障排查
- API 正常但策略不出信号:
- 看 `signal-engine` 日志;
- 检查 `strategies.status='running'`
- 检查 `signal_indicators` 最近 5 分钟是否持续写入。
- 有信号但不开仓:
- 查 `paper_trades` 是否达到 `max_positions`
- 查 `flip_threshold/entry_score` 是否过高;
- 查 `paper_monitor``signal_engine` 日志是否有拒绝原因。
- 数据断流:
- 检查 `agg_trades` 最新 `time_ms`
- 检查 `agg-collector`、`market-collector`、`liq-collector` 进程。

145
docs/PROBLEM_REPORT.md Normal file
View File

@ -0,0 +1,145 @@
# 项目问题报告
> 生成时间2026-03-03
> 基于 commit `0d9dffa` 的代码分析
---
## 🔴 高危问题(可能导致实盘出错)
### P1数据库从未真正迁移到云端
**现象**:你以为整个系统已经跑在 Cloud SQL 上,实际上只有 `agg_trades` 原始成交数据在双写。其他核心数据全在**本地 PG127.0.0.1**。
| 数据表 | 本地 PG | Cloud SQL |
|-------|---------|-----------|
| `agg_trades`(原始成交) | ✅ | ✅ 双写 |
| `signal_indicators`(信号输出) | ✅ | ❌ 没有 |
| `paper_trades`(模拟盘) | ✅ | ❌ 没有 |
| `rate_snapshots`(费率快照) | ✅ | ❌ 没有 |
| `market_indicators`(市场数据) | ✅ | ❌ 没有 |
| `live_trades`(实盘交易) | ❌ | ✅ 只在云端 |
| `live_config` / `live_events` | ❌ | ✅ 只在云端 |
**最致命的问题**`live_executor.py` 和 `risk_guard.py` 默认连 Cloud SQL`DB_HOST=10.106.0.3`),但 `signal_engine.py` 只把信号写到本地 PG。这意味着
- 实盘执行器读取的 `signal_indicators` 表在 Cloud SQL 里**可能是空的**
- 风控模块监控的 `live_trades` 和信号引擎写的数据完全在两个不同的数据库里
**影响**:实盘交易链路存在断裂风险,需立即核查服务器上各进程实际连接的数据库地址。
**修复方向**:统一所有进程连接到 Cloud SQL或统一连接到本地 PG通过 Cloud SQL Auth Proxy
---
### P2`users` 表双定义字段不一致
`db.py``auth.py` 各自定义了一个 `users` 表,字段不同:
| 字段 | db.py 版本 | auth.py 版本 |
|------|-----------|-------------|
| `email` | ✅ | ✅ |
| `password_hash` | ✅ | ✅ |
| `role` | ✅ | ✅ |
| `created_at` | ✅ | ✅ |
| `discord_id` | ❌ | ✅ |
| `banned` | ❌ | ✅ |
FastAPI 启动时先跑 `init_schema()`db.py 版),再跑 `ensure_auth_tables()`auth.py 版),因为 `CREATE TABLE IF NOT EXISTS` 第一次成功后就不再执行,**实际创建的是缺少 `discord_id``banned` 字段的旧版本**。
**影响**:封禁用户功能(`banned` 字段)在新装环境下可能失效。
---
### P3`signal_indicators` 表 INSERT 包含 `strategy` 字段但 schema 没有
`save_indicator()` 函数向 `signal_indicators` 插入数据时包含 `strategy` 字段(`signal_engine.py:697`),但 `SCHEMA_SQL` 里的建表语句没有这个字段(`db.py:205-224`)。
**影响**:在全新环境初始化后,信号引擎写入会报列不存在的错误。
---
## 🟡 中危问题(影响稳定性和维护)
### P4`requirements.txt` 严重不完整
文件只列了 5 个包,实际运行还需要:
| 缺失依赖 | 用于 |
|---------|------|
| `asyncpg` | FastAPI 异步数据库 |
| `psycopg2-binary` | 同步数据库signal_engine 等) |
| `aiohttp` | live_executor、risk_guard |
| `websockets``httpx` | agg_trades_collector WS 连接 |
| `psutil` | 已在文件里,但版本未锁定 |
**影响**:新机器部署直接失败。
---
### P5`market_indicators` 和 `liquidations` 表不在主 schema 中
这两张表由各自的 collector 进程单独创建,不在 `init_schema()` 里。如果 collector 没跑过signal_engine 查这两张表时会报错(会降级为默认中间分,不会崩溃,但数据不准)。
---
### P6没有 CI/CD没有自动化测试
- 代码变更完全靠人工验证
- 策略逻辑(`evaluate_signal`)没有任何单元测试,重构风险极高
- 部署流程:手动 ssh + git pull + pm2 restart
---
## 🟠 安全风险
### P7测试网密码硬编码在源代码里
三个文件里都有:
```python
os.getenv("PG_PASS", "arb_engine_2026") # db.py:19
os.getenv("DB_PASSWORD", "arb_engine_2026") # live_executor.py:44
os.getenv("DB_PASSWORD", "arb_engine_2026") # risk_guard.py:42
```
代码一旦泄露GitHub public、截图等测试网数据库直接裸奔。
### P8JWT Secret 有测试网默认值
```python
_jwt_default = "arb-engine-jwt-secret-v2-2026" if _TRADE_ENV == "testnet" else None
```
如果生产环境 `TRADE_ENV` 没有正确设置,会静默使用这个已知 secret所有 JWT 都可伪造。
---
## 🔵 架构债务(长期)
### P9三套数据库连接配置并存极易混淆
| 配置方式 | 使用的进程 | 默认连哪 |
|---------|----------|---------|
| `db.py``PG_HOST` | main.py、signal_engine、collectors | `127.0.0.1`(本地) |
| 进程内 `DB_HOST` | live_executor、risk_guard、position_sync | `10.106.0.3`Cloud SQL |
| `market_data_collector.py``PG_HOST` | market_data_collector | `127.0.0.1`(本地) |
没有统一的连接配置入口,每个进程各自读各自的环境变量,迁移时极容易漏改。
### P10前端轮询压力
`/api/rates` 每 2 秒轮询一次,用户多了服务器压力线性增长。目前 3 秒缓存有一定缓冲,但没有限流保护。
---
## 📋 建议优先级
| 优先级 | 任务 |
|-------|------|
| 🔴 立即 | 登服务器确认各进程实际连的数据库地址,核查实盘链路是否完整 |
| 🔴 立即 | 补全 `signal_indicators` 表的 `strategy` 字段 |
| 🔴 本周 | 统一数据库连接配置,所有进程用同一套环境变量 |
| 🟡 本周 | 修复 `users` 表双定义问题,合并到 auth.py 版本 |
| 🟡 本周 | 补全 `requirements.txt` |
| 🟠 本月 | 把硬编码密码移到 `.env` 文件,不进代码仓库 |
| 🔵 长期 | 添加 signal_engine 核心逻辑的单元测试 |
| 🔵 长期 | 配置 GitHub Actions 做基础 lint 和安全扫描 |

425
docs/PROJECT.md Normal file
View File

@ -0,0 +1,425 @@
# Arbitrage Engine V5.1 — 项目文档
> 最后更新2026-03-01
> 版本V5.1
> 作者:琪智科技
## 一、项目概述
**Arbitrage Engine** 是一套加密货币合约交易信号系统基于多因子评分模型实时分析市场微观结构数据Order Flow生成做多/做空信号并执行模拟盘交易。
### 核心能力
- **5层100分评分体系**:方向层(45) + 拥挤层(20) + 环境层(15) + 确认层(15) + 辅助层(5)
- **4个交易币种**BTCUSDT、ETHUSDT、XRPUSDT、SOLUSDT
- **8个信号源**6个已接入评分2个在采集中
- **模拟盘自动交易**:信号触发 → 开仓 → TP/SL → 平仓,全自动
- **实时数据采集**aggTrades、市场指标、清算数据
- **Web仪表盘**:信号展示、模拟盘监控、收益统计
### 技术栈
| 层级 | 技术 |
|------|------|
| 后端 | Python 3 + FastAPI + uvicorn |
| 前端 | Next.js 15 + React + TypeScript + Tailwind CSS |
| 数据库 | PostgreSQL 18GCP Cloud SQL |
| 进程管理 | PM2 |
| 数据源 | Binance Futures API + WebSocket |
| 反向代理 | Caddy |
### 代码统计
| 模块 | 文件数 | 代码行数 |
|------|--------|---------|
| Backend (Python) | 15个 | 4,598行 |
| Frontend (TSX/TS) | 21个 | 3,518行 |
| **总计** | **36个** | **8,116行** |
---
## 二、项目结构
```
arbitrage-engine/
├── backend/ # Python后端
│ ├── main.py # FastAPI主入口 + 全部API路由
│ ├── db.py # 数据库连接层PG同步+异步池+Cloud SQL双写
│ ├── auth.py # 用户认证系统JWT + 邀请码注册)
│ ├── signal_engine.py # 🔥 核心信号评估引擎5层评分
│ ├── paper_monitor.py # 模拟盘监控WebSocket实时TP/SL
│ ├── agg_trades_collector.py # aggTrades实时采集Binance WS
│ ├── market_data_collector.py# 市场指标采集(多空比/OI/FR等
│ ├── liquidation_collector.py# 清算数据采集Binance forceOrder
│ ├── backtest.py # 回测框架
│ ├── backfill_agg_trades.py # aggTrades历史回补工具
│ ├── admin_cli.py # 管理命令行工具
│ ├── signal_pusher.py # [已废弃] 旧版信号推送
│ ├── subscriptions.py # [预留] 订阅系统
│ ├── migrate_sqlite_to_pg.py # [一次性] SQLite→PG数据迁移
│ └── migrate_auth_sqlite_to_pg.py # [一次性] Auth SQLite→PG迁移
├── frontend/ # Next.js前端
│ ├── app/
│ │ ├── page.tsx # 首页(仪表盘概览)
│ │ ├── layout.tsx # 全局布局
│ │ ├── signals/page.tsx # 信号引擎页面5层评分展示
│ │ ├── paper/page.tsx # 模拟盘页面(持仓+历史+统计)
│ │ ├── trades/page.tsx # 历史交易页面
│ │ ├── server/page.tsx # 服务器监控页面
│ │ ├── kline/page.tsx # K线图页面
│ │ ├── live/page.tsx # 实时数据页面
│ │ ├── dashboard/page.tsx # 仪表盘页面
│ │ ├── login/page.tsx # 登录页面
│ │ ├── register/page.tsx # 注册页面(需邀请码)
│ │ ├── about/page.tsx # 关于页面
│ │ └── history/page.tsx # [占位] 历史页面
│ ├── components/
│ │ ├── Sidebar.tsx # 侧边导航栏
│ │ ├── Navbar.tsx # 顶部导航栏
│ │ ├── AuthHeader.tsx # 认证头部
│ │ ├── LiveTradesCard.tsx # 实时交易卡片
│ │ ├── FundingChart.tsx # 资金费率图表
│ │ ├── RateCard.tsx # 费率卡片
│ │ └── StatsCard.tsx # 统计卡片
│ ├── lib/
│ │ ├── auth.tsx # 前端认证逻辑JWT管理
│ │ └── api.ts # API请求封装
│ ├── next.config.ts # Next.js配置API代理
│ ├── package.json # 依赖配置
│ └── tsconfig.json # TypeScript配置
├── docs/ # 项目文档
│ └── PROJECT.md # 本文件
├── .gitignore
└── README.md
```
---
## 三、后端文件详解
### 3.1 `main.py`949行— API主入口
FastAPI应用包含全部HTTP API路由
**API路由**
| 路径 | 方法 | 功能 |
|------|------|------|
| `/api/health` | GET | 健康检查 |
| `/api/signals/latest` | GET | 最新信号数据 |
| `/api/signals/history` | GET | 历史信号 |
| `/api/paper/summary` | GET | 模拟盘概览 |
| `/api/paper/positions` | GET | 当前持仓 |
| `/api/paper/trades` | GET | 历史交易 |
| `/api/paper/equity-curve` | GET | 权益曲线 |
| `/api/paper/stats` | GET | 详细统计(支持按币种) |
| `/api/paper/config` | GET/POST | 模拟盘配置 |
| `/api/server/status` | GET | 服务器监控 |
**依赖:** FastAPI, uvicorn, asyncpg, psutil
**运行方式:** `uvicorn main:app --host 0.0.0.0 --port 4332`
---
### 3.2 `signal_engine.py`739行— 🔥 核心信号引擎
**最重要的文件**。每15秒评估一次4个币种的交易信号。
**5层评分体系100分满分**
| 层级 | 权重 | 信号源 | 逻辑 |
|------|------|--------|------|
| 方向层 | 45分 | CVD三轨(fast/mid) + P99大单 + CVD加速度 | 资金流向判断多空 |
| 拥挤层 | 20分 | 多空比 + 大户持仓比 | 市场拥挤度,反向指标 |
| 环境层 | 15分 | OI变化率 | 合约持仓量变化 |
| 确认层 | 15分 | CVD-Price背离 + 大单方向确认 | 信号交叉验证 |
| 辅助层 | 5分 | Coinbase Premium | 机构资金流向 |
**核心类:**
- `TradeWindow`滑动窗口维护buy_vol/sell_vol/CVD自动trim过期数据
- `ATRCalculator`ATR计算器用于仓位管理和TP/SL计算
- `SymbolState`每个币种的完整状态4个窗口 + ATR + 大单 + 市场指标)
**数据流:**
```
aggTrades(PG) → process_trade() → 4个TradeWindow更新
evaluate_signal() → 5层打分
score >= 60 → 开仓信号
paper_trading_open() → 写入paper_trades
```
**关键参数:**
- `WINDOW_FAST = 30min`CVD快线窗口
- `WINDOW_MID = 4h`CVD慢线窗口
- `WINDOW_DAY = 24h`P99大单计算窗口
- `EVAL_INTERVAL = 15s`:评估间隔
- `SIGNAL_THRESHOLD = 60`:开仓阈值分数
- `COOLDOWN = 300s`:同币种同方向冷却时间
**冷启动机制:**
1. 启动时`load_historical()`从PG读取最近4小时aggTrades灌入窗口
2. 前3轮(45秒)不出信号(冷启动保护)
---
### 3.3 `paper_monitor.py`179行— 模拟盘监控
独立PM2进程通过WebSocket连接Binance实时监控持仓的TP/SL。
**核心逻辑:**
1. 启动时加载所有active/tp1_hit持仓
2. 连接Binance aggTrade WS获取实时价格
3. 每笔成交检查是否触发TP1/TP2/SL
4. 触发后更新paper_trades状态
**TP/SL规则**
- TP1 = entry ± 1.5×ATR平半仓
- TP2 = entry ± 3.0×ATR平剩余
- SL = entry ∓ 1.0×ATR
- TP1触发后SL移动到成本价保本
**为什么独立进程:** TP/SL必须毫秒级响应不能和15秒评估循环共享进程。
---
### 3.4 `db.py`357行— 数据库连接层
统一的PostgreSQL连接管理支持同步和异步两种模式。
**连接池:**
- `get_sync_pool()` / `get_sync_conn()`psycopg2同步池供collector/signal_engine用
- `get_async_pool()` / `async_fetch()`asyncpg异步池供FastAPI用
- `get_cloud_sync_pool()` / `get_cloud_sync_conn()`Cloud SQL双写池
**配置(环境变量):**
- `PG_HOST`数据库地址默认127.0.0.1现在应指向Cloud SQL
- `CLOUD_PG_HOST`Cloud SQL地址10.106.0.3
- `CLOUD_PG_ENABLED`:双写开关
**Schema管理** `init_schema()` + `ensure_partitions()`(按月自动建分区表)
---
### 3.5 `agg_trades_collector.py`307行— aggTrades采集器
实时采集Binance合约aggTrades数据是数据的源头。
**架构:**
- **WebSocket主链路**4个币种各一条WS连接实时推送
- **REST补洞**断线重连后用REST API从last_agg_id追平
- **连续性巡检**每60秒检查agg_id连续性发现断档自动补洞
- **批量写入**攒200条或1秒flush一次
**双写机制:** flush_buffer同时写本地PG和Cloud SQLCloud SQL写失败不影响主流程。
**数据量:** 目前约7000万条BTC 23天 + ETH 3天 + XRP/SOL 3天
---
### 3.6 `market_data_collector.py`192行— 市场指标采集
每5分钟从Binance REST API采集市场指标存入`market_indicators`表。
**采集指标5种**
| 指标 | API | 用途 |
|------|-----|------|
| long_short_ratio | /futures/data/globalLongShortAccountRatio | 多空比 |
| top_trader_position | /futures/data/topLongShortPositionRatio | 大户持仓比 |
| open_interest_hist | /futures/data/openInterestHist | 持仓量变化 |
| coinbase_premium | Coinbase vs Binance价差 | 机构资金流 |
| funding_rate | /fapi/v1/fundingRate | 资金费率 |
---
### 3.7 `liquidation_collector.py`140行— 清算数据采集
连接Binance WebSocket `forceOrder`流,实时采集强制平仓事件。
**双层存储:**
1. `liquidations`表:每笔清算原始记录
2. `market_indicators`每5分钟聚合long_liq_usd/short_liq_usd/total/count
**Side映射** BUY order = SHORT被清算SELL order = LONG被清算
---
### 3.8 `auth.py`384行— 认证系统
基于JWT的用户认证邀请码注册制。
**功能:**
- 注册(需邀请码)/ 登录 / Token刷新
- Admin管理邀请码生成/用户封禁)
- JWT签发access_token 24h + refresh_token 7d
**安全:** scrypt密码哈希 + HMAC-SHA256 JWT签名
---
### 3.9 `backtest.py`503行— 回测框架
对历史aggTrades数据运行信号引擎验证策略表现。
**输出指标:** 胜率、PF、夏普比率、最大回撤、按方向/币种统计
---
### 3.10 其他文件
| 文件 | 行数 | 说明 |
|------|------|------|
| `backfill_agg_trades.py` | 209 | aggTrades历史数据回补REST API批量拉取 |
| `admin_cli.py` | 123 | 命令行管理工具(生成邀请码、管理用户) |
| `signal_pusher.py` | 108 | **[已废弃]** 旧版信号推送仍用SQLite可删除 |
| `subscriptions.py` | 23 | **[预留]** 订阅系统占位 |
| `migrate_sqlite_to_pg.py` | 215 | **[一次性]** SQLite→PG数据迁移脚本 |
| `migrate_auth_sqlite_to_pg.py` | 170 | **[一次性]** Auth SQLite→PG迁移脚本 |
---
## 四、前端文件详解
### 4.1 页面
| 文件 | 行数 | 功能 |
|------|------|------|
| `app/page.tsx` | 320 | 首页仪表盘(资金费率卡片 + 概览) |
| `app/signals/page.tsx` | 523 | 信号引擎4币种实时评分 + 5层分数展示 |
| `app/paper/page.tsx` | 459 | 模拟盘(当前持仓 + 历史交易 + 权益曲线 + 按币种统计) |
| `app/trades/page.tsx` | 384 | 历史交易(筛选、排序、详情) |
| `app/server/page.tsx` | 279 | 服务器监控PM2进程 + CPU/内存 + PG状态 |
| `app/kline/page.tsx` | 170 | K线图 |
| `app/live/page.tsx` | 130 | 实时数据流 |
| `app/dashboard/page.tsx` | 112 | 仪表盘 |
| `app/login/page.tsx` | 75 | 登录 |
| `app/register/page.tsx` | 88 | 注册(邀请码) |
| `app/about/page.tsx` | 81 | 关于 |
| `app/layout.tsx` | 34 | 全局布局 |
### 4.2 组件
| 文件 | 行数 | 功能 |
|------|------|------|
| `components/Sidebar.tsx` | 139 | 侧边导航栏 |
| `components/LiveTradesCard.tsx` | 124 | 实时交易卡片组件 |
| `components/FundingChart.tsx` | 95 | 资金费率图表Recharts |
| `components/RateCard.tsx` | 82 | 费率展示卡片 |
| `components/Navbar.tsx` | 71 | 顶部导航 |
| `components/StatsCard.tsx` | 57 | 统计卡片 |
| `components/AuthHeader.tsx` | 37 | 认证状态头部 |
### 4.3 工具库
| 文件 | 行数 | 功能 |
|------|------|------|
| `lib/auth.tsx` | 137 | JWT管理存储/刷新/authFetch封装 |
| `lib/api.ts` | 116 | API请求基础封装 |
---
## 五、数据库Schema
### PostgreSQLCloud SQL
**核心表:**
| 表名 | 说明 | 数据量 |
|------|------|--------|
| `agg_trades` | aggTrades分区父表 | 7039万+ |
| `agg_trades_202602` | 2月分区 | 6854万 |
| `agg_trades_202603` | 3月分区 | 185万+ |
| `agg_trades_meta` | 每币种最新agg_id | 4条 |
| `signal_indicators` | 信号评分记录每15秒×4币种 | 2.7万+ |
| `market_indicators` | 市场指标每5分钟×4币种×5指标 | 5400+ |
| `paper_trades` | 模拟盘交易记录 | 163+ |
| `liquidations` | 清算事件原始记录 | 3600+ |
| `rate_snapshots` | 资金费率快照K线图用 | 12万+ |
| `users` | 用户表 | 1 |
| `invite_codes` | 邀请码 | 1 |
| `subscriptions` | 订阅信息 | 1 |
| `refresh_tokens` | JWT刷新令牌 | — |
| `signal_indicators_1m` | 1分钟粒度信号备用 | — |
| `signal_trades` | 信号交易记录(旧) | — |
| `signal_logs` | 信号日志(旧) | — |
---
## 六、PM2进程列表
| 进程名 | 文件 | 端口 | 说明 |
|--------|------|------|------|
| `arb-api` | backend/main.py | 4332 | FastAPI后端 |
| `arb-web` | frontend/ | 4333 | Next.js前端 |
| `signal-engine` | backend/signal_engine.py | — | 信号评估引擎 |
| `paper-monitor` | backend/paper_monitor.py | — | 模拟盘TP/SL监控 |
| `agg-collector` | backend/agg_trades_collector.py | — | aggTrades采集 |
| `market-collector` | backend/market_data_collector.py | — | 市场指标采集 |
| `liq-collector` | backend/liquidation_collector.py | — | 清算数据采集 |
---
## 七、信号源清单
| # | 信号源 | 采集方式 | 存储 | 评分使用 |
|---|--------|---------|------|---------|
| 1 | CVD三轨fast 30m / mid 4h | aggTrades WS实时计算 | 内存 | ✅ 方向层 |
| 2 | P99大单流 | aggTrades实时统计 | 内存 | ✅ 方向层 |
| 3 | CVD加速度 | CVD差分计算 | 内存 | ✅ 方向层+5 bonus |
| 4 | 多空比 + 大户持仓比 | Binance REST 5min | market_indicators | ✅ 拥挤层 |
| 5 | OI变化率 | Binance REST 5min | market_indicators | ✅ 环境层 |
| 6 | Coinbase Premium | Coinbase+Binance价差 | market_indicators | ✅ 辅助层 |
| 7 | Funding Rate | Binance REST 5min | market_indicators | ⬜ 采集中 |
| 8 | 清算数据 | Binance WS forceOrder | liquidations + market_indicators | ⬜ 采集中 |
---
## 八、模拟盘配置
| 参数 | 值 |
|------|-----|
| 初始资金 | $10,000 |
| 单笔风险 | 2%$200 = 1R |
| 最大同时持仓 | 4个 |
| TP1 | 1.5× ATR平半仓 |
| TP2 | 3.0× ATR平剩余 |
| SL | 1.0× ATR |
| 手续费 | Taker 0.05% × 2开仓+平仓) |
| 反向信号 | 先平仓再开新仓 |
| 冷却时间 | 同币种同方向300秒 |
---
## 九、部署信息
| 项目 | 详情 |
|------|------|
| 运行服务器 | GCP asia-northeast1-b (n2-standard-2, 2核8G) |
| 数据库 | GCP Cloud SQL PG18 (8核64G 100G) |
| Cloud SQL 内网IP | 10.106.0.3 |
| Cloud SQL 公网IP | 34.85.117.248 |
| Web访问 | https://arb.zhouyangclaw.com |
| Git仓库 | https://git.darkerilclaw.com/lulu/arbitrage-engine.git |
| 反向代理 | CaddySSL自动 |
---
## 十、版本路线图
| 版本 | 状态 | 内容 |
|------|------|------|
| V5.0 | ✅ 完成 | 基础信号系统 + PG迁移 |
| V5.1 | ✅ 当前 | 5层评分 + 模拟盘 + 6信号源评分 + 2信号源采集 |
| V5.2 | 📋 计划 | FR+清算加入评分 + 策略配置化 + AB测试 + 24h warmup |
| V5.3 | 📋 远期 | 推特新闻情绪信号(多模型投票制) |
---
## 十一、已知问题与待改进
1. **signal_pusher.py 已废弃**仍用SQLite应删除或重写
2. **subscriptions.py 空文件**:预留的订阅系统未实现
3. **history/page.tsx 空页面**只有5行占位代码
4. **冷启动warmup只有4小时**P99大单需要24小时数据V5.2改进)
5. **开仓价用信号评估价**:实盘需改为真实成交价
6. **双写机制**切主库后agg_collector的本地双写可关闭
7. **前端缺少错误边界**API异常时无友好提示

View File

@ -1,497 +0,0 @@
# 策略工厂与信号引擎验证清单Strategy Factory & Signal Engine Validation
> 目的:把需要“逐项验证”的点全部列清楚,变成可打勾的 checklist。
> 范围:`backend/signal_engine.py`single-engine 数据发射源) + V5.4 策略工厂(`strategies` 表 → 信号 / 模拟盘)。
> 权威规格仍以 `docs/arbitrage-engine-full-spec.md` 为准,本文件只列“需要检验的点”。
---
## 1. 背景与角色划分(人话版)
- single-engine`backend/signal_engine.py`)在本系统里扮演的是:
- 从 `agg_trades` + `market_indicators` 等数据源,滚动算出各种指标;
- 每 15 秒左右,对每个运行中策略打分、判断是否开仓;
- 把结果写入:
- `signal_indicators`(信号与指标快照);
- `paper_trades`(模拟盘开仓记录);
- 并通过 NOTIFY 推送给其他进程。
- V5.4 策略工厂的职责是:
- 把 `strategies` 表中 `status='running'` 的每一行,当成一个“策略实例”;
- 对每个实例,组合:
- 多窗口 CVD5m / 15m / 30m / 1h / 4h
- ALT 四层评分Direction / Crowding / Environment / Auxiliary
- 5 个否决 Gatevol / cvd / whale / obi / spot_perp的阈值与开关
- 在统一的信号模型上,跑出不同风格的策略。
当前假设:
- 你只对 **aggTrades 数据流** 有信心,其他所有东西(指标/门控/评分/策略映射/落库)都需要重新审计;
- 我们需要一个尽可能完整的“验证清单”,后续可以按照这份清单逐条打勾。
下面把需要检查的点分成 3 层:
- A. 数据与指标层:所有输入信号是不是对的(基础指标本身)
- B. 策略配置与工厂层DB 字段是否正确映射成策略参数
- C. 决策与落库层:评分 / 门控 / 开仓 / 写库 / 冷却 / 方向限制等是否符合规则
---
## 2. 总体结构视图(先有个地图)
- **数据来源**
- `agg_trades`:逐笔成交 → 多窗口 CVD / ATR / VWAP / 大单 P95/P99 / 巨鲸成交;
- `market_indicators`OI、多空比、Top Trader、资金费率、Coinbase Premium、OBI、期现背离等
- 实时 WebSocketOBI 实时值、spot-perp divergence 实时值(如果有)。
- **策略配置**
- `strategies`策略实例的参数symbol、方向、CVD 窗口、四层权重、五门阈值、TP/SL、timeout、flip_threshold 等);
- `load_strategy_configs_from_db()`:把这些行映射成内部配置 dict。
- **决策 / 落库**
- `evaluate_factory_strategy()`(原 `_evaluate_v53`):统一的评分与 Gates 逻辑;
- `save_indicator()`:写 `signal_indicators`
- `paper_open_trade()`:写 `paper_trades`
- `NOTIFY new_signal`:推送给 live_executor 等其他进程。
这 3 层每一处的小错误,都可能直接导致“赚钱 / 亏钱”的行为偏差,所以需要逐项验证。
---
## 3. A 层数据与指标验证single-engine 基础指标)
### A1. 时间线与窗口基础
- ✅ `agg_trades.time_ms` 的语义是否统一为 **毫秒时间戳**,无混用秒/毫秒的情况。
- ✅ 冷启动加载历史时,时间范围是否覆盖至少 `24h+`足够支持所有窗口5m/15m/30m/1h/4h/24h
- 当前实现:冷启动预载 4hWINDOW_MID之后由实时数据自然填充到 24h。此行为已知且暂视为接受的设计不视为 bug。
- ✅ 滚动窗口裁剪规则是否一致(例如统一使用 `t >= now_ms - window_ms`),避免 off-by-one。
- ✅ 历史回补或乱序插入时,是否会导致窗口内时间回跳,进而影响 CVD/ATR 等指标。
- 当前实现agg_trades_collector 通过 REST 补洞 + 连续性巡检,配合按 agg_id/time_ms 有序读取,认为不会产生系统性时间回跳问题。
### A2. CVD 多窗口计算
目标:确认 5m / 15m / 30m / 1h / 4h 所有 CVD 计算都符合“主动买入量 - 主动卖出量”的定义。
- ✅ 对 is_buyer_maker 的解释是否正确:
- 实现:采集时 `is_buyer_maker = 1 if t["m"] else 0`CVD 里 `is_buyer_maker == 0` 计为买量taker 买),`==1` 计为卖量taker 卖);
- 结论:与 Binance aggTrade 语义一致CVD = taker 买入量 - taker 卖出量,符合预期。
- ✅ 基础 30m / 4h 窗口(`win_fast` / `win_mid`)中,新增/裁剪 trade 时 buy/sell 累计是否严格同步更新。
- 实现:所有 trade 通过 `TradeWindow.add()` 进入窗口,`trim()` 时同步回退 buy/sell 累计CVD 始终等于当前窗口内的买减卖。
- ✅ 其他窗口5m/15m/1h的 CVD 是否通过对已有窗口win_fast/win_mid的列表按时间切片重算而不是重新查库。
- 实现:通过 `_window_ms()` 把窗口字符串转毫秒,从 `win_fast`/`win_mid` 的 `trades` 列表按 `t_ms >= now_ms - window_ms` 切片重算,未重复查库。
- ✅ 所有窗口的截止时间点是否都使用当前 `now_ms`不会出现“5m 用旧 now_ms30m 用新 now_ms”的错位。
- 实现:`build_evaluation_snapshot(now_ms)` 与 `evaluate_factory_strategy(now_ms, ...)` 使用同一个 `now_ms`,动态切片和快照在同一时间基准上计算。
- ⚠️ 斜率与加速度:
- ✅ 定义:`cvd_fast_slope = cvd_fast(now) - prev_cvd_fast``cvd_fast_accel = cvd_fast_slope(now) - prev_cvd_fast_slope`,在持续运行时语义正确;
- ⚠️ 冷启动第一帧使用 `prev_* = 0` 计算,未做专门回退为 0 的处理,可能使第一帧 slope/accel 数值偏大;目前视为需要后续评估/是否调整的注意点。
### A3. ATR 与 ATR 百分位
- ✅ 5m K 线 bucket 是否按 `bar_ms = (time_ms // period_ms) * period_ms` 计算,保证时间对齐。
- 实现:`ATRCalculator.update()` 中按 `time_ms // period_ms` 对齐 5m bar符合预期。
- ✅ Candle 的 open/high/low/close 更新逻辑是否正确,所有 trade 都被处理到。
- 实现:新 bar 时写入旧 bar 到队列,当前 bar 逐笔更新 high/low/close。
- ✅ TRTrue Range计算是否符合标准定义
- 实现:`max(high - low, |high - prev_close|, |low - prev_close|)`,与标准 TR 定义一致。
- ✅ ATR 是按何种算法计算(简单平均 / Wilder EMA与 full-spec 中的期望是否一致。
- 实现:先取第一根 TR 作为初始值,再用 `(atr_val*(length-1)+tr)/length` 逐步平滑,为 Wilder 风格 EMA符合设计。
- ✅ 当 candle 数量不足(小于 2 或小于 ATR lengthATR 返回 0 或合理默认值。
- 实现:`len(candles) < 2` 时直接返回 0.0避免未成熟 ATR 参与决策
- ✅ `atr_percentile`
- ✅ 历史 ATR 样本长度、维护方式(队列长度)是否合理;
- ✅ 百分位算法(<= current / count是否正确
- ✅ 样本太少时是否返回 50.0,避免无意义的极端值。
- 实现:`atr_history` 长度上限 288少于 10 条或当前 ATR=0 时返回 50.0,其余按 rank 百分位计算。
### A4. P95 / P99 大单阈值
- ✅ 使用的样本是否来自 24h 窗口(`win_day`),并且只取 qty不乘 price
- 实现:`compute_p95_p99()` 直接遍历 `win_day.trades`,取 `t[1]`qty构造样本数组窗口长度即 24h。
- ✅ 样本数 < 100 时是否使用保底常数BTC: p95>=5, p99>=10ALT: p95>=50, p99>=100是否与策略设计一致。
- 实现:少于 100 条时直接返回 `(5.0, 10.0)`;样本足够时再按 symbol 区分 BTC/ALT 保底。
- ✅ 排序和索引算法:`sorted(qtys)` 后,`int(n * 0.95)` / `int(n * 0.99)` 是否符合预期的分位点定义。
- 实现:对 qtys 排序后按整数下标取 95% / 99% 位置,属于合理的分位点近似。
- ✅ ALT 的分类逻辑是否只是“非 BTC 即 ALT”以及这是否满足未来新增 symbol 的需求。
- 实现:判断条件为 `"BTC" in self.symbol`,否则按 ALT 处理;目前支持的 symbol 集合有限BTC/ETH/XRP/SOL该简化规则可接受如未来扩展再细化。
### A5. 巨鲸成交与 `whale_cvd_ratio`
- ✅ 巨鲸交易过滤条件是否为 `price * qty >= 100_000`100k USD阈值可配置或硬编码
- 实现:`usd_val = price * qty``usd_val >= 100_000` 视为巨鲸成交,阈值目前硬编码为 100k。
- ✅ `_whale_trades` 窗口长度是否为 15 分钟,裁剪逻辑是否正确(按 time_ms
- 实现:窗口长度 `WHALE_WINDOW_MS = 15min`,按 trade 的 `time_ms` 滑动裁剪。
- ✅ `whale_cvd_ratio` 计算:
- ✅ buy_usd = 所有巨鲸买单金额之和;
- ✅ sell_usd = 所有巨鲸卖单金额之和;
- ✅ ratio = (buy_usd - sell_usd) / (buy_usd + sell_usd)
- ✅ 无数据时返回 0.0 是否符合预期。
- ✅ BTC 与 ALT 对巨鲸数据的使用差异是否符合设计:
- 实现BTC 用 `whale_cvd_ratio`(或 DB 中 `tiered_cvd_whale`)做 GateALT 使用 `recent_large_trades` 中的对立/同向大单判断。
- 备注:鲸鱼 Gate 的“占比阈值”在 C2 中有一个缩放 bug`whale_flow_pct` 被多除以 100已在 Gate 小节标记为问题点。
### A6. `market_indicators` 中的 JSON 指标
需要逐个核对字段名称与意义(对照实际 JSON
- ✅ `long_short_ratio`
- 实现:从 `market_indicators` 读取 JSON `value`,使用 key `longShortRatio` 转为 float默认 1.0。
- ✅ `top_trader_position`
- 实现:使用 key `longAccount`,代表 top trader 多头占比0~1
- ✅ `open_interest_hist`
- 实现:使用 key `sumOpenInterestValue` 作为 OI 数值,后续只用来计算相对变化率。
- ✅ `coinbase_premium`
- 实现:使用 key `premium_pct`,并统一除以 100 转成小数,避免量纲混乱。
- ✅ `funding_rate`
- 实现:优先使用 `fundingRate`,否则 fallback `lastFundingRate`
- ✅ `obi_depth_10`
- 实现:使用 key `obi`,作为 [-1,1] 区间内的订单簿不平衡(正=买压,负=卖压),与 Gate4 逻辑一致。
- ✅ `spot_perp_divergence`
- 实现:使用 key `divergence`,注释中定义为 `(spot - mark) / mark`,与 Gate5 使用方式一致。
### A7. OI 环境指标
- ✅ OI 读取:`open_interest_hist` 是否转换为 float
- 实现:通过 `to_float(self.market_indicators.get("open_interest_hist"))` 转为 float。
- ✅ OI 变化率定义:`oi_change = (oi_now - oi_prev) / oi_prev`oi_prev>0 的保护是否到位。
- 实现:仅在 `prev_oi_value > 0` 时计算变化率,否则 oi_change=0.0。
- ✅ environment_score_raw 的区间划分(例如 >=3%、0~3%、<0对应基础分是否与 full-spec 一致
- 实现oi_change >=3% → 15 分;>0 → 10 分;<=0 → 5 分,符合设计。
- ✅ `prev_oi_value` 的更新时机:只在 oi_value>0 时更新是否会导致长时间停留在 0。
- 实现oi_value>0 时才覆盖 prev启动后首帧用默认 10 分,后续随 OI 变化更新,行为符合预期。
### A8. 其他环境与拥挤指标
- ✅ `long_short_ratio` 的逻辑:>1 多头拥挤,<1 空头拥挤这个假设需要与真实数据验证
- 实现LONG 方向时 lsr 较低(<1,<0.7,<0.5给更高分SHORT 方向时 lsr 较高>1,>1.5,>2给更高分符合“多空占比”直觉。
- ✅ crowding 层中对 LONG / SHORT 的条件:
- 实现与上述区间一致,并在 V5.1 路径中用 crowding_score=ls_score+top_trader_scoreV5.3 路径中 crowding_score capped 为 25 分。
- ✅ `top_trader_position` 映射:
- 实现LONG>=0.55 给 10 分、<=0.45 给 0 分、其他给 5 分SHORT 反向对应“top trader 站在我们这一边时加分”的逻辑。
- ✅ `coinbase_premium` 阈值:
- 实现:>0.0005 / <-0.0005 视为强信号得 5 |premium|<=0.0005 2 其他情况 0 full-spec 描述一致
- 备注:精确阈值是否需要微调属于策略优化问题,暂不视为实现 bug。
### A9. 时间与时区统一
- ✅ `agg_trades.time_ms`、`market_indicators.timestamp_ms`、`signal_indicators.ts` 是否全部为 UTC 毫秒。
- 实现agg_trades 来自 Binance aggTrade `T`毫秒market_indicators 查询按 `timestamp_ms` 排序signal_indicators.ts 由 `int(time.time()*1000)` 生成,语义统一为毫秒。
- ✅ signal_engine 的 `now_ms` 是否与最新成交的时间差在可接受范围内(比如 <5 )。
- 实现:每轮循环用当前 `time.time()` 作为 now_ms立刻用 `fetch_new_trades` 拉取自上次 agg_id 之后的 tick 并入窗口,逻辑上不会积累大延迟。
- ✅ 所有窗口、冷却时间、timeout 等是否都用毫秒表达,避免混用秒/分钟。
- 实现窗口常量、冷却时间、timeout 以及 liquidations 窗口等全部以毫秒表示。
---
## 4. B 层策略配置与工厂验证DB → 内存策略)
### B1. 行筛选与顺序
- ✅ `load_strategy_configs_from_db()` 是否只选择 `status='running'` 的行。
- 实现SQL 显式 `WHERE status = 'running'`
- ⚠️ 是否有必要基于 `schema_version` 做过滤(未来版本升级时)。
- 实现:当前未按 `schema_version` 过滤,所有 running 策略视为同一版本;如未来引入多版本策略,需要补充。
- ✅ ORDER BY created_at 是否会在行为上产生影响(一般无关,但需要意识到)。
- 实现:仅影响策略评估与写入的顺序,对行为影响有限。
### B2. 字段映射完整性
逐字段确认 DB → cfg 的映射:
- ✅ `strategy_id`uuid是否被转为 string并赋值到 `cfg["strategy_id"]`
- 实现SQL 中 `strategy_id::text`,后续直接写入 cfg。
- ✅ `display_name` 是否写入 `cfg["strategy_name_snapshot"]`
- ✅ `symbol` / `direction` 是否原样保留,大小写约定是否固定。
- 实现:直接从 DB 取值赋入 cfg不做大小写转换。
- ✅ `cvd_fast_window` / `cvd_slow_window` 是否直接映射到 cfg。
- ✅ `weight_direction` / `weight_env` / `weight_aux` / `weight_momentum` 是否正确组成 `cfg["weights"]`
- ✅ `entry_score` / `flip_threshold` 是否正确映射到 `cfg["threshold"]` / `cfg["flip_threshold"]`
- ✅ `gate_obi_enabled` / `gate_whale_enabled` / `gate_vol_enabled` / `gate_spot_perp_enabled` / `gate_cvd_enabled` 是否被正确翻译成 bool。
- 实现:直接使用 DB 中的 boolean 字段。
- ✅ 所有 Gate 相关 float 字段(`obi_threshold`、`whale_usd_threshold`、`whale_flow_pct`、`vol_atr_pct_min`、`spot_perp_threshold`
在 None 时有合理默认(与 full-spec 匹配)。
- 实现:在转入 cfg 时对 None 做 `or 默认值` 处理,默认值来源于迁移脚本/旧 symbol_gates。
- ✅ `sl_atr_multiplier` / `tp1_ratio` / `tp2_ratio` 是否正确映射到 `cfg["tp_sl"]`
- ✅ `timeout_minutes` 是否正确映射到 `cfg["timeout_minutes"]`
### B3. legacy 策略兼容逻辑
- [ ] 固定 UUID
- `...000053``"v53"`
- `...000054``"v53_middle"`
- `...000055``"v53_fast"`
是否只对 deprecated legacy 策略生效。
- [ ] 其他策略是否统一命名为 `custom_<uuid前8位>`,与历史 `strategy` 字段约定一致。
- [ ] 当 DB 无策略时,是否 fallback 到 JSON 文件配置;当 DB 有策略时是否会重复加载 JSON。
更新:
- ✅ 固定 UUID 的映射逻辑正确,且只通过 `LEGACY_UUID_MAP` 在内存层生效,不会修改 DB 中的 strategy_id。
- ✅ 其他策略统一命名为 `custom_<uuid前8位>`,与历史 `strategy` 字段兼容。
- ✅ main() 中当 DB 读不到策略(异常)时才 fallback 到 JSON正常情况下不会 DB+JSON 混用。
### B4. CVD 窗口参数
- ✅ `cvd_fast_window` / `cvd_slow_window` 的合法取值集合:是否只支持 {5m,15m,30m,1h,4h}。
- 实现:`_window_ms()` 支持任意 m/h策略层目前只用上述几种组合。
- ✅ 非法值时行为:抛出异常还是回退到默认 30m/4h。
- 实现:未匹配 "m"/"h" 后缀时回退到 30m对异常配置有兜底。
- ✅ `_window_ms()` 的实现是否覆盖所有这些枚举值。
- ✅ `evaluate_factory_strategy()` 中选择 trade 源时fast/slow 窗口与 win_fast/win_mid 的映射是否一致。
- 实现fast 窗口长度 <=30m 用 win_fast否则用 win_midslow 总是用 win_mid与 full-spec 描述一致。
### B5. 四层权重与 entry/flip
- ✅ `weights` 是否已真实参与 scoringdirection/env/aux/momentum 四层权重)。
- 现状V5.4 `strategies` 中的权重字段被映射到 cfg["weights"]direction/env/aux/momentum`evaluate_factory_strategy()` 会将四个权重归一化到 100 分,并把 direction+momentum 总权重按 55:25 的比例拆分给 direction/crowding再按各层原始得分比例缩放真实影响 total_score。
- ✅ `entry_score` 是否只用于“达到该分数即可考虑开仓”,不会在别处被重载。
- 实现:在 `evaluate_factory_strategy()` 中作为标准开仓阈值total_score < entry_score 时不会产生 signalsignal_engine 通过是否有 signal 决定是否尝试开仓
- ✅ `flip_threshold` 是否用于“重仓 / 反向强平”逻辑,阈值关系是否是 `flip_threshold >= entry_score`
- 实现:在 `evaluate_factory_strategy()`total_score >= flip_threshold 时记为重仓 tier="heavy";在 `signal_engine` 中,当反向信号的 `score >= flip_threshold` 时触发 signal_flip 平仓。代码允许设置任意数值,上层应保证配置关系。
- ⚠️ 当 `flip_threshold < entry_score` 时,系统行为是否有定义或需要禁止。
- 现状:代码未禁止这种配置,会导致实际行为变成“更容易触发 heavy 仓”;需要在策略配置侧约束,暂不视为实现错误。
### B6. Gate 参数与开关
逐 Gate 检查 DB → cfg 的映射:
- [ ] Gate vol
- `gate_vol_enabled``gates["vol"]["enabled"]`
- `vol_atr_pct_min``gates["vol"]["vol_atr_pct_min"]`,默认值与 full-spec 一致。
- [ ] Gate cvd
- `gate_cvd_enabled``gates["cvd"]["enabled"]`
- 默认是否为 True并与 full-spec 一致。
- ✅ Gate whale
- `gate_whale_enabled``gates["whale"]["enabled"]`
- `whale_usd_threshold``gates["whale"]["whale_usd_threshold"]`
- ✅ `whale_flow_pct``gates["whale"]["whale_flow_pct"]`(注意是否有 /100 的处理)。
- 更新:`evaluate_factory_strategy()` 中已去掉多余的 `/100` 缩放,现使用 0~1 的比例值与 DB/迁移脚本一致,鲸鱼 Gate 灵敏度恢复到设计水平。
- [ ] Gate obi
- `gate_obi_enabled``gates["obi"]["enabled"]`
- `obi_threshold``gates["obi"]["threshold"]`
- [ ] Gate spot_perp
- `gate_spot_perp_enabled``gates["spot_perp"]["enabled"]`
- `spot_perp_threshold``gates["spot_perp"]["threshold"]`
### B7. 策略方向限制
- ⚠️ DB 中的 `direction` 字段枚举是否固定为 `"long" | "short" | "both"`(大小写统一)。
- 现状:代码层直接使用 DB 文本,不验证枚举/大小写;依赖上游迁移与 API 保证有效值。
- ✅ `evaluate_factory_strategy()` 或上层逻辑中是否有对该字段的应用:
- 预期direction=long → 禁止 SHORT 信号开仓direction=short → 禁止 LONG 信号开仓direction=both → 不限制。
- 现状:已在 `evaluate_factory_strategy()` 内增加 per-strategy 方向约束:
- long 策略在方向判定为 SHORT 时不会产生 signal但仍计算评分与指标快照
- short 策略在方向判定为 LONG 时不会产生 signal
- both 不做限制。
- ⚠️ 若当前实现未对 strategy direction 做约束,需要在此记为一个重点检查项。
- 更新:方向约束已在信号生成阶段生效,但反向 signal_flip 平仓仍按“环境方向”判断,是否也需要 direction 约束留待后续决策。
---
## 5. C 层:决策与落库验证(评分 + 门控 + 开仓 + 写库)
### C1. 逐 symbol / 逐策略评估流程
- ✅ 评估循环是否为:先按 symbol 分组,再对每个 symbol 内的所有策略依次评估。
- ⚠️ 对于每个策略,是否在评估前检查 `cfg["symbol"] == state.symbol`,确保不会在错误币种上开仓。
- 现状:评估阶段对所有策略都计算分数,但在开/平仓阶段才按 `strategy_cfg["symbol"]` 过滤,因此不会在错误币种上实际开仓,只是多算了一些“无用分”。
- ✅ 是否存在“一个策略在多个 symbol 上同时运行”的预期场景,如果有,需要明确规则。
- 现状:当前配置一条策略只绑定一个 symbol引擎逻辑允许同一策略在多个 symbol 开仓,但上层 API 不这么用。
### C2. 五个 Gate 的逻辑
对于每一门,需要验证条件与 full-spec 一致:
- Gate1波动率门ATR/price 下限)
- ✅ `atr_pct_price = atr / price` 的计算是否正确;
- ✅ 阈值 `min_vol` 是否来自 DB 的 `vol_atr_pct_min` 或 JSON fallback
- ✅ 条件:`atr_pct_price < min_vol` gate_block = `"low_vol(...)"`,并且后续直接视为不通过。
- Gate2CVD 共振门
- ✅ 快慢 CVD 同向时方向判断是否为:
fast>0 且 mid>0 → LONGfast<0 mid<0 SHORT
- ✅ 不同向时,若 gate_cvd_enabled=True则设置 `no_direction=True``gate_block="no_direction_consensus"`
- ✅ 当 Gate2 关闭enabled=False是否允许仅根据 fast CVD 决定方向。
- Gate3鲸鱼否决门
- BTC
- ✅ 使用 `whale_cvd_ratio` 或 fallback 到 `market_indicators["tiered_cvd_whale"]`
- ✅ 条件:
- LONG 且 whale_cvd_ratio < -阈值 否决
- SHORT 且 whale_cvd_ratio > 阈值 → 否决。
- ALT
- ✅ `recent_large_trades` 中,对立大单与同向大单的判定逻辑是否正确;
- ✅ 只有“有对立大单且无同向大单”时才否决,且金额阈值是否为 whale_usd_threshold × price。
- Gate4OBI 否决门
- ✅ OBI 采样顺序是否为:先用实时 `rt_obi`,否则用 DB 中 `obi_depth_10`
- ✅ 条件:
- LONG 且 obi_raw < -obi_veto Gate 拒绝
- SHORT 且 obi_raw > obi_veto → Gate 拒绝。
- Gate5期现背离否决门
- ✅ 采样顺序:先用实时 `rt_spot_perp_div`,否则用 DB 中 `spot_perp_divergence`
- ✅ 条件:
- LONG 且 divergence < -spd_veto Gate 拒绝
- SHORT 且 divergence > spd_veto → Gate 拒绝。
### C3. gate_block / gate_passed 对评分和信号的影响
- ✅ 任意一门触发后,`gate_block` 是否被设置为对应字符串,`gate_passed=False`。
- ✅ 评分计算完后,如果 `gate_passed=False`,是否将 `total_score` 强制置为 0。
- ✅ `result["signal"]` 在 gate_passed=False 或 no_direction=True 时是否总是 None。
- ✅ `factors` 中是否仍然保留原始四层分数和 Gate 细节,方便事后分析。
### C4. 四层评分计算细节
- Direction Layer
- ✅ CVD 共振基础分数(例如 30 分)是否与 full-spec 一致;
- ✅ p99_flow有同向大单 / 无大单 / 有对立大单 三种情况的得分是否符合预期;
- ✅ accel_bonuscvd_fast_accel 与方向同向时报 5 分;
- ✅ v53_fast 的独立加速路径(不要求双线共振)是否正确实现。
- Crowding Layer
- ✅ long_short_ratio 与 top_trader_position 的区间划分与得分是否符合 full-spec
- ✅ crowding_score capped 到 25 分。
- Environment Layer
- ✅ ALT或默认路径只用 OI 变化率映射出 environment_score_raw
- ✅ v53_fast 额外的 OBI bonus强/弱阈值与得分 5/3 是否正确;
- ✅ 总分 capped 到 15。
- Auxiliary Layer
- ✅ coinbase_premium 的区间与得分5/2/0是否正确实施。
- 汇总:
- ✅ total_score = 四层得分之和 capped 到 100并四舍五入到 0.1
- ✅ 如果 `gate_passed=False`,最终 total_score 是否强制为 0。
### C5. 权重的实际使用情况
- ✅ 当前实现已经使用 `weights` 来调整各层最大分/权重direction/env/aux/momentum需要确认缩放规则是否符合预期
- ✅ future如要进一步调整权重逻辑需要设计迁移/验证方案,并评估对历史统计的影响。
### C6. signal 生成与冷却entry / flip / cooldown
- ✅ 条件逻辑:
- total_score >= flip_threshold → `tier="heavy"`
- entry_score <= total_score < flip_threshold `tier="standard"`
- total_score < entry_score 不开仓
- ✅ 冷却:
- COOLDOWN_MS = 10 分钟;
- 以 `strategy_name` 为 key 的 last_signal_ts 是否正确记录;
- 冷却期内不再生成新 signal。
- ✅ `direction` 限制:
- 策略 direction=long/short/both 是否在最终生成 signal 和 signal_flip 时被应用:
- 只多策略:不会生成空头 signal也不会因为空头评估方向触发 signal_flip 平仓;
- 只空策略:反之同理;
- both行为与原来一致。
### C7. `signal_indicators` 落库字段映射
- ✅ ts是否使用当前 `now_ms`,单位为毫秒。
- ✅ symbol与当前 `SymbolState.symbol` 一致。
- ✅ strategy写入 `cfg["name"]`legacy 名称)。
- ✅ strategy_id写入 `cfg["strategy_id"]`uuid string
- ✅ strategy_name_snapshot写入 `cfg["strategy_name_snapshot"]`
- ✅ 所有指标字段:
- cvd_fast / cvd_mid / cvd_day / cvd_fast_slope
- atr_5m / atr_percentile / atr_value
- vwap_30m / price
- p95_qty / p99_qty
- cvd_fast_5m仅 v53_fast 有值);
是否与 `snapshot` / 重新计算的一致。
- ✅ factorsJSON
- 是否包含 gate_passed / gate_block / atr_pct_price / obi_raw / spot_perp_div / whale_cvd_ratio
- direction / crowding / environment / auxiliary 四个子对象的 score/max 等字段是否齐全。
### C8. `signal_feature_events`(如仍在使用)
- ✅ 只对 v53 系列策略写入(策略名以 "v53" 开头)。
- ✅ 原始字段cvd_fast_raw 等)是否与 `result` 中的一致。
- ✅ score_direction / score_crowding / score_environment / score_aux 是否与 factors 中一致。
- ✅ gate_passed / block_reason 是否正确记录,便于后期分析。
### C9. `paper_open_trade` 模拟盘开仓逻辑
- ✅ ATR <= 0 时是否直接拒绝开仓,避免无意义 SL/TP。
- ✅ SL/TP 价格计算:
- LONG`SL = price - risk_distance``TP1 = price + tp1_ratio × risk_distance``TP2 = price + tp2_ratio × risk_distance`
- SHORT`SL = price + risk_distance``TP1 = price - tp1_ratio × risk_distance``TP2 = price - tp2_ratio × risk_distance`
- 旧版 JSON 策略仍按 `tp*_mult × ATR` 方式计算,属于兼容分支;
是否与 full-spec 中“以 R 计目标”的定义一致。
- ✅ risk_distance = sl_multiplier × ATR 是否正确计算并写入。
- ✅ SL 合理性校验:
- 实际 SL 距离是否在 [0.8, 1.2] × risk_distance 之间;
- 不满足时是否拒绝开仓并打日志(避免奇怪配置)。
- ✅ 模拟盘全局开关:
- `PAPER_TRADING_ENABLED` / `PAPER_ENABLED_STRATEGIES` / `PAPER_MAX_POSITIONS` 是否生效;
- 同一策略是否有 per-strategy 的最大持仓控制(如无,需要记录)。
- ✅ 写库字段:
- symbol / direction / score / tier / entry_price / entry_ts
- tp1_price / tp2_price / sl_price / atr_at_entry
- score_factors = factors JSON
- strategy = `cfg["name"]`
- strategy_id / strategy_name_snapshot
- risk_distance
是否全部正确写入,对应字段类型与含义与 full-spec 一致。
### C10. 重复开仓与持仓交互
- ✅ 当前设计是否允许“同一策略在同一 symbol 上同时有多笔持仓”(分批进场)。
- ✅ 是否存在逻辑限制“同一策略同方向仅允许一笔活动持仓”,如果没有,需要明确这一点。
- ✅ 与 `paper_monitor` 的交互:
- 反向 signal 是否会触发 signal_flip 平仓;
- 平仓后冷却和再开仓的行为是否符合预期。
### C11. 通知与其他 side-effect
- ✅ 当 `result["signal"]` 非空时,是否总是触发 `NOTIFY new_signal`payload 格式是否是:
`"symbol:strategy:signal:score"`
- ✅ 当 gate_block 不为空gate_passed=False或 no_direction=True 时,是否绝不会发 NOTIFY。
- ✅ 如果 live_executor 等进程依赖该 payload是否已经约定好格式且长期不变。
---
## 6. 验证优先级建议
后续可以按下面优先顺序逐项打勾:
**P0必须最先验证的**
- A2CVD 多窗口计算5m/15m/30m/1h/4h 全链路)。
- A3+A9ATR / ATR 百分位 / 波动率门。
- A5+A6巨鲸、OBI、期现背离的数据源与符号方向。
- C2+C3+C4+C6五个 Gate + 四层评分 + entry/flip + cooldown 的交互。
- C7+C9`signal_indicators` / `paper_trades` 落库字段,特别是 strategy_id / strategy_name_snapshot 与 SL/TP/risk_distance。
**P1中优先级**
- B2~B7`strategies` 表字段映射完整性、CVD 窗口、Gate 参数、方向限制。
- C1per-symbol 遍历逻辑与 symbol 过滤。
- C8`signal_feature_events` 的字段映射(如还在使用)。
**P2相对低优先级**
- 对权重缩放逻辑的长期行为做验证C5确保与预期一致
- 日志/监控是否足够支撑线上排查;
- 未来如需长期在线学习,可在此基础上加更多验证点。
---
## 7. 使用方式(给人类 / AI 的操作建议)
- 把本文件当作 **审计 checklist**
- 每次改动前,先在这里找相关条目,确认“期望行为”是什么;
- 审计现有代码时,对照这些条目逐项比对,实现与期望不一致的地方要记录为 bug。
- 对于 P0 条目,建议配合:
- 真实历史数据回放(从数据库抽样一段时间);
- 针对单一策略 / 单一 symbol 的离线重算脚本(对比 signal_indicators / paper_trades
- 改动任何 C 层(评分/开仓/落库)逻辑时,必须同时:
- 更新 `docs/arbitrage-engine-full-spec.md` 中对应章节;
- 在本验证清单中勾选/更新受影响的条目;
- 附上简短的“为什么这样改 + 对历史行为的影响”说明。
> 一句话:
> 以后任何人(包括 AI想动 signal_engine / 策略工厂,都应该先看 full-spec再看这份验证清单
> 确保“为什么这么算”和“有没有算对”都在一个有记录的地方。

61
docs/V52-TODO.md Normal file
View File

@ -0,0 +1,61 @@
# V5.2 待修复清单
> 来源Claude Code审阅报告 + 露露复查
> 创建2026-03-01
## 已在V5.1-hotfix中修复P0
| ID | 问题 | 修复 |
|----|------|------|
| P0-1 | 冷却期阻断反向信号平仓 | evaluate_signal始终输出direction主循环基于direction+score>=60触发反向平仓 |
| P0-2 | pnl_r TP场景虚高2倍 | paper_monitor+signal_engine统一用(exit-entry)/risk_distance计算 |
| P1-1 | 分区月份Bug(timedelta 30天) | 改为正确的月份加法 + UTC时区 |
| P2-2 | 分区边界用本地时区 | 改为datetime.timezone.utc |
## V5.2 必须修复
### 后端
| ID | 优先级 | 文件 | 问题 | 建议修复 |
|----|--------|------|------|---------|
| P0-3 | P1 | signal_engine.py | 开仓价用30分VWAP而非实时价 | 改用win_fast.trades[-1][2]最新成交价 |
| P0-4 | P2 | signal_engine+paper_monitor | 双进程并发写竞态 | SELECT FOR UPDATE SKIP LOCKED |
| P1-2 | P2 | signal_engine.py | 浮点精度漂移(buy_vol/sell_vol) | 每N次trim后从deque重算sums |
| P1-3 | P1 | market_data_collector.py | 单连接无重连 | 改用db.get_sync_conn()连接池 |
| P1-4 | P3 | db.py | 连接池初始化线程不安全 | 加threading.Lock双重检查 |
| P2-1 | P2 | market_data_collector.py | XRP/SOL coinbase_premium KeyError | 不在pair_map中的跳过 |
| P2-3 | P2 | agg_trades_collector.py | flush_buffer每秒调ensure_partitions | 移到定时任务(每小时) |
| P2-4 | P3 | liquidation_collector.py | elif条件冗余 | 改为else |
| P2-5 | P2 | signal_engine.py | atr_percentile @property有写副作用 | 移到显式update_atr_history() |
| P2-6 | P2 | main.py | 1R=$200硬编码 | 从paper_config.json读取 |
| P3-1 | P2 | auth.py | JWT密钥硬编码默认值 | 启动时强制校验环境变量 |
| P3-2 | P3 | main.py | CORS allow_origins=["*"] | 限制为前端域名 |
| P3-3 | P3 | auth.py | refresh token刷新非原子 | UPDATE...RETURNING原子操作 |
| P3-4 | P3 | auth.py | 登录无频率限制 | slowapi或Redis计数器 |
| NEW | P1 | signal_engine.py | 冷启动warmup只有4小时 | 分批加载24小时数据加载完再出信号 |
### 前端
| ID | 优先级 | 文件 | 问题 | 建议修复 |
|----|--------|------|------|---------|
| FE-P1-1 | P1 | lib/auth.tsx | 并发401多次refresh竞态 | 单例Promise防并发刷新 |
| FE-P1-2 | P1 | lib/auth.tsx | 刷新失败AuthContext未同步 | 事件总线通知强制logout |
| FE-P1-3 | P1 | 所有页面 | catch{}静默吞掉API错误 | 加error state+用户提示 |
| FE-P1-4 | P2 | paper/page.tsx | LatestSignals串行4请求 | Promise.allSettled并行 |
| FE-P2-1 | P3 | app/page.tsx | MiniKChart每30秒销毁重建 | 只更新数据不重建chart |
| FE-P2-3 | P2 | paper/page.tsx | ControlPanel非admin可见 | 校验isAdmin |
| FE-P2-4 | P1 | paper/page.tsx | WebSocket无断线重连 | 指数退避重连+断线提示 |
| FE-P2-5 | P2 | paper/page.tsx | 1R=$200前端硬编码 | 从API读取配置 |
| FE-P2-6 | P2 | signals/page.tsx | 5秒轮询5分钟数据 | 改为300秒间隔 |
| FE-P2-8 | P3 | paper/signals | 大量any类型 | 定义TypeScript interface |
| FE-P3-1 | P3 | lib/auth.tsx | Token存localStorage | 评估httpOnly cookie |
| FE-P3-3 | P3 | app/page.tsx | Promise.all任一失败全丢 | 改Promise.allSettled |
## V5.2 新功能(同步开发)
| 功能 | 说明 |
|------|------|
| FR+清算加入评分 | 8信号源完整接入 |
| 策略配置化框架 | 一套代码多份配置 |
| AB测试 | V5.1 vs V5.2两套权重对比 |
| 24h warmup | 启动时分批加载24小时数据 |

View File

@ -0,0 +1,117 @@
---
generated_by: repo-insight
version: 1
created: 2026-03-03
last_updated: 2026-03-03
source_commit: 0d9dffa
coverage: standard
---
# 00 — System Overview
## Purpose
High-level description of the arbitrage-engine project: what it does, its tech stack, repo layout, and entry points.
## TL;DR
- **Domain**: Crypto perpetual futures funding-rate arbitrage monitoring and short-term trading signal engine.
- **Strategy**: Hold spot long + perpetual short to collect funding rates every 8 h; plus a CVD/ATR-based short-term directional signal engine (V5.x).
- **Backend**: Python / FastAPI + independent PM2 worker processes; PostgreSQL (local + Cloud SQL dual-write).
- **Frontend**: Next.js 16 / React 19 / TypeScript SPA, charting via `lightweight-charts` + Recharts.
- **Targets**: BTC, ETH, XRP, SOL perpetual contracts on Binance USDC-M futures.
- **Deployment**: PM2 process manager on a GCP VM; frontend served via Next.js; backend accessible at `https://arb.zhouyangclaw.com`.
- **Auth**: JWT (access 24 h + refresh 7 d) + invite-code registration gating.
- **Trading modes**: Paper (simulated), Live (Binance Futures testnet or production via `TRADE_ENV`).
## Canonical Facts
### Repo Layout
```
arbitrage-engine/
├── backend/ # Python FastAPI API + all worker processes
│ ├── main.py # FastAPI app entry point (uvicorn)
│ ├── signal_engine.py # V5 signal engine (PM2 worker, 15 s loop)
│ ├── live_executor.py # Live trade executor (PM2 worker)
│ ├── risk_guard.py # Risk circuit-breaker (PM2 worker)
│ ├── market_data_collector.py # Binance WS market data (PM2 worker)
│ ├── agg_trades_collector.py # Binance aggTrades WS collector (PM2 worker)
│ ├── liquidation_collector.py # Binance liquidation WS collector (PM2 worker)
│ ├── signal_pusher.py # Discord signal notifier (PM2 worker)
│ ├── db.py # Dual-pool PostgreSQL layer (psycopg2 sync + asyncpg async)
│ ├── auth.py # JWT auth + invite-code registration router
│ ├── trade_config.py # Symbol / qty precision constants
│ ├── backtest.py # Offline backtest engine
│ ├── paper_monitor.py # Paper trade monitoring helper
│ ├── admin_cli.py # CLI for invite / user management
│ ├── subscriptions.py # Signal subscription query helper
│ ├── paper_config.json # Paper trading runtime toggle
│ ├── strategies/ # JSON strategy configs (v51_baseline, v52_8signals)
│ ├── ecosystem.dev.config.js # PM2 process definitions
│ └── logs/ # Rotating log files
├── frontend/ # Next.js app
│ ├── app/ # App Router pages
│ ├── components/ # Reusable UI components
│ ├── lib/api.ts # Typed API client
│ └── lib/auth.tsx # Auth context + token refresh logic
├── docs/ # Documentation (including docs/ai/)
├── scripts/ # Utility scripts
└── signal-engine.log # Live log symlink / output file
```
### Primary Language & Frameworks
| Layer | Technology |
|-------|-----------|
| Backend API | Python 3.x, FastAPI, uvicorn |
| DB access | asyncpg (async), psycopg2 (sync) |
| Frontend | TypeScript, Next.js 16, React 19 |
| Styling | Tailwind CSS v4 |
| Charts | lightweight-charts 5.x, Recharts 3.x |
| Process manager | PM2 (via `ecosystem.dev.config.js`) |
| Database | PostgreSQL (local + Cloud SQL dual-write) |
### Entry Points
| Process | File | Role |
|---------|------|------|
| HTTP API | `backend/main.py` | FastAPI on uvicorn |
| Signal engine | `backend/signal_engine.py` | 15 s indicator loop |
| Trade executor | `backend/live_executor.py` | PG NOTIFY listener → Binance API |
| Risk guard | `backend/risk_guard.py` | 5 s circuit-breaker loop |
| Market data | `backend/market_data_collector.py` | Binance WS → `market_indicators` table |
| aggTrades collector | `backend/agg_trades_collector.py` | Binance WS → `agg_trades` partitioned table |
| Liquidation collector | `backend/liquidation_collector.py` | Binance WS → liquidation tables |
| Signal pusher | `backend/signal_pusher.py` | DB → Discord push |
| Frontend | `frontend/` | Next.js dev/prod server |
### Monitored Symbols
`BTCUSDT`, `ETHUSDT`, `XRPUSDT`, `SOLUSDT` (Binance USDC-M Futures)
### Environment Variables (key ones)
| Variable | Default | Description |
|----------|---------|-------------|
| `PG_HOST` | `127.0.0.1` | Local PG host |
| `PG_DB` | `arb_engine` | Database name |
| `PG_USER` / `PG_PASS` | `arb` / `arb_engine_2026` | PG credentials |
| `CLOUD_PG_HOST` | `10.106.0.3` | Cloud SQL host |
| `CLOUD_PG_ENABLED` | `true` | Enable dual-write |
| `JWT_SECRET` | (testnet default set) | JWT signing key |
| `TRADE_ENV` | `testnet` | `testnet` or `production` |
| `LIVE_STRATEGIES` | `["v52_8signals"]` | Active live trading strategies |
| `RISK_PER_TRADE_USD` | `2` | USD risk per trade |
## Interfaces / Dependencies
- **External API**: Binance USDC-M Futures REST (`https://fapi.binance.com/fapi/v1`) and WebSocket.
- **Discord**: Webhook for signal notifications (via `signal_pusher.py`).
- **CORS origins**: `https://arb.zhouyangclaw.com`, `http://localhost:3000`, `http://localhost:3001`.
## Unknowns & Risks
- [inference] PM2 `ecosystem.dev.config.js` not read in this pass; exact process restart policies and env injection not confirmed.
- [inference] `.env` file usage confirmed via `python-dotenv` calls in live modules, but `.env.example` absent.
- [unknown] Deployment pipeline (CI/CD) not present in repo.
## Source Refs
- `backend/main.py:1-27` — FastAPI app init, CORS, SYMBOLS
- `backend/signal_engine.py:1-16` — V5 architecture docstring
- `backend/live_executor.py:1-10` — live executor architecture comment
- `backend/risk_guard.py:1-12` — risk guard circuit-break rules
- `backend/db.py:14-30` — PG/Cloud SQL env config
- `frontend/package.json` — frontend dependencies
- `frontend/lib/api.ts:1-116` — typed API client

View File

@ -0,0 +1,137 @@
---
generated_by: repo-insight
version: 1
created: 2026-03-03
last_updated: 2026-03-03
source_commit: 0d9dffa
coverage: standard
---
# 01 — Architecture Map
## Purpose
Describes the architecture style, component relationships, data flow, and runtime execution topology of the arbitrage engine.
## TL;DR
- **Multi-process architecture**: each concern is a separate PM2 process; they communicate exclusively through PostgreSQL (tables + NOTIFY/LISTEN).
- **No message broker**: PostgreSQL serves as both the data store and the inter-process message bus (`NOTIFY new_signal`).
- **Dual-database write**: every PG write in `signal_engine.py` and `agg_trades_collector.py` attempts a secondary write to Cloud SQL (GCP) for durability.
- **FastAPI is read-only at runtime**: it proxies Binance REST for rates/history and reads the PG tables written by workers; it does not control the signal engine.
- **Signal pipeline**: raw aggTrades → in-memory rolling windows (CVD/VWAP/ATR) → scored signal → PG write + `NOTIFY` → live_executor executes Binance order.
- **Frontend polling**: React SPA polls `/api/rates` every 2 s (public) and slow endpoints every 120 s (auth required).
- **Risk guard is a separate process**: polls every 5 s, can block new orders (circuit-break) by writing a flag to `live_config`; live_executor reads that flag before each trade.
## Canonical Facts
### Architecture Style
Shared-DB multi-process monolith. No microservices; no message broker. All processes run on a single GCP VM.
### Component Diagram (text)
```
Binance WS (aggTrades)
└─► agg_trades_collector.py ──────────────────► agg_trades (partitioned table)
Binance WS (market data) ▼
└─► market_data_collector.py ──────────────► market_indicators table
Binance WS (liquidations) ▼
└─► liquidation_collector.py ──────────────► liquidation tables
signal_engine.py
(15 s loop, reads agg_trades +
market_indicators)
┌─────────┴──────────────┐
│ │
signal_indicators paper_trades
signal_indicators_1m (paper mode)
signal_trades
NOTIFY new_signal
live_executor.py
(LISTEN new_signal →
Binance Futures API)
live_trades table
risk_guard.py (5 s)
monitors live_trades,
writes live_config flags
signal_pusher.py
(reads signal_indicators →
Discord webhook)
FastAPI main.py (read/proxy)
+ rate_snapshots (2 s write)
Next.js Frontend
(polling SPA)
```
### Data Flow — Signal Pipeline
1. `agg_trades_collector.py`: streams `aggTrade` WS events for all symbols, batch-inserts into `agg_trades` partitioned table (partitioned by month on `time_ms`).
2. `signal_engine.py` (15 s loop per symbol):
- Cold-start: reads last N rows from `agg_trades` to warm up `TradeWindow` (CVD, VWAP) and `ATRCalculator` deques.
- Fetches new trades since `last_agg_id`.
- Feeds trades into three `TradeWindow` instances (30 m, 4 h, 24 h) and one `ATRCalculator` (5 m candles, 14-period).
- Reads `market_indicators` for long-short ratio, OI, coinbase premium, funding rate, liquidations.
- Scores signal using JSON strategy config weights (score 0100, threshold 75).
- Writes to `signal_indicators` (15 s cadence) and `signal_indicators_1m` (1 m cadence).
- If score ≥ threshold: opens paper trade (if enabled), emits `NOTIFY new_signal` (if live enabled).
3. `live_executor.py`: `LISTEN new_signal` on PG; deserializes payload; calls Binance Futures REST to place market order; writes to `live_trades`.
4. `risk_guard.py`: every 5 s checks daily loss, consecutive losses, unrealized PnL, balance, data freshness, hold timeout; sets `live_config.circuit_break` flag to block/resume new orders.
### Strategy Scoring (V5.x)
Two JSON configs in `backend/strategies/`:
| Config | Version | Threshold | Signals |
|--------|---------|-----------|---------|
| `v51_baseline.json` | 5.1 | 75 | cvd, p99, accel, ls_ratio, oi, coinbase_premium |
| `v52_8signals.json` | 5.2 | 75 | cvd, p99, accel, ls_ratio, oi, coinbase_premium, funding_rate, liquidation |
Score categories: `direction` (CVD), `crowding` (P99 large trades), `environment` (ATR/VWAP), `confirmation` (LS ratio, OI), `auxiliary` (coinbase premium), `funding_rate`, `liquidation`.
### TP/SL Configuration
- V5.1: SL=1.4×ATR, TP1=1.05×ATR, TP2=2.1×ATR
- V5.2: SL=2.1×ATR, TP1=1.4×ATR, TP2=3.15×ATR
- Signal cooldown: 10 minutes per symbol per direction.
### Risk Guard Circuit-Break Rules
| Rule | Threshold | Action |
|------|-----------|--------|
| Daily loss | -5R | Full close + shutdown |
| Consecutive losses | 5 | Pause 60 min |
| API disconnect | >30 s | Pause new orders |
| Balance too low | < risk×2 | Reject new orders |
| Data stale | >30 s | Block new orders |
| Hold timeout yellow | 45 min | Alert |
| Hold timeout auto-close | 70 min | Force close |
### Frontend Architecture
- **Next.js App Router** (`frontend/app/`): page-per-route, all pages are client components (`"use client"`).
- **Auth**: JWT stored in `localStorage`; `lib/auth.tsx` provides `useAuth()` hook + `authFetch()` helper with auto-refresh.
- **API client**: `lib/api.ts` — typed wrapper, distinguishes public (`/api/rates`, `/api/health`) from protected (all other) endpoints.
- **Polling strategy**: rates every 2 s, slow data (stats, history, signals) every 120 s; kline charts re-render every 30 s.
## Interfaces / Dependencies
- PG NOTIFY channel name: `new_signal`
- `live_config` table keys: `risk_per_trade_usd`, `max_positions`, `circuit_break` (inferred)
- `market_indicators` populated by `market_data_collector.py` with types: `long_short_ratio`, `top_trader_position`, `open_interest_hist`, `coinbase_premium`, `funding_rate`
## Unknowns & Risks
- [inference] PM2 config (`ecosystem.dev.config.js`) not read; exact restart/watch/env-file settings unknown.
- [inference] `signal_pusher.py` exact Discord webhook configuration (env var name, rate limit handling) not confirmed.
- [unknown] Cloud SQL write failure does not block signal_engine but may create data divergence between local PG and Cloud SQL.
- [risk] Hardcoded testnet credentials in source code (`arb_engine_2026`); production requires explicit env var override.
## Source Refs
- `backend/signal_engine.py:1-16` — architecture docstring
- `backend/live_executor.py:1-10` — executor architecture comment
- `backend/risk_guard.py:1-12, 55-73` — risk rules and config
- `backend/signal_engine.py:170-245``TradeWindow`, `ATRCalculator` classes
- `backend/signal_engine.py:44-67` — strategy config loading
- `backend/strategies/v51_baseline.json`, `backend/strategies/v52_8signals.json`
- `backend/main.py:61-83` — background snapshot loop
- `frontend/lib/api.ts:103-116` — API client methods
- `frontend/app/page.tsx:149-154` — polling intervals

View File

@ -0,0 +1,218 @@
---
generated_by: repo-insight
version: 1
created: 2026-03-03
last_updated: 2026-03-03
source_commit: 0d9dffa
coverage: standard
---
# 02 — Module Cheatsheet
## Purpose
Module-by-module index: file path, role, key public interfaces, and dependencies.
## TL;DR
- Backend has 20 Python modules; signal_engine.py is the largest and most complex (~1000+ lines).
- Frontend has 2 TypeScript lib files + 9 pages + 6 components.
- `db.py` is the only shared infrastructure module; all other backend modules import from it.
- `signal_engine.py` is the core business logic module; `live_executor.py` and `risk_guard.py` are independent processes that only use `db.py` and direct PG connections.
- Strategy configs are external JSON; no code changes needed to tune weights/thresholds.
## Canonical Facts
### Backend Modules
#### `backend/main.py` — FastAPI HTTP API
- **Role**: Primary HTTP API server; rate/snapshot/history proxy; aggTrade query endpoints; signal history.
- **Key interfaces**:
- `GET /api/health` — liveness check (public)
- `GET /api/rates` — live Binance premiumIndex for 4 symbols (public, 3 s cache)
- `GET /api/snapshots` — rate snapshot history from PG (auth required)
- `GET /api/kline` — candlestick bars aggregated from `rate_snapshots` (auth required)
- `GET /api/stats` — 7-day funding rate stats per symbol (auth required, 60 s cache)
- `GET /api/stats/ytd` — YTD annualized stats (auth required, 3600 s cache)
- `GET /api/history` — 7-day raw funding rate history (auth required, 60 s cache)
- `GET /api/signals/history``signal_logs` table (auth required)
- `GET /api/trades/meta``agg_trades_meta` (auth required)
- `GET /api/trades/summary` — aggregated OHLCV from `agg_trades` (auth required)
- Many more: paper trades, signals v52, live trades, live config, position sync, etc. (full list in saved tool output)
- **Deps**: `auth.py`, `db.py`, `httpx`, `asyncio`
- **Background task**: `background_snapshot_loop()` writes `rate_snapshots` every 2 s.
#### `backend/signal_engine.py` — V5 Signal Engine (PM2 worker)
- **Role**: Core signal computation loop; 15 s interval; in-memory rolling-window indicators; scored signal output.
- **Key classes**:
- `TradeWindow(window_ms)` — rolling CVD/VWAP calculator using `deque`; props: `cvd`, `vwap`
- `ATRCalculator(period_ms, length)` — 5-min candle ATR; props: `atr`, `atr_percentile`
- `SymbolState` — per-symbol state container holding `TradeWindow` ×3, `ATRCalculator`, large-order percentile deques
- **Key functions**:
- `load_strategy_configs() -> list[dict]` — reads JSON files from `strategies/`
- `fetch_market_indicators(symbol) -> dict` — reads `market_indicators` table
- `fetch_new_trades(symbol, last_id) -> list` — reads new rows from `agg_trades`
- `save_indicator(ts, symbol, result, strategy)` — writes to `signal_indicators`
- `paper_open_trade(...)` — inserts `paper_trades` row
- `paper_check_positions(symbol, price, now_ms)` — checks TP/SL for paper positions
- `main()` — entry point; calls `load_historical()` then enters main loop
- **Deps**: `db.py`, `json`, `collections.deque`
#### `backend/live_executor.py` — Live Trade Executor (PM2 worker)
- **Role**: Listens on PG `NOTIFY new_signal`; places Binance Futures market orders; writes `live_trades`.
- **Key functions**:
- `reload_live_config(conn)` — refreshes `RISK_PER_TRADE_USD`, `MAX_POSITIONS` from `live_config` every 60 s
- `binance_request(session, method, path, params)` — HMAC-signed Binance API call
- **Config**: `TRADE_ENV` (`testnet`/`production`), `LIVE_STRATEGIES`, `RISK_PER_TRADE_USD`, `MAX_POSITIONS`
- **Deps**: `psycopg2`, `aiohttp`, HMAC signing
#### `backend/risk_guard.py` — Risk Circuit-Breaker (PM2 worker)
- **Role**: Every 5 s; monitors PnL, balance, data freshness, hold timeouts; writes circuit-break flags.
- **Key classes**: `RiskState` — holds `status` (`normal`/`warning`/`circuit_break`), loss counters
- **Key functions**:
- `check_daily_loss(conn)` — sums `pnl_r` from today's `live_trades`
- `check_unrealized_loss(session, risk_usd_dynamic)` — queries Binance positions API
- `check_balance(session)` — queries Binance account balance
- `check_data_freshness(conn)` — checks `market_indicators` recency
- `check_hold_timeout(session, conn)` — force-closes positions held >70 min
- `trigger_circuit_break(session, conn, reason, action)` — writes to `live_events`, may flat positions
- `check_auto_resume()` — re-enables trading after cooldown
- `check_emergency_commands(session, conn)` — watches for manual DB commands
- **Deps**: `trade_config.py`, `aiohttp`, `psycopg2`
#### `backend/db.py` — Database Layer
- **Role**: All PG connectivity; schema creation; partition management.
- **Key interfaces**:
- Sync (psycopg2): `get_sync_conn()`, `sync_execute()`, `sync_executemany()`
- Async (asyncpg): `async_fetch()`, `async_fetchrow()`, `async_execute()`
- Cloud SQL sync pool: `get_cloud_sync_conn()` (non-fatal on failure)
- `init_schema()` — creates all tables from `SCHEMA_SQL`
- `ensure_partitions()` — creates `agg_trades_YYYYMM` partitions for current+next 2 months
- **Deps**: `asyncpg`, `psycopg2`
#### `backend/auth.py` — JWT Auth + Registration
- **Role**: FastAPI router at `/api`; register/login/refresh/logout endpoints.
- **Key interfaces**:
- `POST /api/register` — invite-code gated registration
- `POST /api/login` — returns `access_token` + `refresh_token`
- `POST /api/refresh` — token refresh
- `POST /api/logout` — revokes refresh token
- `GET /api/me` — current user info
- `get_current_user` — FastAPI `Depends` injector; validates Bearer JWT
- **Token storage**: HMAC-SHA256 hand-rolled JWT (no PyJWT); refresh tokens stored in `refresh_tokens` table.
- **Deps**: `db.py`, `hashlib`, `hmac`, `secrets`
#### `backend/agg_trades_collector.py` — AggTrades Collector (PM2 worker)
- **Role**: Streams Binance `aggTrade` WebSocket events; batch-inserts into `agg_trades` partitioned table; maintains `agg_trades_meta`.
- **Key functions**: `ws_collect(symbol)`, `rest_catchup(symbol, from_id)`, `continuity_check()`, `flush_buffer(symbol, trades)`
- **Deps**: `db.py`, `websockets`/`httpx`
#### `backend/market_data_collector.py` — Market Data Collector (PM2 worker)
- **Role**: Collects Binance market indicators (LS ratio, OI, coinbase premium, funding rate) via REST polling; stores in `market_indicators` JSONB.
- **Key class**: `MarketDataCollector`
- **Deps**: `db.py`, `httpx`
#### `backend/liquidation_collector.py` — Liquidation Collector (PM2 worker)
- **Role**: Streams Binance liquidation WS; aggregates into `liquidation_events` and `liquidation_agg` tables.
- **Key functions**: `ensure_table()`, `save_liquidation()`, `save_aggregated()`, `run()`
- **Deps**: `db.py`, `websockets`
#### `backend/backtest.py` — Offline Backtester
- **Role**: Replays `agg_trades` from PG to simulate signal engine and measure strategy performance.
- **Key classes**: `Position`, `BacktestEngine`
- **Key functions**: `load_trades()`, `run_backtest()`, `main()`
- **Deps**: `db.py`
#### `backend/trade_config.py` — Symbol / Qty Config
- **Role**: Constants for symbols and Binance qty precision.
- **Deps**: none
#### `backend/admin_cli.py` — Admin CLI
- **Role**: CLI for invite-code and user management (gen_invite, list_invites, ban_user, set_admin).
- **Deps**: `db.py`
#### `backend/subscriptions.py` — Subscription Query Helper
- **Role**: Helpers for querying signal history (used internally).
- **Deps**: `db.py`
#### `backend/paper_monitor.py` — Paper Trade Monitor
- **Role**: Standalone script to print paper trade status.
- **Deps**: `db.py`
#### `backend/signal_pusher.py` — Discord Notifier (PM2 worker)
- **Role**: Polls `signal_indicators` for high-score events; pushes Discord webhook notifications.
- **Deps**: `db.py`, `httpx`
#### `backend/position_sync.py` — Position Sync
- **Role**: Syncs live positions between `live_trades` table and Binance account state.
- **Deps**: `db.py`, `aiohttp`
#### `backend/fix_historical_pnl.py` — PnL Fix Script
- **Role**: One-time migration to recalculate historical PnL in `paper_trades`.
- **Deps**: `db.py`
### Frontend Modules
#### `frontend/lib/api.ts` — API Client
- **Role**: Typed `api` object with all backend endpoint wrappers; distinguishes public vs. protected fetches.
- **Interfaces exported**: `RateData`, `RatesResponse`, `HistoryPoint`, `HistoryResponse`, `StatsResponse`, `SignalHistoryItem`, `SnapshotItem`, `KBar`, `KlineResponse`, `YtdStatsResponse`, `api` object
- **Deps**: `lib/auth.tsx` (`authFetch`)
#### `frontend/lib/auth.tsx` — Auth Context
- **Role**: React context for current user; `useAuth()` hook; `authFetch()` with access-token injection and auto-refresh.
- **Deps**: Next.js router, `localStorage`
#### `frontend/app/` Pages
| Page | Route | Description |
|------|-------|-------------|
| `page.tsx` | `/` | Main dashboard: rates, kline, history, signal log |
| `dashboard/page.tsx` | `/dashboard` | (inferred) extended dashboard |
| `signals/page.tsx` | `/signals` | Signal history view (V5.1) |
| `signals-v52/page.tsx` | `/signals-v52` | Signal history view (V5.2) |
| `paper/page.tsx` | `/paper` | Paper trades view (V5.1) |
| `paper-v52/page.tsx` | `/paper-v52` | Paper trades view (V5.2) |
| `live/page.tsx` | `/live` | Live trades view |
| `history/page.tsx` | `/history` | Funding rate history |
| `kline/page.tsx` | `/kline` | Kline chart page |
| `trades/page.tsx` | `/trades` | aggTrades summary |
| `server/page.tsx` | `/server` | Server status / metrics |
| `about/page.tsx` | `/about` | About page |
| `login/page.tsx` | `/login` | Login form |
| `register/page.tsx` | `/register` | Registration form |
#### `frontend/components/`
| Component | Role |
|-----------|------|
| `Navbar.tsx` | Top navigation bar |
| `Sidebar.tsx` | Sidebar navigation |
| `AuthHeader.tsx` | Auth-aware header with user info |
| `RateCard.tsx` | Displays current funding rate for one asset |
| `StatsCard.tsx` | Displays 7d mean and annualized stats |
| `FundingChart.tsx` | Funding rate chart component |
| `LiveTradesCard.tsx` | Live trades summary card |
## Interfaces / Dependencies
### Key import graph (backend)
```
main.py → auth.py, db.py
signal_engine.py → db.py
live_executor.py → psycopg2 direct (no db.py module import)
risk_guard.py → trade_config.py, psycopg2 direct
backtest.py → db.py
agg_trades_collector.py → db.py
market_data_collector.py → db.py
liquidation_collector.py → db.py
admin_cli.py → db.py
```
## Unknowns & Risks
- [inference] Content of `frontend/app/dashboard/`, `signals/`, `paper/`, `live/` pages not read; role described from filename convention.
- [unknown] `signal_pusher.py` Discord webhook env var name not confirmed.
- [inference] `position_sync.py` exact interface not read; role inferred from name and listing.
## Source Refs
- `backend/main.py` — all API route definitions
- `backend/signal_engine.py:170-285``TradeWindow`, `ATRCalculator`, `SymbolState`
- `backend/auth.py:23-23` — router prefix `/api`
- `backend/db.py:35-157` — all public DB functions
- `frontend/lib/api.ts:103-116``api` export object
- `frontend/lib/auth.tsx` — auth context (not fully read)

242
docs/ai/03-api-contracts.md Normal file
View File

@ -0,0 +1,242 @@
---
generated_by: repo-insight
version: 1
created: 2026-03-03
last_updated: 2026-03-03
source_commit: 0d9dffa
coverage: standard
---
# 03 — API Contracts
## Purpose
Documents all REST API endpoints, authentication requirements, request/response shapes, and error conventions.
## TL;DR
- Base URL: `https://arb.zhouyangclaw.com` (prod) or `http://localhost:8000` (local).
- Auth: Bearer JWT in `Authorization` header. Two public endpoints (`/api/health`, `/api/rates`) need no token.
- Token lifecycle: access token 24 h, refresh token 7 d; use `POST /api/refresh` to renew.
- Registration is invite-code gated: must supply a valid `invite_code` in register body.
- All timestamps are Unix epoch (seconds or ms depending on field; see per-endpoint notes).
- Funding rates are stored as decimals (e.g. `0.0001` = 0.01%). Frontend multiplies by 10000 for "万分之" display.
- Error responses: standard FastAPI `{"detail": "..."}` with appropriate HTTP status codes.
## Canonical Facts
### Authentication
#### `POST /api/register`
```json
// Request
{
"email": "user@example.com",
"password": "...",
"invite_code": "XXXX"
}
// Response 200
{
"access_token": "<jwt>",
"refresh_token": "<token>",
"token_type": "bearer",
"user": { "id": 1, "email": "...", "role": "user" }
}
// Errors: 400 (invite invalid/expired), 409 (email taken)
```
#### `POST /api/login`
```json
// Request
{
"email": "user@example.com",
"password": "..."
}
// Response 200
{
"access_token": "<jwt>",
"refresh_token": "<token>",
"token_type": "bearer",
"user": { "id": 1, "email": "...", "role": "user" }
}
// Errors: 401 (invalid credentials), 403 (banned)
```
#### `POST /api/refresh`
```json
// Request
{ "refresh_token": "<token>" }
// Response 200
{ "access_token": "<new_jwt>", "token_type": "bearer" }
// Errors: 401 (expired/revoked)
```
#### `POST /api/logout`
```json
// Request header: Authorization: Bearer <access_token>
// Request body: { "refresh_token": "<token>" }
// Response 200: { "ok": true }
```
#### `GET /api/me`
```json
// Auth required
// Response 200
{ "id": 1, "email": "...", "role": "user", "created_at": "..." }
```
### Public Endpoints (no auth)
#### `GET /api/health`
```json
{ "status": "ok", "timestamp": "2026-03-03T12:00:00" }
```
#### `GET /api/rates`
Returns live Binance premiumIndex for BTCUSDT, ETHUSDT, XRPUSDT, SOLUSDT. Cached 3 s.
```json
{
"BTC": {
"symbol": "BTCUSDT",
"markPrice": 65000.0,
"indexPrice": 64990.0,
"lastFundingRate": 0.0001,
"nextFundingTime": 1234567890000,
"timestamp": 1234567890000
},
"ETH": { ... },
"XRP": { ... },
"SOL": { ... }
}
```
### Protected Endpoints (Bearer JWT required)
#### `GET /api/history`
7-day funding rate history from Binance. Cached 60 s.
```json
{
"BTC": [
{ "fundingTime": 1234567890000, "fundingRate": 0.0001, "timestamp": "2026-03-01T08:00:00" }
],
"ETH": [ ... ], "XRP": [ ... ], "SOL": [ ... ]
}
```
#### `GET /api/stats`
7-day funding rate statistics. Cached 60 s.
```json
{
"BTC": { "mean7d": 0.01, "annualized": 10.95, "count": 21 },
"ETH": { ... },
"combo": { "mean7d": 0.009, "annualized": 9.85 }
}
// mean7d in %; annualized = mean * 3 * 365 * 100
```
#### `GET /api/stats/ytd`
Year-to-date annualized stats. Cached 3600 s.
```json
{
"BTC": { "annualized": 12.5, "count": 150 },
"ETH": { ... }
}
```
#### `GET /api/snapshots?hours=24&limit=5000`
Rate snapshots from local PG.
```json
{
"count": 43200,
"hours": 24,
"data": [
{ "ts": 1709000000, "btc_rate": 0.0001, "eth_rate": 0.00008, "btc_price": 65000, "eth_price": 3200 }
]
}
```
#### `GET /api/kline?symbol=BTC&interval=1h&limit=500`
Candlestick bars derived from `rate_snapshots`. Rates scaled by ×10000.
- `interval`: `1m`, `5m`, `30m`, `1h`, `4h`, `8h`, `1d`, `1w`, `1M`
```json
{
"symbol": "BTC",
"interval": "1h",
"count": 24,
"data": [
{
"time": 1709000000,
"open": 1.0, "high": 1.2, "low": 0.8, "close": 1.1,
"price_open": 65000, "price_high": 65500, "price_low": 64800, "price_close": 65200
}
]
}
```
#### `GET /api/signals/history?limit=100`
Legacy signal log from `signal_logs` table.
```json
{
"items": [
{ "id": 1, "symbol": "BTCUSDT", "rate": 0.0001, "annualized": 10.95, "sent_at": "2026-03-01T08:00:00", "message": "..." }
]
}
```
#### `GET /api/trades/meta`
aggTrades collection status.
```json
{
"BTC": { "last_agg_id": 123456789, "last_time_ms": 1709000000000, "updated_at": "2026-03-03 12:00:00" }
}
```
#### `GET /api/trades/summary?symbol=BTC&start_ms=0&end_ms=0&interval=1m`
Aggregated OHLCV from `agg_trades` via PG native aggregation.
```json
{
"symbol": "BTC",
"interval": "1m",
"count": 60,
"data": [
{ "bar_ms": 1709000000000, "buy_vol": 10.5, "sell_vol": 9.3, "trade_count": 45, "vwap": 65000.0, "max_qty": 2.5 }
]
}
```
#### Signal V52 Endpoints (inferred from frontend routes)
- `GET /api/signals/v52` — signals for v52_8signals strategy
- `GET /api/paper/trades` — paper trade history
- `GET /api/paper/trades/v52` — v52 paper trade history
- `GET /api/live/trades` — live trade history
- `GET /api/live/config` — current live config
- `GET /api/live/events` — live trading event log
- `GET /api/server/stats` — server process stats (psutil)
### Auth Header Format
```
Authorization: Bearer <access_token>
```
Frontend auto-injects via `authFetch()` in `lib/auth.tsx`. On 401, attempts token refresh before retry.
### Error Shape
All errors follow FastAPI default:
```json
{ "detail": "Human-readable error message" }
```
Common HTTP status codes: 400 (bad request), 401 (unauthorized), 403 (forbidden/banned), 404 (not found), 422 (validation error), 502 (Binance upstream error).
## Interfaces / Dependencies
- Binance USDC-M Futures REST: `https://fapi.binance.com/fapi/v1/premiumIndex`, `/fundingRate`
- CORS allowed origins: `https://arb.zhouyangclaw.com`, `http://localhost:3000`, `http://localhost:3001`
- `NEXT_PUBLIC_API_URL` env var controls the frontend base URL (empty = same-origin)
## Unknowns & Risks
- [inference] Full endpoint list for signals-v52, paper-v52, live, server pages not confirmed by reading main.py lines 300+. The full saved output contains more routes.
- [inference] `POST /api/register` exact field validation (password min length, etc.) not confirmed.
- [risk] No rate limiting visible on public endpoints; `/api/rates` with 3 s cache could be bypassed by direct calls.
## Source Refs
- `backend/main.py:101-298` — all confirmed REST endpoints
- `backend/auth.py:23` — auth router prefix
- `backend/main.py:16-21` — CORS config
- `frontend/lib/api.ts:90-116` — client-side API wrappers
- `frontend/lib/auth.tsx``authFetch` with auto-refresh (not fully read)

301
docs/ai/04-data-model.md Normal file
View File

@ -0,0 +1,301 @@
---
generated_by: repo-insight
version: 1
created: 2026-03-03
last_updated: 2026-03-03
source_commit: 0d9dffa
coverage: standard
---
# 04 — Data Model
## Purpose
Documents all PostgreSQL tables, columns, relations, constraints, storage design, and partitioning strategy.
## TL;DR
- Single PostgreSQL database `arb_engine`; 15+ tables defined in `db.py` `SCHEMA_SQL` + `auth.py` `AUTH_SCHEMA`.
- `agg_trades` is a range-partitioned table (by `time_ms` in milliseconds); monthly partitions auto-created by `ensure_partitions()`.
- Dual-write: local PG is primary; Cloud SQL at `10.106.0.3` receives same writes via a secondary psycopg2 pool (non-fatal if down).
- All timestamps: `ts` columns are Unix seconds (integer); `time_ms` columns are Unix milliseconds (bigint); `created_at` columns are PG `TIMESTAMP`.
- JSONB used for `score_factors` in `paper_trades`/`live_trades`, `detail` in `live_events`, `value` in `market_indicators`.
- Auth tokens stored in DB: refresh tokens in `refresh_tokens` table (revocable); no session table.
## Canonical Facts
### Tables
#### `rate_snapshots` — Funding Rate Snapshots
Populated every 2 s by `background_snapshot_loop()` in `main.py`.
| Column | Type | Description |
|--------|------|-------------|
| `id` | BIGSERIAL PK | |
| `ts` | BIGINT NOT NULL | Unix seconds |
| `btc_rate` | DOUBLE PRECISION | BTC funding rate (decimal) |
| `eth_rate` | DOUBLE PRECISION | ETH funding rate |
| `btc_price` | DOUBLE PRECISION | BTC mark price USD |
| `eth_price` | DOUBLE PRECISION | ETH mark price USD |
| `btc_index_price` | DOUBLE PRECISION | BTC index price |
| `eth_index_price` | DOUBLE PRECISION | ETH index price |
Index: `idx_rate_snapshots_ts` on `ts`.
---
#### `agg_trades` — Aggregate Trades (Partitioned)
Partitioned by `RANGE(time_ms)`; monthly child tables named `agg_trades_YYYYMM`.
| Column | Type | Description |
|--------|------|-------------|
| `agg_id` | BIGINT NOT NULL | Binance aggTrade ID |
| `symbol` | TEXT NOT NULL | e.g. `BTCUSDT` |
| `price` | DOUBLE PRECISION | Trade price |
| `qty` | DOUBLE PRECISION | Trade quantity (BTC/ETH/etc.) |
| `time_ms` | BIGINT NOT NULL | Trade timestamp ms |
| `is_buyer_maker` | SMALLINT | 0=taker buy, 1=taker sell |
PK: `(time_ms, symbol, agg_id)`.
Indexes: `idx_agg_trades_sym_time` on `(symbol, time_ms DESC)`, `idx_agg_trades_sym_agg` on `(symbol, agg_id)`.
Partitions auto-created for current + next 2 months. Named `agg_trades_YYYYMM`.
---
#### `agg_trades_meta` — Collection State
| Column | Type | Description |
|--------|------|-------------|
| `symbol` | TEXT PK | e.g. `BTCUSDT` |
| `last_agg_id` | BIGINT | Last processed aggTrade ID |
| `last_time_ms` | BIGINT | Timestamp of last trade |
| `earliest_agg_id` | BIGINT | Oldest buffered ID |
| `earliest_time_ms` | BIGINT | Oldest buffered timestamp |
| `updated_at` | TEXT | Human-readable update time |
---
#### `signal_indicators` — Signal Engine Output (15 s cadence)
| Column | Type | Description |
|--------|------|-------------|
| `id` | BIGSERIAL PK | |
| `ts` | BIGINT | Unix seconds |
| `symbol` | TEXT | |
| `cvd_fast` | DOUBLE PRECISION | CVD 30 m window |
| `cvd_mid` | DOUBLE PRECISION | CVD 4 h window |
| `cvd_day` | DOUBLE PRECISION | CVD UTC day |
| `cvd_fast_slope` | DOUBLE PRECISION | CVD momentum |
| `atr_5m` | DOUBLE PRECISION | ATR (5 m candles, 14 periods) |
| `atr_percentile` | DOUBLE PRECISION | ATR rank in 24 h history |
| `vwap_30m` | DOUBLE PRECISION | VWAP 30 m |
| `price` | DOUBLE PRECISION | Current mark price |
| `p95_qty` | DOUBLE PRECISION | P95 large-order threshold |
| `p99_qty` | DOUBLE PRECISION | P99 large-order threshold |
| `buy_vol_1m` | DOUBLE PRECISION | 1 m buy volume |
| `sell_vol_1m` | DOUBLE PRECISION | 1 m sell volume |
| `score` | INTEGER | Signal score 0100 |
| `signal` | TEXT | `LONG`, `SHORT`, or null |
Indexes: `idx_si_ts`, `idx_si_sym_ts`.
---
#### `signal_indicators_1m` — 1-Minute Signal Snapshot
Subset of `signal_indicators` columns; written at 1 m cadence for lightweight chart queries.
---
#### `signal_trades` — Signal Engine Trade Tracking
| Column | Type | Description |
|--------|------|-------------|
| `id` | BIGSERIAL PK | |
| `ts_open` | BIGINT | Open timestamp (Unix s) |
| `ts_close` | BIGINT | Close timestamp |
| `symbol` | TEXT | |
| `direction` | TEXT | `LONG` / `SHORT` |
| `entry_price` | DOUBLE PRECISION | |
| `exit_price` | DOUBLE PRECISION | |
| `qty` | DOUBLE PRECISION | |
| `score` | INTEGER | Signal score at entry |
| `pnl` | DOUBLE PRECISION | Realized PnL |
| `sl_price` | DOUBLE PRECISION | Stop-loss level |
| `tp1_price` | DOUBLE PRECISION | Take-profit 1 level |
| `tp2_price` | DOUBLE PRECISION | Take-profit 2 level |
| `status` | TEXT DEFAULT `open` | `open`, `closed`, `stopped` |
---
#### `paper_trades` — Paper Trading Records
| Column | Type | Description |
|--------|------|-------------|
| `id` | BIGSERIAL PK | |
| `symbol` | TEXT | |
| `direction` | TEXT | `LONG`/`SHORT` |
| `score` | INT | Signal score |
| `tier` | TEXT | `light`/`standard`/`heavy` |
| `entry_price` | DOUBLE PRECISION | |
| `entry_ts` | BIGINT | Unix ms |
| `exit_price` | DOUBLE PRECISION | |
| `exit_ts` | BIGINT | |
| `tp1_price` | DOUBLE PRECISION | |
| `tp2_price` | DOUBLE PRECISION | |
| `sl_price` | DOUBLE PRECISION | |
| `tp1_hit` | BOOLEAN DEFAULT FALSE | |
| `status` | TEXT DEFAULT `active` | `active`, `tp1`, `tp2`, `sl`, `timeout` |
| `pnl_r` | DOUBLE PRECISION | PnL in R units |
| `atr_at_entry` | DOUBLE PRECISION | ATR snapshot at entry |
| `score_factors` | JSONB | Breakdown of signal score components |
| `strategy` | VARCHAR(32) DEFAULT `v51_baseline` | Strategy name |
| `created_at` | TIMESTAMP | |
---
#### `live_trades` — Live Trading Records
| Column | Type | Description |
|--------|------|-------------|
| `id` | BIGSERIAL PK | |
| `symbol` | TEXT | |
| `strategy` | TEXT | |
| `direction` | TEXT | `LONG`/`SHORT` |
| `status` | TEXT DEFAULT `active` | |
| `entry_price` / `exit_price` | DOUBLE PRECISION | |
| `entry_ts` / `exit_ts` | BIGINT | Unix ms |
| `sl_price`, `tp1_price`, `tp2_price` | DOUBLE PRECISION | |
| `tp1_hit` | BOOLEAN | |
| `score` | DOUBLE PRECISION | |
| `tier` | TEXT | |
| `pnl_r` | DOUBLE PRECISION | |
| `fee_usdt` | DOUBLE PRECISION | Exchange fees |
| `funding_fee_usdt` | DOUBLE PRECISION | Funding fees paid while holding |
| `risk_distance` | DOUBLE PRECISION | Entry to SL distance |
| `atr_at_entry` | DOUBLE PRECISION | |
| `score_factors` | JSONB | |
| `signal_id` | BIGINT | FK → signal_indicators.id |
| `binance_order_id` | TEXT | Binance order ID |
| `fill_price` | DOUBLE PRECISION | Actual fill price |
| `slippage_bps` | DOUBLE PRECISION | Slippage in basis points |
| `protection_gap_ms` | BIGINT | Time between SL order and fill |
| `signal_to_order_ms` | BIGINT | Latency: signal → order placed |
| `order_to_fill_ms` | BIGINT | Latency: order → fill |
| `qty` | DOUBLE PRECISION | |
| `created_at` | TIMESTAMP | |
---
#### `live_config` — Runtime Configuration KV Store
| Column | Type | Description |
|--------|------|-------------|
| `key` | TEXT PK | Config key |
| `value` | TEXT | Config value (string) |
| `label` | TEXT | Human label |
| `updated_at` | TIMESTAMP | |
Known keys: `risk_per_trade_usd`, `max_positions`, `circuit_break` (inferred).
---
#### `live_events` — Trade Event Log
| Column | Type | Description |
|--------|------|-------------|
| `id` | BIGSERIAL PK | |
| `ts` | BIGINT | Unix ms (default: NOW()) |
| `level` | TEXT | `info`/`warning`/`error` |
| `category` | TEXT | Event category |
| `symbol` | TEXT | |
| `message` | TEXT | |
| `detail` | JSONB | Structured event data |
---
#### `signal_logs` — Legacy Signal Log
Kept for backwards compatibility with the original funding-rate signal system.
| Column | Type |
|--------|------|
| `id` | BIGSERIAL PK |
| `symbol` | TEXT |
| `rate` | DOUBLE PRECISION |
| `annualized` | DOUBLE PRECISION |
| `sent_at` | TEXT |
| `message` | TEXT |
---
#### Auth Tables (defined in `auth.py` AUTH_SCHEMA)
**`users`**
| Column | Type |
|--------|------|
| `id` | BIGSERIAL PK |
| `email` | TEXT UNIQUE NOT NULL |
| `password_hash` | TEXT NOT NULL |
| `discord_id` | TEXT |
| `role` | TEXT DEFAULT `user` |
| `banned` | INTEGER DEFAULT 0 |
| `created_at` | TEXT |
**`subscriptions`**
| Column | Type |
|--------|------|
| `user_id` | BIGINT PK → users |
| `tier` | TEXT DEFAULT `free` |
| `expires_at` | TEXT |
**`invite_codes`**
| Column | Type |
|--------|------|
| `id` | BIGSERIAL PK |
| `code` | TEXT UNIQUE |
| `created_by` | INTEGER |
| `max_uses` | INTEGER DEFAULT 1 |
| `used_count` | INTEGER DEFAULT 0 |
| `status` | TEXT DEFAULT `active` |
| `expires_at` | TEXT |
**`invite_usage`**
| Column | Type |
|--------|------|
| `id` | BIGSERIAL PK |
| `code_id` | BIGINT → invite_codes |
| `user_id` | BIGINT → users |
| `used_at` | TEXT |
**`refresh_tokens`**
| Column | Type |
|--------|------|
| `id` | BIGSERIAL PK |
| `user_id` | BIGINT → users |
| `token` | TEXT UNIQUE |
| `expires_at` | TEXT |
| `revoked` | INTEGER DEFAULT 0 |
---
#### `market_indicators` — Market Indicator JSONB Store
Populated by `market_data_collector.py`.
| Column | Type | Description |
|--------|------|-------------|
| `symbol` | TEXT | |
| `indicator_type` | TEXT | `long_short_ratio`, `top_trader_position`, `open_interest_hist`, `coinbase_premium`, `funding_rate` |
| `timestamp_ms` | BIGINT | |
| `value` | JSONB | Raw indicator payload |
Query pattern: `WHERE symbol=? AND indicator_type=? ORDER BY timestamp_ms DESC LIMIT 1`.
### Storage Design Decisions
- **Partitioning**: `agg_trades` partitioned by month to avoid table bloat; partition maintenance is automated.
- **Dual-write**: Cloud SQL secondary is best-effort (errors logged, never fatal).
- **JSONB `score_factors`**: allows schema-free storage of per-strategy signal breakdowns without migrations.
- **Timestamps**: mix of Unix seconds (`ts`), Unix ms (`time_ms`, `timestamp_ms`, `entry_ts`), ISO strings (`created_at` TEXT in auth tables), and PG `TIMESTAMP`; be careful when querying across tables.
## Interfaces / Dependencies
- `db.py:init_schema()` — creates all tables in `SCHEMA_SQL`
- `auth.py:ensure_tables()` — creates auth tables from `AUTH_SCHEMA`
- `db.py:ensure_partitions()` — auto-creates monthly `agg_trades_YYYYMM` partitions
## Unknowns & Risks
- [unknown] `market_indicators` table schema not in `SCHEMA_SQL`; likely created by `market_data_collector.py` separately — verify before querying.
- [risk] Timestamp inconsistency: some tables use TEXT for timestamps (auth tables), others use BIGINT, others use PG TIMESTAMP — cross-table JOINs on time fields require explicit casting.
- [inference] `live_config` circuit-break key name not confirmed from source; inferred from `risk_guard.py` behavior.
- [risk] `users` table defined in both `SCHEMA_SQL` (db.py) and `AUTH_SCHEMA` (auth.py); duplicate CREATE TABLE IF NOT EXISTS; actual schema diverges between the two definitions (db.py version lacks `discord_id`, `banned`).
## Source Refs
- `backend/db.py:166-356``SCHEMA_SQL` with all table definitions
- `backend/auth.py:28-71``AUTH_SCHEMA` auth tables
- `backend/db.py:360-414``ensure_partitions()`, `init_schema()`
- `backend/signal_engine.py:123-158``market_indicators` query pattern

View File

@ -0,0 +1,251 @@
---
generated_by: repo-insight
version: 1
created: 2026-03-03
last_updated: 2026-03-03
source_commit: 0d9dffa
coverage: deep
---
# 05 — Build, Run & Test
## Purpose
所有构建、运行、测试、部署相关命令及环境变量配置说明。
## TL;DR
- 无 CI/CD 流水线;手动部署到 GCP VMPM2 管理进程。
- 后端无构建步骤,直接 `python3 main.py` 或 PM2 启动。
- 前端标准 Next.js`npm run dev` / `npm run build` / `npm start`
- 测试文件未发现;验证通过 backtest.py 回测和 paper trading 模拟盘进行。
- 本地开发:前端 `/api/*` 通过 Next.js rewrite 代理到 `http://127.0.0.1:4332`(即 uvicorn 端口)。
- 数据库 schema 自动在启动时初始化(`init_schema()`),无独立 migration 工具。
## Canonical Facts
### 环境要求
| 组件 | 要求 |
|------|------|
| Python | 3.10+(使用 `list[dict]` 等 3.10 语法) |
| Node.js | 推荐 20.xpackage.json `@types/node: ^20` |
| PostgreSQL | 本地实例 + Cloud SQL`10.106.0.3` |
| PM2 | 用于进程管理(需全局安装) |
### 后端依赖安装
```bash
cd backend
pip install -r requirements.txt
# requirements.txt 内容fastapi, uvicorn, httpx, python-dotenv, psutil
# 实际还需要(从源码 import 推断):
# asyncpg, psycopg2-binary, aiohttp, websockets
```
> [inference] `requirements.txt` 内容不完整,仅列出 5 个包,但源码 import 了 `asyncpg`、`psycopg2`、`aiohttp` 等。运行前需确认完整依赖已安装。
### 后端启动
#### 单进程开发模式
```bash
cd backend
# FastAPI HTTP API默认端口 4332从 next.config.ts 推断)
uvicorn main:app --host 0.0.0.0 --port 4332 --reload
# 信号引擎(独立进程)
python3 signal_engine.py
# aggTrades 收集器
python3 agg_trades_collector.py
# 市场数据收集器
python3 market_data_collector.py
# 清算数据收集器
python3 liquidation_collector.py
# 实盘执行器
TRADE_ENV=testnet python3 live_executor.py
# 风控模块
TRADE_ENV=testnet python3 risk_guard.py
# 信号推送Discord
python3 signal_pusher.py
```
#### PM2 生产模式
```bash
cd backend
# 使用 ecosystem 配置(目前只定义了 arb-dev-signal
pm2 start ecosystem.dev.config.js
# 查看进程状态
pm2 status
# 查看日志
pm2 logs arb-dev-signal
# 停止所有
pm2 stop all
# 重启
pm2 restart all
```
> [inference] `ecosystem.dev.config.js` 当前只配置了 `signal_engine.py`,其他进程需手动启动或添加到 PM2 配置。
### 环境变量配置
#### 数据库(所有后端进程共用)
```bash
export PG_HOST=127.0.0.1 # 本地 PG
export PG_PORT=5432
export PG_DB=arb_engine
export PG_USER=arb
export PG_PASS=arb_engine_2026 # 测试网默认,生产需覆盖
export CLOUD_PG_HOST=10.106.0.3 # Cloud SQL
export CLOUD_PG_ENABLED=true
```
#### 认证
```bash
export JWT_SECRET=<> # 生产环境必填,长度 ≥32
# 测试网有默认值 "arb-engine-jwt-secret-v2-2026",生产环境 TRADE_ENV != testnet 时必须设置
```
#### 交易环境
```bash
export TRADE_ENV=testnet # 或 production
export LIVE_STRATEGIES='["v52_8signals"]'
export RISK_PER_TRADE_USD=2 # 每笔风险 USD
export MAX_POSITIONS=4 # 最大同时持仓数
```
#### 实盘专用live_executor + risk_guard
```bash
export DB_HOST=10.106.0.3
export DB_PASSWORD=<生产密码>
export DB_NAME=arb_engine
export DB_USER=arb
# 币安 API Key需在 Binance 配置)
export BINANCE_API_KEY=<key>
export BINANCE_API_SECRET=<secret>
```
#### 前端
```bash
# .env.local 或部署环境
NEXT_PUBLIC_API_URL= # 留空=同源,生产时设为 https://arb.zhouyangclaw.com
```
### 数据库初始化
```bash
# schema 在 FastAPI 启动时自动创建init_schema + ensure_auth_tables
# 手动初始化:
cd backend
python3 -c "from db import init_schema; init_schema()"
# 分区维护(自动在 init_schema 内调用):
python3 -c "from db import ensure_partitions; ensure_partitions()"
```
### 前端构建与启动
```bash
cd frontend
npm install
# 开发模式(热重载,端口 3000
npm run dev
# 生产构建
npm run build
npm start
# Lint
npm run lint
```
### 前端 API 代理配置
`frontend/next.config.ts``/api/*` 代理到 `http://127.0.0.1:4332`
- 本地开发时 uvicorn 需监听 **4332 端口**
- 生产部署时通过 `NEXT_PUBLIC_API_URL` 或 nginx 反向代理处理跨域。
### 回测(离线验证)
```bash
cd backend
# 指定天数回测
python3 backtest.py --symbol BTCUSDT --days 20
# 指定日期范围回测
python3 backtest.py --symbol BTCUSDT --start 2026-02-08 --end 2026-02-28
# 输出:胜率、盈亏比、夏普比率、最大回撤等统计
```
### 模拟盘Paper Trading
通过 `paper_config.json` 控制:
```json
{
"enabled": true,
"enabled_strategies": ["v52_8signals"],
"initial_balance": 10000,
"risk_per_trade": 0.02,
"max_positions": 4
}
```
修改后 signal_engine 下次循环自动读取(无需重启)。
监控模拟盘:
```bash
cd backend
python3 paper_monitor.py
```
### 管理员 CLI
```bash
cd backend
python3 admin_cli.py gen_invite [count] [max_uses]
python3 admin_cli.py list_invites
python3 admin_cli.py disable_invite <code>
python3 admin_cli.py list_users
python3 admin_cli.py ban_user <user_id>
python3 admin_cli.py unban_user <user_id>
python3 admin_cli.py set_admin <user_id>
python3 admin_cli.py usage
```
### 日志位置
| 进程 | 日志文件 |
|------|---------|
| signal_engine | `signal-engine.log`(项目根目录) |
| risk_guard | `backend/logs/risk_guard.log`RotatingFileHandler10MB×5 |
| 其他进程 | stdout / PM2 logs |
### 无测试框架
项目中未发现 `pytest`、`unittest` 或任何测试文件。验证策略依赖:
1. **回测**`backtest.py` 逐 tick 回放历史数据
2. **模拟盘**paper trading 实时验证信号质量
3. **手动测试**:前端页面人工验证
## Interfaces / Dependencies
- uvicorn 端口:**4332**(从 `next.config.ts` 推断)
- 前端开发端口:**3000**Next.js 默认)
- CORS 允许 `localhost:3000``localhost:3001`
## Unknowns & Risks
- [inference] uvicorn 端口 4332 从 `next.config.ts` 推断,未在 `main.py` 或启动脚本中显式确认。
- [inference] `requirements.txt` 不完整,实际依赖需从源码 import 语句归纳。
- [unknown] 生产部署的 nginx 配置未在仓库中。
- [risk] 无自动化测试,代码变更风险完全依赖人工回测和 paper trading 验证。
## Source Refs
- `frontend/next.config.ts` — API rewrite 代理到 `127.0.0.1:4332`
- `backend/ecosystem.dev.config.js` — PM2 配置(仅 signal_engine
- `backend/requirements.txt` — 后端依赖(不完整)
- `backend/backtest.py:1-13` — 回测用法说明
- `backend/paper_config.json` — 模拟盘配置
- `backend/admin_cli.py:88` — CLI usage 函数
- `backend/risk_guard.py:81-82` — 日志 RotatingFileHandler 配置

162
docs/ai/06-decision-log.md Normal file
View File

@ -0,0 +1,162 @@
---
generated_by: repo-insight
version: 1
created: 2026-03-03
last_updated: 2026-03-03
source_commit: 0d9dffa
coverage: deep
---
# 06 — Decision Log
## Purpose
记录项目中关键的技术决策、选型原因及权衡取舍(从代码注释和架构特征推断)。
## TL;DR
- 选择 PostgreSQL 作为唯一消息总线NOTIFY/LISTEN避免引入 Kafka/Redis 等额外组件。
- signal_engine 改为 15 秒循环(原 5 秒CPU 降 60%,信号质量无影响。
- 双写 Cloud SQL 作为灾备,失败不阻断主流程。
- `agg_trades` 按月分区,避免单表过大影响查询性能。
- 认证采用自研 HMAC-SHA256 JWT不依赖第三方库。
- 前端使用 Next.js App Router + 纯客户端轮询,不使用 WebSocket 推送。
- 策略参数外置为 JSON 文件,支持热修改无需重启进程。
- 信号评分采用多层加权体系5层每层独立可调支持多策略并行。
## Canonical Facts
### 决策 1PostgreSQL 作为进程间消息总线
**决策**:使用 PostgreSQL `NOTIFY/LISTEN` 在 signal_engine 和 live_executor 之间传递信号,而非 Redis pub/sub 或消息队列。
**原因**(从代码推断):
- 系统已强依赖 PG避免引入新的基础设施依赖。
- 信号触发频率低(每 15 秒最多一次PG NOTIFY 完全满足延迟要求。
- 信号 payload 直接写入 `signal_indicators`NOTIFY 仅做触发通知,消费者可直接查表。
**取舍**:单点依赖 PGPG 宕机时信号传递和持久化同时失败(可接受,因为两者本就强耦合)。
**来源**`live_executor.py:1-10` 架构注释,`signal_engine.py:save_indicator` 函数。
---
### 决策 2信号引擎循环间隔从 5 秒改为 15 秒
**决策**`LOOP_INTERVAL = 15`(原注释说明原值为 5
**原因**:代码注释明确写道 "CPU降60%,信号质量无影响"。
**取舍**:信号触发延迟最坏增加 10 秒对于短线但非高频的策略TP/SL 以 ATR 倍数计,通常 >1% 波动10 秒的额外延迟影响可忽略不计。
**来源**`signal_engine.py:39` `LOOP_INTERVAL = 15 # 秒从5改15CPU降60%,信号质量无影响)`
---
### 决策 3agg_trades 表按月范围分区
**决策**`agg_trades` 使用 `PARTITION BY RANGE(time_ms)`,按月创建子表(如 `agg_trades_202603`)。
**原因**
- aggTrades 是最大的写入表(每秒数百条),无分区会导致单表膨胀。
- 按月分区支持高效的时间范围查询PG 分区裁剪)。
- 旧分区可独立归档或删除,不影响主表。
**取舍**:分区管理需要维护(`ensure_partitions()` 自动创建当月+未来2个月分区需定期执行跨分区查询性能取决于分区裁剪是否生效`time_ms` 条件必须是常量)。
**来源**`db.py:191-201, 360-393`
---
### 决策 4Cloud SQL 双写(非阻塞)
**决策**:所有写入操作在本地 PG 成功后,尝试相同写入到 Cloud SQL`10.106.0.3`Cloud SQL 失败不影响主流程。
**原因**提供数据异地备份Cloud SQL 作为只读副本或灾备使用。
**取舍**
- 本地 PG 和 Cloud SQL 可能出现数据不一致local 成功 + cloud 失败)。
- 双写增加每次写操作的延迟(两个网络 RTT但因为是 best-effort 且使用独立连接池,实际阻塞极少。
- live_executor 直接连 Cloud SQL`DB_HOST=10.106.0.3`),绕过本地 PG。
**来源**`db.py:23-29, 80-118``live_executor.py:50-55`
---
### 决策 5自研 JWT不用 PyJWT 等第三方库)
**决策**:使用 Python 标准库 `hmac`、`hashlib`、`base64` 手动实现 JWT 签发和验证。
**原因**推断减少依赖JWT 结构相对简单HMAC-SHA256 签名几十行代码即可实现。
**取舍**
- 需要自行处理过期、revoke、refresh token 等逻辑(代码中已有 `refresh_tokens` 表)。
- 非标准实现可能在边界情况(时钟偏差、特殊字符等)上与标准库行为不同。
- 无 JWT 生态工具支持(调试工具、密钥轮转库等)。
**来源**`auth.py:1-6`import hashlib, secrets, hmac, base64, json`auth.py:16-19`
---
### 决策 6策略配置外置为 JSON 文件
**决策**V5.x 策略的权重、阈值、TP/SL 倍数等参数存放在 `backend/strategies/*.json`signal_engine 每次 `load_strategy_configs()` 读取。
**原因**
- 策略调优频繁v51→v52 权重变化显著),外置避免每次改参数都要修改代码。
- 多策略并行signal_engine 同时运行 v51_baseline 和 v52_8signals对每个 symbol 分别评分。
- [inference] 支持未来通过前端或 API 修改策略参数而不重启进程(目前 signal_engine 每次循环重读文件 —— 需确认)。
**取舍**JSON 文件无类型检查,配置错误在运行时才发现;缺少配置 schema 校验。
**来源**`signal_engine.py:41-67``backend/strategies/v51_baseline.json``backend/strategies/v52_8signals.json`
---
### 决策 7信号评分采用五层加权体系
**决策**:信号评分分为 5 个独立层次(方向层、拥挤层、资金费率层、环境层、确认层、清算层、辅助层),每层有独立权重,总分 0~100阈值 75 触发信号。
**设计特点**
- 方向层CVD权重最高V5.1: 45分V5.2: 40分是核心指标。
- "standard" 档位score ≥ threshold75"heavy" 档位score ≥ max(threshold+10, 85)。
- 信号冷却:同一 symbol 同一策略触发后 10 分钟内不再触发。
- CVD 快慢线需同向才产生完整方向信号;否则标记 `no_direction=True` 不触发。
**取舍**:权重缩放逻辑较复杂(各层原始满分不统一,需先归一化再乘权重);`market_indicators` 缺失时给默认中间分,保证系统在数据不完整时仍能运行。
**来源**`signal_engine.py:410-651`
---
### 决策 8前端使用轮询而非 WebSocket
**决策**React 前端对 `/api/rates` 每 2 秒轮询慢速数据stats/history/signals每 120 秒轮询K 线图每 30 秒刷新。
**原因**(推断):
- 实现简单,无需维护 WebSocket 连接状态和断线重连逻辑。
- 数据更新频率2 秒/30 秒对轮询友好WebSocket 的优势在于毫秒级推送。
- FastAPI 已支持 WebSocket但实现 SSE/WS 推送需要额外的后端状态管理。
**取舍**:每 2 秒轮询 `/api/rates` 会产生持续的服务器负载;当用户量增加时需要加缓存或换 WebSocket。
**来源**`frontend/app/page.tsx:149-154`
---
### 决策 9live_executor 和 risk_guard 直连 Cloud SQL
**决策**`live_executor.py` 和 `risk_guard.py` 默认 `DB_HOST=10.106.0.3`Cloud SQL而不是本地 PG。
**原因**(推断):这两个进程运行在与 signal_engine 不同的环境(可能是另一台 GCP VM 或容器),直连 Cloud SQL 避免通过本地 PG 中转。
**取舍**live_executor 和 signal_engine 使用不同的 PG 实例,理论上存在数据读取延迟(双写同步延迟)。
**来源**`live_executor.py:50-55``risk_guard.py:47-53`
## Interfaces / Dependencies
无额外接口依赖,均为内部架构决策。
## Unknowns & Risks
- [inference] 所有决策均从代码推断,无明确的 ADRArchitecture Decision Record文档。
- [unknown] 策略配置是否支持热重载signal_engine 是否每次循环都重读 JSON未确认。
- [risk] 决策 4双写+ 决策 9live executor 直连 Cloud SQL组合下若本地 PG 和 Cloud SQL 数据不一致live_executor 可能读到滞后的信号或重复执行。
## Source Refs
- `backend/signal_engine.py:39` — LOOP_INTERVAL 注释
- `backend/signal_engine.py:44-67` — load_strategy_configs
- `backend/signal_engine.py:410-651` — evaluate_signal 完整评分逻辑
- `backend/db.py:23-29, 80-118` — Cloud SQL 双写连接池
- `backend/live_executor.py:50-55` — DB_HOST 配置
- `backend/auth.py:1-6` — 自研 JWT import
- `frontend/app/page.tsx:149-154` — 轮询间隔
- `backend/strategies/v51_baseline.json`, `v52_8signals.json`

100
docs/ai/07-glossary.md Normal file
View File

@ -0,0 +1,100 @@
---
generated_by: repo-insight
version: 1
created: 2026-03-03
last_updated: 2026-03-03
source_commit: 0d9dffa
coverage: deep
---
# 07 — Glossary
## Purpose
项目中使用的专业术语、领域术语和项目自定义术语的定义。
## TL;DR
- 项目混合使用量化交易、加密货币和工程术语,中英文混用。
- "R" 是风险单位1R = 单笔风险金额PAPER_RISK_PER_TRADE × 余额)。
- CVD 是核心指标:累计 delta = 主动买量 - 主动卖量,衡量买卖压力。
- ATR 用于动态计算止盈止损距离TP/SL 均以 ATR 倍数表示)。
- "tier" 指仓位档位light/standard/heavy对应不同仓位大小倍数。
## Canonical Facts
### 交易与量化术语
| 术语 | 定义 |
|------|------|
| **资金费率Funding Rate** | 永续合约中多空双方每8小时相互支付的费率。正费率多头付给空头负费率空头付给多头。以小数表示`0.0001` = 0.01%)。 |
| **永续合约Perpetual / Perp** | 无到期日的期货合约,通过资金费率机制锚定现货价格。本项目操作 Binance USDC-M 永续合约。 |
| **套利Arbitrage** | 持有现货多头 + 永续空头资金费率为正时空头每8小时收取费率收益实现无方向性风险的稳定收益。 |
| **年化Annualized** | `平均费率 × 3次/天 × 365天 × 100%`,将单次资金费率换算为年化百分比。 |
| **CVDCumulative Volume Delta** | 累计成交量差值 = 主动买量 - 主动卖量。正值表示买方主导负值表示卖方主导。本项目计算三个窗口CVD_fast30分钟、CVD_mid4小时、CVD_dayUTC日内。 |
| **aggTrade** | Binance 聚合成交数据:同一方向、同一价格、同一时刻的多笔成交合并为一条记录,包含 `is_buyer_maker` 字段0=主动买1=主动卖)。 |
| **is_buyer_maker** | `0`:买方是 taker主动买入`1`:买方是 maker被动成交即主动卖。CVD 计算0→买量1→卖量。 |
| **VWAPVolume Weighted Average Price** | 成交量加权平均价格。用于判断当前价格相对于短期平均成本的位置。 |
| **ATRAverage True Range** | 平均真实波动幅度,衡量市场波动性。本项目使用 5 分钟 K 线、14 周期 EMA 计算。 |
| **ATR Percentile** | 当前 ATR 在过去 24 小时内的历史分位数0~100衡量当前波动性是高还是低。 |
| **P95 / P99** | 过去 24 小时内成交量的第 95/99 百分位数,作为"大单阈值"。超过 P99 的成交视为大单,对信号评分有影响。 |
| **Long/Short Ratio多空比** | 全市场多头账户数 / 空头账户数。反映市场情绪拥挤程度。 |
| **Top Trader Position顶级交易者持仓比** | 大户多头持仓占比,范围 0~1。高于 0.55 视为多头拥挤,低于 0.45 视为空头拥挤。 |
| **Open InterestOI持仓量** | 市场上所有未平仓合约的总名义价值USD。OI 增加 ≥3% 视为环境强势信号。 |
| **Coinbase Premium** | Coinbase Pro BTC/USD 现货价格相对 Binance BTC/USDT 的溢价比例。正溢价(>0.05%)被视为看涨信号(美国机构买入)。以比例存储(如 `0.0005` = 0.05%)。 |
| **清算Liquidation** | 爆仓事件。空头清算多于多头清算(短时间内)视为看涨信号(逼空)。本项目使用 5 分钟窗口内多空清算 USD 之比进行评分。 |
| **R风险单位** | 单笔风险金额。1R = `初始余额 × 风险比例`(默认 2%,即 200U。盈亏以 R 倍数表示1R=保本2R=盈利1倍风险-1R=全亏。 |
| **PnL_R** | 以 R 为单位的盈亏:`pnl_r = (exit_price - entry_price) / risk_distance × direction_sign`。 |
| **TP1 / TP2Take Profit** | 止盈目标价。TP1 为第一目标触发后平一半仓位TP2 为第二目标(平剩余)。 |
| **SLStop Loss** | 止损价。SL 触发后视 TP1 是否已命中:未命中→亏损 1R已命中→保本sl_be 状态)。 |
| **Tier档位** | 仓位大小分级。`light`=0.5×R`standard`=1.0×R`heavy`=1.5×R。信号分数越高触发越重的档位score ≥ max(threshold+10, 85) → heavyscore ≥ threshold → standard。 |
| **Warmup冷启动** | signal_engine 启动时读取历史 `agg_trades` 填充滚动窗口的过程,完成前不产生信号(`state.warmup=True`)。 |
| **Signal Cooldown信号冷却** | 同一 symbol 同一策略触发信号后10 分钟内不再触发新信号,防止过度交易。 |
### 策略术语
| 术语 | 定义 |
|------|------|
| **v51_baseline** | V5.1 基准策略。6 个信号cvd, p99, accel, ls_ratio, oi, coinbase_premium。SL=1.4×ATRTP1=1.05×ATRTP2=2.1×ATR。 |
| **v52_8signals** | V5.2 扩展策略。8 个信号v51 + funding_rate + liquidation。SL=2.1×ATRTP1=1.4×ATRTP2=3.15×ATR更宽止损更高盈亏比目标。 |
| **Score / 信号分数** | 0~100 的综合评分,由多层加权指标累加得出,阈值 75 触发信号。 |
| **Direction Layer方向层** | 评分第一层,最高 45 分v51或 40 分v52。基于 CVD_fast、CVD_mid 同向性和 P99 大单方向。 |
| **Crowding Layer拥挤层** | 基于多空比和顶级交易者持仓的市场拥挤度评分。 |
| **Environment Layer环境层** | 基于持仓量变化OI change的市场环境评分。 |
| **Confirmation Layer确认层** | CVD 快慢线同向确认15 分(满足)或 0 分。 |
| **Auxiliary Layer辅助层** | Coinbase Premium 辅助确认0~5 分。 |
| **Accel Bonus加速奖励** | CVD 快线斜率正在加速时额外加分v51: +5分v52: +0分。 |
| **Score Factors** | 各层得分详情,以 JSONB 格式存储在 `paper_trades.score_factors``live_trades.score_factors`。 |
### 工程术语
| 术语 | 定义 |
|------|------|
| **Paper Trading / 模拟盘** | 不真实下单、仅模拟记录的交易,用于验证策略。数据存储在 `paper_trades` 表。 |
| **Live Trading / 实盘** | 通过 Binance API 真实下单执行的交易。数据存储在 `live_trades` 表。 |
| **Testnet** | Binance 测试网(`https://testnet.binancefuture.com`),使用虚拟资金。`TRADE_ENV=testnet`。 |
| **Production** | Binance 生产环境(`https://fapi.binance.com`),使用真实资金。`TRADE_ENV=production`。 |
| **Circuit Break熔断** | risk_guard 触发的保护机制,阻止新开仓甚至强制平仓。通过 `live_config` 表的 flag 通知 live_executor。 |
| **Dual Write双写** | 同一数据同时写入本地 PG 和 Cloud SQLCloud SQL 写失败不阻断主流程。 |
| **Partition / 分区** | `agg_trades` 表的月度子表(如 `agg_trades_202603`),用于管理大表性能。 |
| **NOTIFY/LISTEN** | PostgreSQL 原生异步通知机制。signal_engine 用 `NOTIFY new_signal` 触发live_executor 用 `LISTEN new_signal` 接收。 |
| **TradeWindow** | signal_engine 中的滚动时间窗口类,维护 CVD 和 VWAP 的实时滚动计算。 |
| **SymbolState** | 每个交易对的完整状态容器,包含三个 TradeWindow、ATRCalculator、market_indicators 缓存和信号冷却记录。 |
| **Invite Code邀请码** | 注册时必须提供的一次性(或限次)代码,由管理员通过 `admin_cli.py` 生成。 |
| **Subscription Tier** | 用户订阅等级(`free` 等),存储在 `subscriptions` 表,当前代码中使用有限。 |
| **万分之** | 前端显示资金费率时的单位表述,实际值 × 10000 展示。例如 `0.0001` 显示为 `1.0000 万分之`。 |
## Interfaces / Dependencies
无。
## Unknowns & Risks
- [inference] `Subscription Tier` 功能在 schema 中有定义但实际业务逻辑中使用程度不确定(可能是预留字段)。
- [inference] "no_direction" 状态CVD_fast 和 CVD_mid 不一致时)的处理逻辑:方向取 CVD_fast但标记为不触发信号可用于反向平仓判断。
## Source Refs
- `backend/signal_engine.py:1-16` — CVD/ATR/VWAP/P95/P99 架构注释
- `backend/signal_engine.py:69-81` — Paper trading 参数定义R、tier 倍数)
- `backend/signal_engine.py:170-207` — TradeWindow 类CVD/VWAP 定义)
- `backend/signal_engine.py:209-257` — ATRCalculator 类
- `backend/signal_engine.py:410-651` — evaluate_signal各层评分逻辑
- `backend/strategies/v51_baseline.json`, `v52_8signals.json` — 策略参数
- `backend/trade_config.py` — 交易对精度配置
- `frontend/app/page.tsx:186` — "万分之" 显示注释

View File

@ -0,0 +1,141 @@
---
generated_by: repo-insight
version: 1
created: 2026-03-03
last_updated: 2026-03-03
source_commit: 0d9dffa
coverage: deep
---
# 99 — Open Questions
## Purpose
记录文档生成过程中发现的未解决问题、不确定点和潜在风险。
## TL;DR
- `requirements.txt` 不完整,实际依赖需手动补齐。
- `users` 表在两个地方定义且 schema 不一致db.py vs auth.py
- live_executor / risk_guard 直连 Cloud SQL 但 signal_engine 写本地 PG存在数据同步延迟风险。
- 策略是否支持热重载(每次循环重读 JSON未确认。
- uvicorn 监听端口 4332 未在启动脚本中显式确认。
- 无 CI/CD无自动化测试。
## Open Questions
### 高优先级(影响正确性)
#### Q1users 表 schema 双定义不一致
**问题**`db.py` 的 `SCHEMA_SQL``auth.py``AUTH_SCHEMA` 均定义了 `users` 表,但字段不同:
- `db.py` 版本:`id, email, password_hash, role, created_at`(无 `discord_id`、无 `banned`
- `auth.py` 版本:`id, email, password_hash, discord_id, role, banned, created_at`
`init_schema()``ensure_auth_tables()` 都在 FastAPI startup 中调用,两次 `CREATE TABLE IF NOT EXISTS` 第一次成功后第二次静默跳过。**实际创建的是哪个版本?** 取决于调用顺序(先 `init_schema``ensure_auth_tables`),如果本地 PG 已有旧版表则字段可能缺失。
**影响**auth 相关功能discord_id 关联、banned 状态检查)可能在 schema 未更新的环境下失效。
**建议行动**:统一到 auth.py 版本,或添加 `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` 迁移。
**来源**`db.py:269-276``auth.py:28-37`
---
#### Q2live_executor 读 Cloud SQLsignal_engine 写本地 PG双写延迟是否可接受
**问题**signal_engine 写入本地 PG`signal_indicators`),同时双写 Cloud SQLlive_executor 直连 Cloud SQL 读取信号。若某次双写失败或延迟live_executor 可能错过信号或读到不一致数据。
**影响**:实盘信号丢失或执行延迟。
**建议行动**:确认 NOTIFY 是否也发送到 Cloud SQL即 live_executor 通过 LISTEN 接收信号,不依赖轮询读表);或将 live_executor 改为连接本地 PG。
**来源**`live_executor.py:50-55``db.py:76-95`
---
#### Q3requirements.txt 不完整
**问题**`requirements.txt` 只列出 `fastapi, uvicorn, httpx, python-dotenv, psutil`,但源码还 import 了 `asyncpg`、`psycopg2`(用于 psycopg2-binary、`aiohttp`、`websockets`(推断)等。
**影响**:新环境安装后进程无法启动。
**建议行动**:执行 `pip freeze > requirements.txt` 或手动补全所有依赖。
**来源**`backend/requirements.txt:1-5``backend/db.py:9-11``backend/live_executor.py:28-29`
---
### 中优先级(影响维护性)
#### Q4策略 JSON 是否支持热重载
**问题**`load_strategy_configs()` 在 `main()` 函数开头调用一次。不清楚 signal_engine 的主循环是否每次迭代都重新调用此函数。
**影响**:如果不支持热重载,修改策略 JSON 后需要重启 signal_engine 进程。
**来源**`signal_engine.py:44-67, 964`(需查看 main 函数结构)
---
#### Q5uvicorn 端口确认
**问题**:从 `frontend/next.config.ts` 推断 uvicorn 运行在 `127.0.0.1:4332`,但没有找到后端启动脚本明确指定此端口。
**建议行动**:在 `ecosystem.dev.config.js` 或启动脚本中显式记录端口。
**来源**`frontend/next.config.ts:8`
---
#### Q6market_indicators 表 schema 未在 SCHEMA_SQL 中定义
**问题**signal_engine 从 `market_indicators` 表读取数据(指标类型:`long_short_ratio`, `top_trader_position`, `open_interest_hist`, `coinbase_premium`, `funding_rate`),但该表的 CREATE TABLE 语句不在 `db.py``SCHEMA_SQL` 中。
**影响**:表由 `market_data_collector.py` 单独创建如果该进程未运行过表不存在signal_engine 会报错或返回空数据。
**建议行动**:将 `market_indicators` 表定义加入 `SCHEMA_SQL`,确保 `init_schema()` 能覆盖全量 schema。
**来源**`signal_engine.py:123-158``db.py:166-357`(未见 market_indicators 定义)
---
#### Q7liquidations 表 schema 未确认
**问题**signal_engine 查询 `liquidations` 表(`SELECT FROM liquidations WHERE symbol=%s AND trade_time >= %s`),但该表定义在 `SCHEMA_SQL` 中同样未找到。可能由 `liquidation_collector.py` 自行创建。
**来源**`signal_engine.py:395-407``liquidation_collector.py:28``ensure_table()` 函数)
---
### 低优先级(长期健康度)
#### Q8无 CI/CD 流水线
**问题**:仓库中没有 `.github/workflows/`、Dockerfile、docker-compose.yml 等部署自动化文件。所有部署为手动操作ssh + git pull + pm2 restart
**建议行动**:添加 GitHub Actions 用于基本 lint 检查和依赖安全扫描。
---
#### Q9无自动化测试
**问题**:未发现任何测试文件(`test_*.py`、`*.test.ts` 等)。策略验证完全依赖人工回测和模拟盘。
**建议行动**:至少为 `evaluate_signal()`、`TradeWindow`、`ATRCalculator` 添加单元测试,防止重构回归。
---
#### Q10生产环境硬编码密码风险
**问题**`db.py`、`live_executor.py`、`risk_guard.py` 中均有 testnet 默认密码 `arb_engine_2026` 硬编码在源代码里(通过 `os.getenv(..., "arb_engine_2026")` 方式)。
**影响**代码一旦泄露testnet 数据库可被访问;生产环境如果环境变量设置失败,会静默使用错误密码(失败时的错误信息较明确,但仍有风险)。
**建议行动**testnet 默认密码移除或通过单独的 `.env.testnet` 文件管理,不内嵌到源代码。
**来源**`db.py:19``live_executor.py:44``risk_guard.py:42`
---
#### Q11`signal_indicators` 表含 `strategy` 字段但 schema 未声明
**问题**`save_indicator()` 函数的 INSERT 语句包含 `strategy` 字段,但 `SCHEMA_SQL` 中的 `signal_indicators` 表定义不包含该字段。可能通过 `ALTER TABLE ADD COLUMN IF NOT EXISTS` 在运行时补充,或是后续版本添加但忘记更新 schema。
**来源**`signal_engine.py:690-699``db.py:205-224`signal_indicators 定义)
## Source Refs
- `backend/db.py:269-276` — db.py 版 users 表
- `backend/auth.py:28-37` — auth.py 版 users 表(含 discord_id, banned
- `backend/requirements.txt` — 不完整的依赖列表
- `backend/live_executor.py:44, 50-55` — DB_HOST 和默认密码
- `backend/risk_guard.py:42, 47-53` — DB_HOST 和默认密码
- `backend/signal_engine.py:395-407` — liquidations 表查询
- `backend/signal_engine.py:690-699` — strategy 字段 INSERT

52
docs/ai/INDEX.md Normal file
View File

@ -0,0 +1,52 @@
---
generated_by: repo-insight
version: 1
created: 2026-03-03
last_updated: 2026-03-03
source_commit: 0d9dffa
coverage: deep
---
# Arbitrage Engine — AI Documentation Index
**Project**: `arbitrage-engine`
**Summary**: Full-stack crypto perpetual futures funding-rate arbitrage monitoring and V5.x CVD/ATR-based short-term trading signal engine. Python/FastAPI backend + Next.js frontend + PostgreSQL + Binance USDC-M Futures.
## Generated Documents
| File | Description |
|------|-------------|
| [00-system-overview.md](./00-system-overview.md) | Project purpose, tech stack, repo layout, entry points, environment variables |
| [01-architecture-map.md](./01-architecture-map.md) | Multi-process architecture, component diagram, signal pipeline data flow, risk guard rules, frontend polling |
| [02-module-cheatsheet.md](./02-module-cheatsheet.md) | Module-by-module index: role, public interfaces, dependencies for all 20 backend + 15 frontend files |
| [03-api-contracts.md](./03-api-contracts.md) | All REST endpoints, auth flows, request/response shapes, error conventions |
| [04-data-model.md](./04-data-model.md) | All PostgreSQL tables, columns, partitioning strategy, storage design decisions |
| [05-build-run-test.md](./05-build-run-test.md) | 构建/运行/部署命令环境变量PM2 配置,回测和模拟盘操作 |
| [06-decision-log.md](./06-decision-log.md) | 9 项关键技术决策PG 消息总线、循环间隔、双写、分区、自研 JWT 等 |
| [07-glossary.md](./07-glossary.md) | 交易术语CVD/ATR/R/tier+ 工程术语paper trading/warmup/circuit break |
| [99-open-questions.md](./99-open-questions.md) | 11 个未解决问题users 表双定义冲突、依赖不完整、硬编码密码、无测试等 |
## Recommended Reading Order
1. **Start here**: `00-system-overview.md` — 了解项目定位和结构。
2. **Architecture**: `01-architecture-map.md` — 理解 7+ 进程的交互方式。
3. **Data**: `04-data-model.md` — 任何 DB 相关工作的必读;注意时间戳格式不统一问题。
4. **API**: `03-api-contracts.md` — 前端开发或 API 对接时参考。
5. **Module detail**: `02-module-cheatsheet.md` — 修改特定文件前的参考。
6. **Ops**: `05-build-run-test.md` — 部署和运维操作。
7. **Concepts**: `07-glossary.md` — 不熟悉量化术语时查阅。
8. **Risks**: `99-open-questions.md` — 开始开发前必读,了解已知风险点。
## Coverage Tier
**Deep** — 包含完整的模块签名读取、核心业务模块深度阅读signal_engine 全文、evaluate_signal 评分逻辑、backtest.py、构建运行指南、决策日志、术语表和开放问题。
## Key Facts for AI Agents
- **Signal engine is the core**: `backend/signal_engine.py` — change with care; affects all trading modes.
- **Strategy tuning via JSON**: modify `backend/strategies/v51_baseline.json` or `v52_8signals.json` to change signal weights/thresholds without code changes.
- **No ORM**: raw SQL via `asyncpg`/`psycopg2`; schema in `db.py:SCHEMA_SQL`.
- **Auth is custom JWT**: no third-party auth library; hand-rolled HMAC-SHA256 in `auth.py`.
- **`TRADE_ENV=testnet` default**: production use requires explicit env override + strong JWT_SECRET.
- **Dual timestamp formats**: `ts` = Unix seconds, `time_ms`/`entry_ts`/`timestamp_ms` = Unix milliseconds — do not confuse.
## Generation Timestamp
2026-03-03T00:00:00 (UTC)

View File

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

View File

@ -0,0 +1,251 @@
# V5.4 Execution State Machine
本文档描述 V5.4 策略的执行层状态机设计,重点是 maker/taker 组合策略,确保在不牺牲入场/出场时效性的前提下,最大程度降低手续费与滑点摩擦。
设计目标:
- 将"信号质量"和"执行质量"解耦,执行层只负责:更便宜、更稳定地实现既定 TP/SL/flip/timeout 规则。
- 入场阶段在不丢失大行情的前提下尽量使用 maker
- 出场阶段 TP 强制 maker 主路径,同时用 taker 做安全兜底;
- SL 始终使用 taker优先保证风控。
---
## 1. 入场状态机Entry State Machine
### 1.1 状态定义
- `IDLE`:无持仓、无挂单,等待信号。
- `ENTRY_PENDING_MAKER`已下入场限价挂单post-only等待成交或超时。
- `ENTRY_FILL`:入场成交完成(全仓或部分)。
- `ENTRY_FALLBACK_TAKER`:超时后使用 taker 市价单补齐未成交部分。
### 1.2 关键参数
- `entry_price_signal`:信号引擎给出的入场参考价(通常为最新价或中间价 mid
- `tick_size`:交易所最小价格步长。
- `entry_offset_ticks`maker 入场挂单相对盘口的偏移(通常为 12 个 tick
- `entry_timeout_ms`:入场 maker 挂单最大等待时间(如 30005000ms
- `entry_fallback_slippage_bps`fallback taker 允许的最大滑点(基础保护,超出则放弃补仓或缩小仓位)。
### 1.3 状态机伪代码
```pseudo
state = IDLE
on_signal_open(signal):
if state != IDLE:
return // 避免重复入场
// 计算 maker 挂单价格
side = signal.side // LONG or SHORT
ref_price = best_bid_ask_mid()
if side == LONG:
entry_price_maker = min(ref_price, best_bid() + entry_offset_ticks * tick_size)
else: // SHORT
entry_price_maker = max(ref_price, best_ask() - entry_offset_ticks * tick_size)
// 下 post-only 入场挂单
order_id = place_limit_post_only(side, entry_price_maker, target_size)
entry_start_ts = now()
state = ENTRY_PENDING_MAKER
on_timer():
if state == ENTRY_PENDING_MAKER:
if order_filled(order_id):
filled_size = get_filled_size(order_id)
if filled_size >= min_fill_ratio * target_size:
state = ENTRY_FILL
return
if now() - entry_start_ts >= entry_timeout_ms:
// 超时,取消剩余挂单
cancel_order(order_id)
remaining_size = target_size - get_filled_size(order_id)
if remaining_size <= 0:
state = ENTRY_FILL
return
// 兜底:按容忍滑点发市价单
mkt_price = best_bid_ask_mid()
theoretical_price = ref_price_at_signal
slippage_bps = abs(mkt_price - theoretical_price) / theoretical_price * 10000
if slippage_bps <= entry_fallback_slippage_bps:
place_market_order(side, remaining_size)
state = ENTRY_FILL
else:
// 滑点过大,放弃补仓或缩减仓位
state = ENTRY_FILL // 仅保留已成交部分
```
---
## 2. 出场状态机TP/SL/Flip/Timeout
出场分为四类TP止盈、SL止损、flip信号翻转、timeout超时退出
### 2.1 通用状态
- `POSITION_OPEN`:持仓打开,已根据策略下好 TP/SL 限价单。
- `TP_PENDING_MAKER`TP 限价挂单等待成交。
- `TP_FALLBACK_TAKER`TP 越价未成交时,撤单+市价平仓兜底。
- `SL_PENDING`:止损触发,直接发送 taker 单。
- `FLIP_PENDING`:翻转触发,先平仓再反向开仓(可复用入场状态机)。
- `TIMEOUT_PENDING`:超时触发,按策略规则离场(可偏 maker
### 2.2 关键参数
- `tp1_r`, `tp2_r`TP1/TP2 的目标 R 距离(如 1.0R / 2.0R)。
- `sl_r`:止损距离(如 -1.0R)。
- `tp_timeout_ms`:价格越过 TP 水平后TP 限价未成交的允许时间窗口。
- `flip_threshold`翻转触发条件score + OBI + VWAP 等综合判断)。
- `timeout_seconds`:最大持仓时间,用于 timeout 出场。
### 2.3 TP 状态机maker 主路径 + taker 兜底)
```pseudo
on_position_open(pos):
// 开仓后立即挂 TP1 限价单maker
tp1_price = pos.entry_price + pos.side * tp1_r * pos.risk_distance
tp2_price = pos.entry_price + pos.side * tp2_r * pos.risk_distance
// 半仓挂 TP1半仓挂 TP2
tp1_id = place_limit_post_only(exit_side(pos.side), tp1_price, pos.size * 0.5)
tp2_id = place_limit_post_only(exit_side(pos.side), tp2_price, pos.size * 0.5)
pos.state = POSITION_OPEN
on_timer():
if pos.state == POSITION_OPEN:
current_price = best_bid_ask_mid()
// 检查 TP1 越价兜底
tp1_crossed = (pos.side == LONG and current_price >= tp1_price) or
(pos.side == SHORT and current_price <= tp1_price)
if tp1_crossed and not pos.tp1_cross_ts:
pos.tp1_cross_ts = now()
if pos.tp1_cross_ts:
if order_filled(tp1_id):
pos.tp1_cross_ts = None // 成交,清除计时
elif now() - pos.tp1_cross_ts >= tp_timeout_ms:
cancel_order(tp1_id)
remaining = size_tp1 - get_filled_size(tp1_id)
if remaining > 0:
place_market_order(exit_side(pos.side), remaining)
// 检查 TP2 越价兜底
tp2_crossed = (pos.side == LONG and current_price >= tp2_price) or
(pos.side == SHORT and current_price <= tp2_price)
if tp2_crossed and not pos.tp2_cross_ts:
pos.tp2_cross_ts = now()
if pos.tp2_cross_ts:
if order_filled(tp2_id):
pos.tp2_cross_ts = None
elif now() - pos.tp2_cross_ts >= tp_timeout_ms:
cancel_order(tp2_id)
remaining = size_tp2 - get_filled_size(tp2_id)
if remaining > 0:
place_market_order(exit_side(pos.side), remaining)
// 检查是否已全部平仓
if pos_size(pos) <= 0:
pos.state = CLOSED
```
### 2.4 SL 状态机(纯 taker
```pseudo
on_sl_trigger(pos, sl_price):
// 触发条件可以来自价格监控或止损订单触发
// 这里策略层只关心:一旦触发,立即使用 taker
close_size = pos_size(pos)
if close_size > 0:
place_market_order(exit_side(pos.side), close_size)
pos.state = CLOSED
```
SL 不做 maker 逻辑,避免在极端行情下挂单无法成交。
### 2.5 Flip 状态机(平旧仓 + 新开仓)
```pseudo
on_flip_signal(pos, new_side, flip_context):
if not flip_condition_met(flip_context):
return
// flip 条件score < 85 AND OBI 翻转 AND 价格跌破 VWAP三条件同时满足
// flip_condition_met 由信号引擎判断
// 1) 先平旧仓(按 SL 逻辑,优先 taker
close_size = pos_size(pos)
if close_size > 0:
place_market_order(exit_side(pos.side), close_size)
pos.state = CLOSED
// 2) 再按入场状态机开新仓
new_signal = build_signal_from_flip(new_side, flip_context)
entry_state_machine.on_signal_open(new_signal)
```
flip 的关键是:**门槛更高**(如 score < 85 OBI 翻转且价格跌破 VWAP尽量减少在震荡行情中来回打脸
### 2.6 Timeout 状态机(超时出场)
```pseudo
on_timer():
if pos.state == POSITION_OPEN and now() - pos.open_ts >= timeout_seconds:
// 可以偏 maker先挂限价平仓超时再 taker
timeout_price = best_bid_ask_mid()
size = pos_size(pos)
oid = place_limit_post_only(exit_side(pos.side), timeout_price, size)
pos.timeout_order_id = oid
pos.timeout_start_ts = now()
pos.state = TIMEOUT_PENDING
if pos.state == TIMEOUT_PENDING:
if order_filled(pos.timeout_order_id):
pos.state = CLOSED
elif now() - pos.timeout_start_ts >= timeout_grace_ms:
cancel_order(pos.timeout_order_id)
remaining = pos_size(pos)
if remaining > 0:
place_market_order(exit_side(pos.side), remaining)
pos.state = CLOSED
```
---
## 3. 监控指标(执行层 KPI
| 指标 | 说明 | 目标 |
|------|------|------|
| `maker_ratio_entry` | 入场成交中 maker 比例 | ≥ 50% |
| `maker_ratio_tp` | TP 成交中 maker 比例 | ≥ 80% |
| `avg_friction_cost_r` | 每笔平均摩擦成本(手续费+滑点,以 R 计) | ≤ 0.15R |
| `entry_timeout_rate` | 入场超时触发 taker 兜底比例 | ≤ 30% |
| `tp_overshoot_rate` | TP 越价后兜底比例 | ≤ 20% |
| `flip_frequency` | 每笔持仓中 flip 次数 | ≤ 1次/持仓 |
---
## 4. 设计原则汇总
| 场景 | 主路径 | 兜底 |
|------|--------|------|
| 入场 | limit post-only盘口内侧 1-2 tick | 超时 → taker滑点容忍内 |
| TP1 / TP2 | limit post-only预挂 | 越价 X ms 未成交 → 撤单 + taker |
| SL | — | 纯 taker立即执行 |
| Flip | 平仓用 taker新开仓复用入场逻辑 | — |
| Timeout | limit post-only | grace period 后 → taker |
> **标签**`#EXECUTION-MAKER-TAKER`
> **状态**V5.4 设计文档,待 3/11 A/B test 结束后进入实现阶段
> **作者**小范xiaofan+ 露露lulu
> **日期**2026-03-06

View File

@ -0,0 +1,205 @@
---
title: Funding Rate Arbitrage Plan v2
---
> 初版日期2026年2月
> v2更新2026年2月24日露露×小周15轮联合数据验证
> 数据来源Binance官方API实算覆盖2019-2026全周期
---
## 核心定位
**自用低风险稳定收益策略**,不依赖行情判断,赚市场机制的钱。
- 目标年化:全周期均值 **11-14%**PM模式净值
- 风险等级:低(完全对冲,不暴露方向风险)
- 对标大幅优于债基年化2%、银行理财3-4%
- 执行方式:**选好点位买入后长期持有不动**
---
## 原理
永续合约每8小时结算一次资金费率
- 多头多 → 多头付钱给空头(费率为正)
- 空头多 → 空头付钱给多头(费率为负)
**套利做法**:现货买入 + 永续做空完全对冲币价风险净收资金费率USDT结算
```
买入1 BTC现货$96,000
做空1 BTC永续$96,0001倍杠杆
BTC涨跌两边对冲净盈亏=0
资金费率每8小时直接收USDT
```
---
## 全周期真实数据2019-2026Binance实算
### BTCBTCUSDT永续2019.9至今)
| 年份 | 年化毛收益 | 市场特征 |
|------|-----------|---------|
| 2019 | 7.48% | 起步期 |
| 2020 | 17.19% | 牛市前奏 |
| **2021** | **30.61%** | **大牛市** |
| 2022 | 4.16% | 熊市 |
| 2023 | 7.87% | 复苏 |
| 2024 | 11.92% | 牛市 |
| 2025 | 5.13% | 震荡 |
| 2026 YTD | 2.71% | 震荡 |
| **全周期均值** | **12.33%** | - |
| **PM净年化** | **11.67%** | 扣0.06%手续费 |
- 每8小时费率均值0.011257%
- 负费率占比13.07%
### ETHETHUSDT永续2019.11至今)
| 年份 | 年化毛收益 | 市场特征 |
|------|-----------|---------|
| 2019 | 8.91% | 起步期 |
| 2020 | 27.41% | 牛市前奏 |
| **2021** | **37.54%** | **大牛市** |
| 2022 | 0.79% | 熊市 |
| 2023 | 8.26% | 复苏 |
| 2024 | 12.96% | 牛市 |
| 2025 | 4.93% | 震荡 |
| 2026 YTD | 0.83% | 震荡 |
| **全周期均值** | **14.87%** | - |
| **PM净年化** | **14.09%** | 扣0.06%手续费 |
- 每8小时费率均值0.013584%
- 负费率占比12.17%
### BTC+ETH 50/50组合
- **全周期组合年化毛收益13.81%**
- **PM净年化13.08%**
---
## 关键结论(数据验证后确认)
1. **全周期PM净年化11-14%**,远超债基和银行理财
2. **收益是USDT**,不承受币价波动风险
3. **费率为负时不动A方案**负费率仅占12-13%,长期均值为正
4. **只做BTC和ETH**:流动性最好、费率最稳定,不做山寨币
5. **年际波动大**牛市30%+熊市0-4%,需要耐心
6. **50万美金在BTC/ETH市场无滑点问题**
7. **策略已被机构验证**EthenaTVL数十亿、Binance Smart Arbitrage、Presto Research回测
---
## Portfolio Margin组合保证金· 必须开通
**为什么必须用PM模式**
| 维度 | 传统模式 | Portfolio Margin |
|------|---------|-----------------|
| $50万可做规模 | ~$25万一半做保证金 | ~$47.5万95%利用率) |
| 额外USDT需求 | 需$25万USDT | 不需要 |
| 年化收益 | ~6%(资金效率低) | ~12%(资金效率高) |
| 风险识别 | 不识别对冲 | 识别对冲,保证金需求低 |
**PM关键信息**
- Binance 2024年10月已取消最低余额要求所有用户可开
- BTC/ETH抵押率0.955%折扣)
- 支持360+种加密货币作为抵押品
**PM风控线**
- uniMMR < 1.35 内部预警
- uniMMR < 1.25 评估减仓
- uniMMR < 1.20 Binance限制开新仓
- uniMMR < 1.05 触发强平
---
## 执行流程
### 开仓
- 确认市场趋势(不急,等好点位)
- 开通Portfolio Margin
- 同时执行现货买入BTC/ETH + 永续做空等值1倍杠杆
### 持仓
- **完全不动**每8小时自动收取资金费率
- 费率为负不平仓,长期均值为正
- 只监控uniMMR安全垫
### 平仓条件(极端情况)
- 正常情况:**不平仓,长期持有**
- 极端情况uniMMR接近1.25时评估是否减仓
---
## 手续费说明PM模式下影响极小
| 操作 | 0.06%费率档 | $50万单次 |
|------|-----------|----------|
| 现货买入 | 0.06% | $285 |
| 永续开空 | 0.02% | $95 |
| **开仓合计** | | **$380** |
因为"买入后不动",手续费只在开仓时发生一次。
$50万本金年收益约$6万12%$380手续费可忽略。
---
## 风险清单
| 风险 | 严重程度 | 说明 |
|------|---------|------|
| 市场周期 | ⚠️ 中 | 熊市年化可降至0-4%,但不亏本金 |
| 费率持续为负 | 🟢 低 | 历史负费率占比仅12-13%,长期均值为正 |
| 交易所对手方 | ⚠️ 中 | FTX教训建议未来考虑分散 |
| 爆仓PM模式 | 🟢 低 | 1倍杠杆+对冲理论需BTC翻倍才触发 |
| 基差波动 | 🟢 低 | 长期持有不影响,只影响平仓时机 |
---
## 与替代方案对比
| 方案 | 年化 | 风险 | 操作难度 | 备注 |
|------|------|------|---------|------|
| **本方案(自建)** | 11-14% | 低 | 中 | 完全自主可控 |
| Ethena sUSDe | 4-15% | 中(合约风险) | 低 | DeFi依赖协议安全 |
| Binance Smart Arbitrage | 未知 | 低 | 低 | 官方产品,黑盒 |
| 银行理财 | 3-4% | 极低 | 无 | 对标基准 |
| 债基 | 2% | 低 | 无 | 对标基准 |
---
## 执行计划
### 第一阶段:准备(现在)
- [ ] 开通Binance Portfolio Margin
- [ ] 确认VIP等级和手续费档位
- [ ] 准备资金USDT入金
### 第二阶段模拟验证可选1-2个月
- [ ] 搭建模拟系统,实时跑数据验证
- [ ] 对比模拟结果与历史回测
### 第三阶段:入场
- [ ] 等待合适入场时机(趋势确认)
- [ ] 同时开BTC+ETH对冲仓位
- [ ] 开始收费率
### 第四阶段:长期持有
- [ ] 每日检查uniMMR安全垫
- [ ] 每周记录累计收益
- [ ] 不主动平仓,长期持有
---
## 数据验证过程
本文档数据经过露露Claude Opus 4.6和小周GPT-5.3-Codex15轮交叉验证
- 数据来源Binance fapi/v1/fundingRate 官方API
- 覆盖周期2019年9月至2026年2月约6.5年)
- 验证内容费率均值、年化收益、负费率占比、手续费敏感性、PM模式资金效率
- 外部参考Presto Research、Ethena、CoinCryptoRank、FMZ量化

View File

@ -0,0 +1,20 @@
---
title: Project ArbitrageEngine
---
套利引擎ArbitrageEngine简称 AE— 资金费率自动化套利系统 + 短线交易信号系统。
## 当前状态
🟢 **V2-V4 已上线** — 权限管控 + aggTrades采集 + 成交流分析面板
🟡 **V5 方案定稿** — 短线交易信号系统,待开发
🔗 **面板地址**https://arb.zhouyangclaw.com
⏳ **实盘阻塞**等范总提供Binance API Key + PM + 资金
## 文档
- [V5 短线交易信号系统方案](/docs/project-arbitrage-engine/v5-signal-system) — 信号体系、风控、回测方案、开发路线图 🆕
- [V2-V4 产品技术文档](/docs/project-arbitrage-engine/v2-v4-plan) — 权限管控 / aggTrades / 成交流面板
- [Phase 0 开发进度](/docs/project-arbitrage-engine/phase0-progress) — 已完成功能、Git结构
- [Funding Rate Arbitrage Plan v2](/docs/project-arbitrage-engine/funding-rate-arbitrage-plan) — 策略原理、数据验证、执行方案
- [Requirements v1.3.1](/docs/project-arbitrage-engine/requirements-v1.3) — 完整PRD产品+技术+商业)

View File

@ -0,0 +1,138 @@
---
title: Phase 0 开发进度
---
> 更新日期2026年2月27日
> 状态:🟡 Phase 0 进行中监控面板已上线SaaS MVP已上线
---
## Phase 0 — 已完成
### 监控面板arb.zhouyangclaw.com
**上线时间**2026-02-26
**技术栈**
- 后端FastAPIPython端口4332
- 前端Next.js 16 + shadcn/ui + Tailwind + Recharts端口4333
- 部署:小周服务器 34.84.9.167Caddy反代HTTPS
**已实现功能**
| 页面 | 功能 | 状态 |
|------|------|------|
| 仪表盘(/ | BTC/ETH实时费率、标记价格、下次结算时间 | ✅ 2秒刷新 |
| 历史(/history | 过去7天费率走势图Recharts折线图+ 历史记录表格 | ✅ |
| 信号(/signals | 套利信号历史记录(触发时间/币种/年化) | ✅ |
| 说明(/about | 策略原理、历史年化数据表、风险说明 | ✅ |
**API端点**
```
GET /api/health — 健康检查
GET /api/rates — 实时资金费率BTC/ETH
GET /api/history — 7天历史费率数据
GET /api/stats — 7天统计均值/年化/50-50组合
```
**性能优化**
- rates3秒缓存2秒前端刷新不会触发限速
- history/stats60秒缓存避免Binance API限速
- User-Agent已设置防403
---
### 信号推送系统 ✅
**逻辑**BTC或ETH 7日年化 > 10% 时自动触发
**推送渠道**Discord Bot API#agent-team频道
**信号格式**
```
📊 套利信号
BTC 7日年化: 12.33%
ETH 7日年化: 8.17%
建议BTC 现货+永续对冲可开仓
时间: 2026-02-26 14:00 UTC
```
**定时任务**每小时检查一次crontab小周服务器
**数据库**SQLitearb.dbsignal_logs表记录推送历史
---
### SaaS MVP 用户系统 ✅
**上线时间**2026-02-26
**新增页面**
| 页面 | 功能 |
|------|------|
| /register | 邮箱+密码注册支持绑定Discord ID |
| /login | JWT登录 |
| /dashboard | 用户面板订阅等级、Discord绑定、升级入口 |
**API端点**
```
POST /api/auth/register — 注册
POST /api/auth/login — 登录返回JWT
POST /api/user/bind-discord — 绑定Discord ID
GET /api/user/me — 获取用户信息
GET /api/signals/history — 信号历史
```
**订阅等级预设**(支付接入前为占位):
| 等级 | 价格 | 功能 |
|------|------|------|
| 免费 | ¥0 | 实时费率面板 |
| Pro | ¥99/月 | 信号推送+历史数据 |
| Premium | ¥299/月 | Pro全部+定制阈值+优先客服 |
---
## Git仓库
- **地址**https://git.darkerilclaw.com/lulu/arbitrage-engine
- **主要目录结构**
```
backend/
main.py — FastAPI主文件rates/history/stats + 缓存)
auth.py — 用户注册/登录/JWT
subscriptions.py — 订阅管理
signal_pusher.py — 信号检测+Discord推送
frontend/
app/
page.tsx — 仪表盘
history/ — 历史页
signals/ — 信号历史页
about/ — 策略说明页
register/ — 注册页
login/ — 登录页
dashboard/ — 用户面板
components/
Navbar.tsx — 响应式导航(手机端汉堡菜单)
RateCard.tsx — 费率卡片
StatsCard.tsx — 统计卡片
FundingChart.tsx — 费率走势图
```
---
## Phase 1 — 待开发
> 需范总提供Binance API Key后开始
| 功能 | 依赖 | 预估工时 |
|------|------|---------|
| 接入真实Binance账户余额/持仓 | Binance API Key只读权限 | 1天 |
| 手动开仓/平仓界面 | 范总确认Portfolio Margin已开通 | 2天 |
| 自动再平衡(持仓期间) | Phase 1基础完成后 | 2天 |
| 风控熔断+自动告警 | — | 1天 |
**Phase 1开启条件范总需提供**
1. Binance API KeyRead + Trade禁止Withdraw
2. 确认Portfolio Margin账户已开通
3. 初始资金就位(建议$500 = BTC$250 + ETH$250

View File

@ -0,0 +1,540 @@
---
title: Requirements v1.3.1
---
> 定稿日期2026年2月24日
> 参与露露Claude Opus 4.6、小周GPT-5.3-Codex、范总确认
> 状态:✅ 需求锁定,待开发
> 版本v1.3.1(部署方案确认)
---
## 0. 文档定位
- **文档类型**:项目基石级 PRD + 技术需求定稿(用于研发、验收、后续商业化)
- **优先级**:安全性 > 可控性 > 稳定性 > 收益表现 > 开发速度
- **约束前提**:先需求锁定,再开发;先 API 全测通,再实盘;先 Dry Run 1 周,再 Real
---
## 1. 项目定义与商业化定位
- **产品名**套利引擎ArbitrageEngine简称 AE
- **定位升级**:从 v1.2 的"范总单用户工具"升级为"可商业化产品雏形"
- Phase 1服务范总实盘验证单用户形态
- 但架构上 Day1 预埋多租户和计费能力,避免二次重构
- **核心价值主张**
- 对用户低门槛执行资金费率套利PM + 风控 + 可视化)
- 对团队:沉淀可复制交易基础设施,具备对外 SaaS 化能力
- **竞争差异化**
- 完整前端非CLI黑框
- 安全第一TOTP、审计、熔断
- Dry Run验证降低用户心理门槛
- PM优化资金效率提升
- 自定义风控(非黑盒)
---
## 2. 目标与边界
**目标Phase 1**
- Binance 上完成 BTC/ETH 对冲套利闭环
- 支持 PM 模式,执行"开/平人工确认,中间自动运行"
- 全链路审计、风控、告警、报表可用
**非目标Phase 1 不做)**
- 不做多交易所聚合
- 不做自动择时开平仓
- 不做资金托管与代客资管
- 不做公开注册与支付计费(仅预埋)
---
## 3. 已确认业务决策(锁定)
| 项目 | 确认结果 |
|------|---------|
| 执行模式 | C — 开仓/平仓需人工确认,持仓期间自动化 |
| 初始实盘资金 | $500BTC $250 + ETH $250 |
| 收益处理 | 留账户复利,不自动提取 |
| 部署服务器 | 小周服务器 34.84.9.167GCP东京Binance延迟11ms |
| 上线节奏 | Phase 0 API测通 → Dry Run 1周 → Real 2个月 |
| 费率为负 | 完全不动A方案 |
---
## 4. 系统状态机
### 模式状态
```
DRY_RUN ──(Phase 0 checklist 100%通过 + 范总确认)──→ REAL
REAL ──(手动降级)──→ DRY_RUN
```
### 交易生命周期
```
IDLE ──(用户点"开始执行"+二次确认)──→ OPENING
OPENING ──(双腿成交+偏差≤1%)──→ HOLDING
OPENING ──(单边失败+补偿失败/超时/API连续失败)──→ HALTED
HOLDING ──(用户点"平仓"+确认)──→ CLOSING
HOLDING ──(uniMMR<1.25) HALTED不自动平仓禁止新操作立即告警
CLOSING ──(双腿平完+仓位归零)──→ IDLE
任意状态 ──(硬风控触发/关键系统异常)──→ HALTED
HALTED ──(范总手动恢复)──→ IDLE
```
---
## 5. 风控参数(硬编码,不可前端修改)
| 参数 | 值 | 说明 |
|------|-----|------|
| 最大单次名义 | $600 | $500本金+20%缓冲 |
| 最大滑点 | 0.10% | 超过取消下单 |
| 最大对冲偏差 | 1.00% | \|spot-perp\|/target |
| 最大杠杆 | 1x | 不可修改 |
| uniMMR预警线 | 1.35 | 告警 |
| uniMMR危险线 | 1.25 | 自动HALTED |
| API失败熔断 | 连续5次 | 进入HALTED |
| 单腿超时(补单) | 8s | 触发补单逻辑 |
| 单腿超时(熔断) | 30s | 触发HALTED |
| 时钟漂移阈值 | >1000ms | 禁止交易请求 |
---
## 6. Dry Run 对照机制
Dry Run 期间必须记录"拟交易账本"
- 拟下单价格、拟成交数量、拟手续费、拟持仓
- 拟资金费率收入(按实际市场费率与拟仓位估算)
**产出对照报告**
- 如果 Real 执行,理论会得到的收益区间
- 引擎计算准确性与一致性验证
**通过标准**Dry Run 1周内无关键错配、无状态机死锁、无风险漏报
---
## 7. 功能模块清单Phase 1
### 认证与安全
- 账号密码 + TOTPGoogle Authenticator
- 会话超时30分钟
- 连续登录失败锁定5次失败后锁定15分钟
- 审计日志全记录append-only
### Binance 接入
- API权限自检Read/Trade only禁Withdraw
- 行情、费率、账户、持仓、下单、回查
- 连接状态实时监控(心跳检测)
- API调用频率控制避免触发限流
### 执行引擎
- REST并发下单现货买入 + 永续做空同时发出)
- WebSocket订阅成交回报、仓位变化
- 单边失败补偿、对冲偏差修复、熔断
- 下单前检查:余额、价格、滑点预估
### 监控告警
- uniMMR、对冲偏差、当前/预测费率、累计收益
- Discord实时告警 + 每日自动汇报
- 双通道告警预留Discord + 邮件/短信)
### 报表
- 8小时/日/周/月收益统计
- 手续费统计、资金费率贡献
- 净值曲线图表
- Dry Run对照报告
### 运维
- PM2健康检查、自动拉起
- 重启后自动对账恢复
- 分api/worker进程
---
## 8. 多租户架构预埋v1.3核心新增)
### 数据模型要求
- 所有核心业务表强制包含 `user_id`(或 `tenant_id`
### 执行隔离要求
- Worker任务必须携带 `user_id` 上下文
- 严禁跨用户读取订单、仓位、密钥、日志
### 密钥管理
- 按用户分区加密存储(每用户独立密钥上下文)
- 主密钥与业务库分离,密钥不落盘
### 权限模型预留
- 用户表保留 `role` 字段RBAC扩展位
- 前端不写死"唯一管理员"
### 审计不可篡改
- 审计日志 append-only禁止更新/删除业务接口
- 仅允许归档,不允许逻辑改写
- 预留哈希链字段(后续可选)
---
## 9. Binance API 接口清单
### 认证与账户Phase 0 必测)
| 接口 | 用途 |
|------|------|
| `GET /api/v3/account` | Spot账户资产、权限 |
| `GET /fapi/v2/account` | Futures账户信息 |
| `GET /fapi/v2/balance` | Futures余额 |
| `GET /fapi/v2/positionRisk` | 永续持仓风险 |
| `GET /fapi/v1/leverageBracket` | 杠杆/名义分档 |
| `GET /sapi/v1/portfolio/account` | PM账户信息 |
### 行情与费率
| 接口 | 用途 |
|------|------|
| `GET /api/v3/ticker/bookTicker` | Spot最优买卖价 |
| `GET /fapi/v1/ticker/bookTicker` | Perp最优买卖价 |
| `GET /fapi/v1/premiumIndex` | 标记价格+当前/预测费率 |
| `GET /fapi/v1/fundingRate` | 历史费率 |
| `GET /api/v3/depth` | Spot深度滑点估算 |
| `GET /fapi/v1/depth` | Perp深度滑点估算 |
### 交易执行
| 接口 | 用途 |
|------|------|
| `POST /api/v3/order` | Spot下单市价/限价) |
| `GET /api/v3/order` | Spot订单回查 |
| `DELETE /api/v3/order` | Spot撤单 |
| `POST /fapi/v1/order` | Futures下单开空/平空) |
| `GET /fapi/v1/order` | Futures订单回查 |
| `DELETE /fapi/v1/order` | Futures撤单 |
| `GET /fapi/v1/openOrders` | 未完成单查询 |
| WebSocket listenKey | 成交回报+仓位变化 |
### 资金与收益
| 接口 | 用途 |
|------|------|
| `GET /fapi/v1/income` | 资金费率收入、手续费、盈亏 |
| `GET /fapi/v2/positionRisk` | 未实现PnL、保证金风险 |
---
## 10. 前端页面清单
### `/login`
- 用户名/密码 + TOTP输入
- 登录审计记录
### `/dashboard`
- 当前模式Dry Run / Real+ 状态机状态
- BTC/ETH仓位卡片持仓量、对冲偏差、当前/预测费率
- uniMMR + 风险灯(🟢 >1.35 / 🟡 1.25-1.35 / 🔴 <1.25
- 当日收益、累计收益、手续费
- 净值收益曲线图
### `/execute`
- 参数展示只读BTC $250 / ETH $250
- 「开始执行(开仓)」按钮(二次确认弹窗)
- 「请求平仓」按钮(二次确认弹窗)
- 执行过程实时日志流SSE
### `/risk`
- 风控阈值参数表(只读展示)
- 告警历史列表
- 熔断记录 + 恢复操作按钮
### `/records`
- 订单记录Spot/Futures可筛选
- 费率收入记录8h维度可按日/周/月汇总)
- 审计日志(全操作记录)
### `/settings`
- API Key配置加密存储显示为***
- Discord Webhook配置
- 模式切换Dry Run ↔ Real需二次确认
---
## 11. 数据库结构PostgreSQLv1.3
```sql
-- 用户(多租户预埋)
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username VARCHAR(64) UNIQUE NOT NULL,
password_hash VARCHAR(256) NOT NULL,
totp_secret_enc VARCHAR(256) NOT NULL,
role VARCHAR(16) DEFAULT 'admin', -- RBAC预留
status VARCHAR(16) DEFAULT 'active',
created_at TIMESTAMPTZ DEFAULT NOW(),
last_login_at TIMESTAMPTZ
);
-- API凭据按用户分区
CREATE TABLE api_credentials (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id) NOT NULL,
account_name VARCHAR(64),
api_key_enc TEXT NOT NULL,
api_secret_enc TEXT NOT NULL,
perm_read BOOLEAN DEFAULT true,
perm_trade BOOLEAN DEFAULT true,
perm_withdraw BOOLEAN DEFAULT false,
ip_whitelist_ok BOOLEAN DEFAULT false,
pm_enabled BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- 策略实例
CREATE TABLE strategy_instances (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id) NOT NULL,
mode VARCHAR(16) NOT NULL, -- DRY_RUN / REAL
state VARCHAR(16) NOT NULL, -- IDLE / OPENING / HOLDING / CLOSING / HALTED
base_capital DECIMAL(18,2),
btc_alloc DECIMAL(18,2),
eth_alloc DECIMAL(18,2),
started_at TIMESTAMPTZ,
closed_at TIMESTAMPTZ
);
-- 订单
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id) NOT NULL,
strategy_id INT REFERENCES strategy_instances(id),
venue VARCHAR(8) NOT NULL, -- SPOT / FUTURES
symbol VARCHAR(16) NOT NULL,
side VARCHAR(8) NOT NULL,
type VARCHAR(16) NOT NULL,
client_order_id VARCHAR(64),
exchange_order_id VARCHAR(64),
price DECIMAL(18,8),
orig_qty DECIMAL(18,8),
executed_qty DECIMAL(18,8),
status VARCHAR(16),
is_reduce_only BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- 仓位快照
CREATE TABLE positions_snapshot (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id) NOT NULL,
strategy_id INT REFERENCES strategy_instances(id),
symbol VARCHAR(16) NOT NULL,
spot_qty DECIMAL(18,8),
futures_qty DECIMAL(18,8),
spot_notional DECIMAL(18,2),
futures_notional DECIMAL(18,2),
hedge_deviation DECIMAL(8,4),
mark_price DECIMAL(18,2),
captured_at TIMESTAMPTZ DEFAULT NOW()
);
-- 资金费率收入
CREATE TABLE funding_income (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id) NOT NULL,
symbol VARCHAR(16) NOT NULL,
funding_time TIMESTAMPTZ NOT NULL,
income_asset VARCHAR(8),
income_amount DECIMAL(18,8),
rate DECIMAL(18,8),
position_notional DECIMAL(18,2),
source_tx_id VARCHAR(64)
);
-- 风控事件
CREATE TABLE risk_events (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id) NOT NULL,
strategy_id INT REFERENCES strategy_instances(id),
event_type VARCHAR(32) NOT NULL,
severity VARCHAR(16) NOT NULL,
metric_value DECIMAL(18,8),
threshold_value DECIMAL(18,8),
action_taken VARCHAR(64),
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 审计日志append-only不可删改
CREATE TABLE audit_logs (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id),
actor VARCHAR(64) NOT NULL,
action VARCHAR(64) NOT NULL,
target VARCHAR(128),
payload_json JSONB,
ip VARCHAR(64),
hash_chain VARCHAR(128), -- 预留哈希链字段
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 系统运行记录
CREATE TABLE system_runs (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id) NOT NULL,
mode VARCHAR(16) NOT NULL,
start_at TIMESTAMPTZ NOT NULL,
end_at TIMESTAMPTZ,
result VARCHAR(16),
notes TEXT
);
```
---
## 12. 三阶段产品路线图
| 阶段 | 目标 | 交付 | 预计周期 |
|------|------|------|---------|
| **Phase 1** | 范总$500跑通2个月 | 单用户验证版(含风控+审计+告警+报表) | 开发2-3周 + 实盘2月 |
| **Phase 2** | 开放小规模内测 | 注册体系+RBAC+订阅计费+运维扩容 | 2-4周 |
| **Phase 3** | 公开获客规模化 | 营销站点+渠道投放+客户成功+合规增强 | 持续 |
---
## 13. Phase 2 新增模块预览
### 账户体系
- 注册、邮箱验证、找回密码、设备管理、2FA强制策略
### RBAC权限
- Owner / Admin / Operator / Viewer 角色与资源权限矩阵
### 订阅计费
- 套餐分层、试用期、续费、到期降级、账单与发票记录
### 多租户运维
- 队列隔离、限流配额、租户级熔断、租户级监控面板
### 客服与支持
- 工单、系统公告、状态页
---
## 14. 商业模式设计
### 方案ASaaS订阅制推荐先做
- 参考定价:$29 / $59 / $99 月费分层(按功能与账户规模)
- 优势:合规压力相对低、收入稳定、扩展快
- 用户资金在自己Binance账户平台不碰钱
### 方案B收益分成制后评估
- 管理费1-2%/年)+ 超额收益分成20%
- 风险:合规、牌照、托管责任显著上升
### 建议路径
- 先SaaS再评估收益分成
- 不在Phase 1/2落地资管模式
---
## 15. 技术选型(锁定)
| 层 | 技术 | 说明 |
|-----|------|------|
| 执行层 | 自建引擎 | 不fork Hummingbot |
| API层 | Binance官方SDK | `@binance/connector` |
| 后端 | Node.js + Express | 和灵镜统一 |
| 前端 | Next.js | 深色UI |
| 数据库 | PostgreSQL | 多租户友好 |
| 进程管理 | PM2 | 分api/worker |
| 部署 | 34.84.9.167 (GCP东京) | Binance延迟11ms |
---
## 15.1 部署与网络安全方案v1.3.1 新增)
### 部署服务器
- **机器**34.84.9.167GCP asia-northeast1-b
- **选择理由**
- Binance API TCP延迟 11ms露露服务器22ms的一半
- 内存可用 6.4GB、磁盘可用 187GB
- 仅1个PM2服务运行负载极低
- GCP基础设施安全性高于普通VPS
- 非root用户运行fzq1228
### 网络访问方案(域名 + HTTPS + 强认证)
- **访问方式**:子域名 + Caddy自动HTTPS
- **不使用IP白名单**范总IP不固定
- **安全靠认证层保障**
| 安全层 | 措施 |
|--------|------|
| 传输层 | HTTPSCaddy自动TLS证书 |
| 认证层 | 账号密码 + TOTP双因素 |
| 会话层 | 30分钟超时自动登出 |
| 防暴破 | 连续5次登录失败锁定15分钟 |
| 审计层 | 所有登录/操作记录append-only |
| 数据库 | PostgreSQL仅监听localhost |
| 进程隔离 | 单独系统用户运行套利引擎 |
### GCP防火墙规则需配置
- 开放端口仅HTTPS443
- 其他端口:全部关闭
- SSH仅密钥登录
### Phase 2 安全升级路径
- 可选Cloudflare Zero TrustTunnel + Access认证
- 可选WAF规则防SQL注入/XSS
- 可选Cloudflare Access策略邮箱OTP二次验证
---
## 16. Phase 0 验收Checklist
### 认证与权限
- [ ] API签名请求成功
- [ ] 读取余额成功
- [ ] 读取持仓成功
- [ ] 验证无提现权限
- [ ] PM状态可读
### 行情与费率
- [ ] Spot/Perp bookTicker可读
- [ ] premiumIndex可读
- [ ] fundingRate历史可拉取BTC/ETH
- [ ] 深度数据可拉取
### 交易闭环
- [ ] Spot下单/回查/撤单成功
- [ ] Futures下单/回查/撤单成功
- [ ] WebSocket成交推送正常
- [ ] 单腿失败补偿流程演练通过
- [ ] 熔断触发与恢复流程通过
### 风控机制
- [ ] 滑点超阈拦截通过
- [ ] 对冲偏差超阈拦截通过
- [ ] API连续失败熔断通过
- [ ] 重启后自动对账通过
- [ ] uniMMR预警/危险触发通过
### 报表与告警
- [ ] 8小时收益计算正确
- [ ] 每日汇报自动推送成功
- [ ] 异常告警实时送达成功
**验收标准**Checklist 100%通过才允许进入 1周 Dry Run。
---
## 17. 风险与原则
- **原则**:真金白银系统,宁慢勿错
- **风险优先级**:执行错误 > 权限泄露 > 可用性 > 收益偏差
- **处置原则**:触发风险先停机后恢复,先对账后继续
---
## 18. 下一步(文档后动作)
1. 独立服务器准备完成
2. API Key权限配置完成Read+Trade禁Withdraw白名单
3. Phase 0执行计划与验收人确认
4. 开始开发

View File

@ -0,0 +1,203 @@
# 策略广场 数据合约文档
> 版本v1.0
> 日期2026-03-07
> 状态:待范总确认 ✅
> 作者:露露 + 小范
> 分支:`feature/strategy-plaza`
---
## 1. 功能概述
**策略广场**Strategy Plaza将现有的 signals-v53 / paper-v53 / signals-v53fast 等分散页面整合为统一入口:
- 总览页策略卡片列表展示每个策略的核心指标30 秒自动刷新
- 详情页:点击卡片进入,顶部 Tab 切换「信号引擎」和「模拟盘」视图
---
## 2. 前端路由
| 路由 | 说明 |
|------|------|
| `/strategy-plaza` | 策略广场总览(卡片列表) |
| `/strategy-plaza/[id]` | 策略详情页默认「信号引擎」tab |
| `/strategy-plaza/[id]?tab=paper` | 策略详情页「模拟盘」tab |
**侧边栏变更:**
- 新增「策略广场」单一入口
- 原 `signals-v53` / `paper-v53` / `signals-v53fast` / `paper-v53fast` / `signals-v53middle` / `paper-v53middle` 页面:**保留但从侧边栏隐藏**(路由仍可访问)
---
## 3. API 设计
### 3.1 `GET /api/strategy-plaza`
返回所有策略的卡片摘要数据。
**Response:**
```json
{
"strategies": [
{
"id": "v53",
"display_name": "V5.3 标准版",
"status": "running",
"started_at": 1741234567000,
"initial_balance": 10000,
"current_balance": 8693,
"net_usdt": -1307,
"net_r": -6.535,
"trade_count": 63,
"win_rate": 49.2,
"avg_win_r": 0.533,
"avg_loss_r": -0.721,
"open_positions": 0,
"pnl_usdt_24h": -320,
"pnl_r_24h": -1.6,
"std_r": 0.9,
"last_trade_at": 1741367890000
}
]
}
```
**字段说明:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | string | 策略唯一标识,与 DB strategy 字段一致 |
| `display_name` | string | 展示名称 |
| `status` | string | `running` / `paused` / `error` |
| `started_at` | number (ms) | 策略启动时间(暂用 paper_trades 第一条 entry_ts后续补 strategy_meta 表) |
| `initial_balance` | number | 初始余额 USDT固定 10000 |
| `current_balance` | number | 当前余额 = initial_balance + net_usdt |
| `net_usdt` | number | 累计盈亏 USDT = SUM(pnl_r) × 200 |
| `net_r` | number | 累计净 R |
| `trade_count` | number | 已出场交易数 |
| `win_rate` | number | 胜率 % |
| `avg_win_r` | number | 平均赢单 R |
| `avg_loss_r` | number | 平均亏单 R负数 |
| `open_positions` | number | 当前活跃持仓数exit_ts IS NULL |
| `pnl_usdt_24h` | number | 最近 24h 盈亏 USDT |
| `pnl_r_24h` | number | 最近 24h 净 R |
| `std_r` | number | 所有已出场交易的 pnl_r 标准差(风险感知) |
| `last_trade_at` | number (ms) | 最近一笔成交的 exit_ts |
**status 判断逻辑:**
- `running`paper_config 中 enabled=true 且最近 signal_indicators 记录 < 5 分钟
- `paused`paper_config 中 enabled=false
- `error`paper_config 中 enabled=true 但 signal_indicators 最新记录 > 5 分钟
---
### 3.2 `GET /api/strategy-plaza/[id]/summary`
返回单个策略的完整摘要,包含卡片字段 + 详情字段。
**Response在 3.1 基础上增加):**
```json
{
"id": "v53",
"display_name": "V5.3 标准版",
"cvd_windows": "30m / 4h",
"description": "标准版30分钟+4小时CVD双轨适配1小时信号周期",
"symbols": ["BTCUSDT", "ETHUSDT", "SOLUSDT", "XRPUSDT"],
"weights": {
"direction": 55,
"crowding": 25,
"environment": 15,
"auxiliary": 5
},
"thresholds": {
"signal_threshold": 75,
"flip_threshold": 85
},
"...所有 3.1 字段..."
}
```
---
### 3.3 `GET /api/strategy-plaza/[id]/signals`
复用现有 `/api/signals` 逻辑,增加 `strategy` 过滤。接口参数和返回格式与现有保持一致。
---
### 3.4 `GET /api/strategy-plaza/[id]/trades`
复用现有 `/api/paper-trades` 逻辑,增加 `strategy` 过滤。接口参数和返回格式与现有保持一致。
---
## 4. 数据来源映射
| 字段 | 数据来源 |
|------|---------|
| `net_usdt`, `net_r`, `trade_count`, `win_rate`, `avg_win_r`, `avg_loss_r` | `paper_trades` WHERE strategy=id AND exit_ts IS NOT NULL |
| `open_positions` | `paper_trades` WHERE strategy=id AND exit_ts IS NULL |
| `pnl_usdt_24h`, `pnl_r_24h` | `paper_trades` WHERE strategy=id AND exit_ts > NOW()-24h |
| `std_r` | STDDEV(pnl_r) FROM paper_trades WHERE strategy=id AND exit_ts IS NOT NULL |
| `started_at` | MIN(entry_ts) FROM paper_trades WHERE strategy=id |
| `last_trade_at` | MAX(exit_ts) FROM paper_trades WHERE strategy=id AND exit_ts IS NOT NULL |
| `status` | paper_config.json + signal_indicators 最新记录时间 |
| `cvd_windows`, `weights`, `thresholds` | backend/strategies/[id].json |
---
## 5. 前端组件规划
### 5.1 总览页组件
```
StrategyPlaza
└── StrategyCardGrid
└── StrategyCard (×N)
├── 策略名 + status badge (running/paused/error)
├── 运行时长 (now - started_at)
├── 当前余额 / 初始余额
├── 净盈亏 USDT + 净R带颜色
├── 胜率
├── 最近24h盈亏小字
└── 点击 → /strategy-plaza/[id]
```
### 5.2 详情页组件
```
StrategyDetail
├── 顶部:策略名 + status + 运行时长
├── Tab 切换:[信号引擎] [模拟盘]
├── Tab: 信号引擎
│ └── 复用 SignalsV53Page 内容
└── Tab: 模拟盘
└── 复用 PaperV53Page 内容
```
---
## 6. 实现计划
| 阶段 | 内容 | 负责 |
|------|------|------|
| P0 | 后端 API `/api/strategy-plaza` | 露露 |
| P1 | 后端 API `/api/strategy-plaza/[id]/summary` | 露露 |
| P2 | 前端总览页StrategyCard × 3 | 露露 |
| P3 | 前端详情页Tab + 复用现有组件) | 露露 |
| P4 | 侧边栏整合(新增入口,隐藏旧页面) | 露露 |
| Review | 代码审阅 + 逻辑验证 | 小范 |
> 开发前等范总确认数据结构,不提前动代码。
---
## 7. 变更记录
| 版本 | 日期 | 内容 |
|------|------|------|
| v1.0 | 2026-03-07 | 初版,露露起草 + 小范审阅 |

View File

@ -0,0 +1,417 @@
---
title: Arbitrage Engine V2-V4 产品+技术文档
description: 权限管控、aggTrades全量采集、成交流分析面板的完整设计
---
# Arbitrage Engine V2-V4 产品+技术文档
## 1. 项目概述
### 1.1 当前状态V1.0 ✅)
- 实时BTC/ETH资金费率监控2秒刷新
- K线图本地2秒粒度聚合9个周期
- 历史费率走势 + 明细表
- YTD年化统计
- 信号推送Discord
- 用户注册/登录框架(无鉴权保护)
- URL: https://arb.zhouyangclaw.com
### 1.2 战略升级方向
从「公开费率监控面板」升级为「私有交易数据研究平台」。
核心目标:
1. 收集全量逐笔成交数据,建立独家数据资产
2. 结合K线与成交流研究价格变动的微观成因
3. 通过复盘标注→模式识别→模拟盘验证,逐步量化短线策略
4. 数据不公开,邀请制访问
### 1.3 核心定位
> 炒币是情绪盘每一段价格背后都代表着集体情绪走向。K线是情绪的结果成交流是情绪的过程。我们要看到过程。
---
## 2. V2.0 — 权限管控 + 邀请制
### 2.1 JWT鉴权设计
**Token体系**
| Token | 有效期 | 用途 |
|-------|--------|------|
| Access Token | 24小时 | API请求鉴权放header |
| Refresh Token | 7天 | 刷新access token |
**实现策略:** 在现有`auth.py`基础上增量改造,不重写。
**新增接口:**
- `POST /api/auth/login` → 返回 `{access_token, refresh_token, expires_in}`
- `POST /api/auth/refresh` → 用refresh token换新access token
- `POST /api/auth/register` → 注册(必须提供有效邀请码)
- `GET /api/auth/me` → 当前用户信息
**依赖库:** `python-jose[cryptography]` 或 `PyJWT`
### 2.2 邀请码机制
**表结构:**
```sql
CREATE TABLE invite_codes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL, -- 8位随机码
created_by INTEGER, -- admin user_id
max_uses INTEGER DEFAULT 1, -- 默认一码一用
used_count INTEGER DEFAULT 0,
status TEXT DEFAULT 'active', -- active/disabled/exhausted
expires_at TEXT, -- 可选过期时间
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE invite_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
used_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (code_id) REFERENCES invite_codes(id),
FOREIGN KEY (user_id) REFERENCES users(id)
);
```
**注册流程:**
1. 用户填写邀请码 + 用户名 + 密码
2. 后端验证:邀请码存在 + status=active + used_count < max_uses + 未过期
3. 创建用户 → 记录使用 → used_count+1
4. 返回JWT token对
### 2.3 路由保护
**公开路由(无需登录):**
- `GET /api/health`
- `POST /api/auth/login`
- `POST /api/auth/register`
- `POST /api/auth/refresh`
- `GET /api/rates`(基础费率,作为引流)
**受保护路由(需登录):**
- `GET /api/kline`
- `GET /api/snapshots`
- `GET /api/history`
- `GET /api/stats`
- `GET /api/stats/ytd`
- `GET /api/signals/history`
- `GET /api/trades/*`V3新增
- `GET /api/analysis/*`V4新增
**Admin路由需admin角色**
- `POST /api/admin/invite-codes` — 生成邀请码
- `GET /api/admin/invite-codes` — 查看所有邀请码
- `DELETE /api/admin/invite-codes/:id` — 禁用邀请码
- `GET /api/admin/users` — 查看所有用户
- `PUT /api/admin/users/:id/ban` — 封禁用户
### 2.4 权限模型
| 资源 | Guest | User | Admin |
|------|-------|------|-------|
| 基础费率 | ✅ | ✅ | ✅ |
| K线/历史/统计 | ❌ | ✅ | ✅ |
| 成交流数据 | ❌ | ✅ | ✅ |
| 分析面板 | ❌ | ✅ | ✅ |
| 标注 | ❌ | ✅(自己) | ✅(全部) |
| 邀请码管理 | ❌ | ❌ | ✅ |
| 用户管理 | ❌ | ❌ | ✅ |
### 2.5 Admin CLI工具
```bash
# 生成邀请码
python3 admin_cli.py gen-invite --count 5
# 查看邀请码状态
python3 admin_cli.py list-invites
# 禁用邀请码
python3 admin_cli.py disable-invite CODE123
# 查看用户
python3 admin_cli.py list-users
# 封禁用户
python3 admin_cli.py ban-user 42
```
### 2.6 前端改造
- 未登录用户仪表盘只显示实时费率卡片引流其他区域显示blur遮挡 + 「登录后查看」
- 登录页:用户名 + 密码 + 邀请码(注册时)
- Token存储`localStorage`access_token + refresh_token
- 请求拦截器自动带token、过期自动刷新
- 401处理跳转登录页
### 2.7 验收标准
- [ ] 无邀请码无法注册
- [ ] 无token访问受保护API返回401
- [ ] Token过期后refresh成功
- [ ] Admin API非admin用户返回403
- [ ] 前端未登录正确遮挡数据区域
---
## 3. V3.0 — aggTrades全量采集
### 3.1 数据模型
**按月分表单库arb.db**
```sql
-- 自动建表每月1张
CREATE TABLE IF NOT EXISTS agg_trades_202602 (
agg_id INTEGER PRIMARY KEY, -- Binance aggTradeId天然唯一
symbol TEXT NOT NULL, -- BTCUSDT / ETHUSDT
price REAL NOT NULL,
qty REAL NOT NULL,
first_trade_id INTEGER,
last_trade_id INTEGER,
time_ms INTEGER NOT NULL, -- 成交时间(毫秒)
is_buyer_maker INTEGER NOT NULL -- 0=主动买, 1=主动卖
);
CREATE INDEX IF NOT EXISTS idx_agg_trades_202602_time
ON agg_trades_202602(symbol, time_ms);
```
**辅助表:**
```sql
CREATE TABLE agg_trades_meta (
symbol TEXT PRIMARY KEY,
last_agg_id INTEGER NOT NULL, -- 最后写入的agg_id
last_time_ms INTEGER NOT NULL, -- 最后写入的时间
updated_at TEXT DEFAULT (datetime('now'))
);
```
### 3.2 采集架构
```
┌─────────────────────┐
│ WebSocket主链路 │ ← wss://fstream.binance.com/ws/btcusdt@aggTrade
│ (实时推送) │ ← wss://fstream.binance.com/ws/ethusdt@aggTrade
└──────┬──────────────┘
│ 攒200条 or 1秒
┌─────────────────────┐
│ 批量写入器 │ → INSERT OR IGNORE INTO agg_trades_YYYYMM
│ (去重+分表路由) │ → UPDATE agg_trades_meta
└──────┬──────────────┘
┌──────┴──────────────┐
│ 补洞巡检(每分钟) │ → 检查agg_id连续性
│ │ → REST /fapi/v1/aggTrades?fromId=X 补缺
└─────────────────────┘
```
**断线重连流程:**
1. WS断线 → 立即重连
2. 读取`last_agg_id` → REST `fromId=last_agg_id+1` 批量拉取每次1000条
3. 追平后切回WS流
4. 全程记日志
### 3.3 写入优化
- 批量大小200条 or 1秒取先到者
- SQLite WAL模式 + `PRAGMA synchronous=NORMAL`
- 单线程写入,避免锁竞争
- 月初自动建新表
### 3.4 去重策略
- `agg_id`作为PRIMARY KEY`INSERT OR IGNORE`天然去重
- WS和REST可能有重叠完全靠PK去重零额外成本
### 3.5 监控与告警
| 指标 | 阈值 | 动作 |
|------|------|------|
| 采集中断 | >30秒无新数据 | Discord告警 |
| agg_id断档 | 缺口>10 | 自动REST补洞 |
| 写入延迟P95 | >500ms | 日志警告 |
| 磁盘占用 | >80% | Discord告警 |
| 日数据完整性 | 缺口率>0.1% | 日报标红 |
### 3.6 存储容量规划
| 时间跨度 | BTC | ETH | 合计 |
|---------|-----|-----|------|
| 1天 | ~200MB | ~150MB | ~350MB |
| 1个月 | ~6GB | ~4.5GB | ~10.5GB |
| 6个月 | ~36GB | ~27GB | ~63GB |
| 1年 | ~73GB | ~55GB | ~128GB |
200GB磁盘可存1年+。超出时按月归档到外部存储。
### 3.7 数据查询接口(需登录)
- `GET /api/trades/raw?symbol=BTC&start_ms=X&end_ms=Y&limit=10000` — 原始成交
- `GET /api/trades/summary?symbol=BTC&start_ms=X&end_ms=Y&interval=1m` — 分钟级聚合
- 返回:`{time, buy_vol, sell_vol, delta, trade_count, vwap, max_single_qty}`
### 3.8 验收标准
- [ ] BTC/ETH双流并行采集PM2常驻
- [ ] agg_id连续性>99.9%
- [ ] 断线重连+补洞在60秒内完成
- [ ] 每日完整性报告自动生成
- [ ] 查询API响应时间<2秒10万条范围内
---
## 4. V4.0 — 成交流分析面板
### 4.1 页面布局
```
┌────────────────────────────────────────────┐
│ [BTC▼] [时间范围选择] [1m 5m 15m] │ ← 全局控制栏
├────────────────────────────────────────────┤
│ │
│ K线图 (lightweight-charts) │ ← 可框选时间段
│ │
├────────────────────────────────────────────┤
│ │
│ 成交流热图 (ECharts heatmap) │ ← 时间×价格×密度
│ 绿=主动买 红=主动卖 │
│ │
├──────────────────────┬─────────────────────┤
│ 时段摘要 │ 标注面板 │
│ 总成交量: 1,234 BTC │ [上涨前兆] [下跌前兆] │
│ 买/卖比: 62%/38% │ [震荡] [放量突破] │
│ Delta: +427 BTC │ │
│ 最大单笔: 12.5 BTC │ [保存标注] │
│ 成交速率: 89笔/秒 │ [AI分析] [导出] │
└──────────────────────┴─────────────────────┘
```
### 4.2 共享时间轴
使用`zustand`或React Context管理全局时间范围
```typescript
interface TimeRange {
startMs: number;
endMs: number;
source: 'kline' | 'heatmap' | 'control' | 'manual';
}
```
K线框选、热图缩放、控制栏切换都更新同一个state所有组件响应式联动。
### 4.3 热图实现
- 库ECharts heatmap首版
- 数据格式:`[timeMs, priceLevel, volume]`
- 价格分档按0.1%粒度BTC约$67 = 1档
- 颜色映射:买量→绿色深度,卖量→红色深度
- 数据量控制15分钟窗口 ≈ 几千个格子ECharts轻松渲染
### 4.4 复盘工具交互
**核心流程:**
1. 选择日期和币种
2. 浏览K线找到明显波动区间
3. 框选 → 下方热图+摘要自动聚焦
4. 观察波动前5-15分钟的成交流特征
5. 发现有意义的模式 → 标注保存
6. 可选点「AI分析」让AI解读该时段特征
### 4.5 标注系统
```sql
CREATE TABLE annotations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
symbol TEXT NOT NULL,
start_ms INTEGER NOT NULL,
end_ms INTEGER NOT NULL,
label TEXT NOT NULL, -- 上涨前兆/下跌前兆/震荡/突破/假突破/洗盘...
note TEXT, -- 用户备注
confidence INTEGER DEFAULT 3, -- 1-5 置信度
version INTEGER DEFAULT 1, -- 版本号(修改时递增)
features_json TEXT, -- 当时的特征快照delta/速率/大单等)
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT,
FOREIGN KEY (user_id) REFERENCES users(id)
);
```
### 4.6 AI辅助分析
**流程:**
```
原始成交数据(该时段)
↓ Python预处理后端
结构化摘要(~500字
- 成交速率变化曲线(文字描述)
- Delta累计走势
- 大单列表(>平均5倍
- 价格关键点(高低点+突破位)
↓ AI分析
结论+建议(~200字
```
**Token消耗** ~3000 token/次分析,成本忽略不计。
### 4.7 验收标准
- [ ] K线与热图时间同步无偏移
- [ ] 热图渲染15分钟窗口<1秒
- [ ] 标注CRUD正常支持版本回溯
- [ ] AI分析响应<10秒
- [ ] 手机端可用(响应式布局)
---
## 5. 开发排期
| 版本 | 内容 | 预估工期 | 依赖 |
|------|------|---------|------|
| V2.0 | 权限管控+邀请制 | 2天 | 无 |
| V3.0 | aggTrades全量采集 | 2天 | V2.0(受保护路由) |
| V4.0 | 成交流分析面板 | 3-5天 | V3.0(数据源) |
**里程碑:**
- V2.0完成:所有数据需登录访问,邀请码可用
- V3.0完成:数据开始积累,每日完整性报告
- V3.0+2周积累足够数据可以开始第一次复盘分析
- V4.0完成:完整的复盘研究工具
---
## 6. 安全与隐私
1. **成交流数据不公开** — 所有`/api/trades/*`和`/api/analysis/*`必须登录
2. **邀请码范总一人控制** — Admin角色仅范总
3. **JWT密钥** — 32字节随机存环境变量
4. **SQLite安全** — WAL模式每日备份
5. **数据永久保留** — 不删不改,原始完整性最重要
---
## 7. 回滚与故障预案
| 故障 | 预案 |
|------|------|
| V2上线后鉴权阻断 | git回退到V1 commit + PM2 restart |
| V3采集进程崩溃 | PM2自动重启 + REST补洞 |
| 磁盘满 | 按月归档旧数据到外部存储 |
| SQLite损坏 | 每日备份恢复 |
| 前端build失败 | 保持上一版本运行不restart |
---
## 8. 数据库迁移规范
- 每次DDL变更写migration脚本带版本号
- 命名:`migration_001_add_invite_codes.sql`
- 所有migration必须幂等`IF NOT EXISTS`
- 发布顺序先跑migration → 再更新代码 → 再restart服务
---
*文档版本: v1.0*
*最后更新: 2026-02-27*
*作者: 露露(Opus 4.6) × 小周(GPT-5.3-Codex)*

View File

@ -0,0 +1,239 @@
---
title: V5 短线交易信号系统方案
---
# V5 短线交易信号系统方案
> 版本v5.0 | 日期2026-02-27 | 状态:方案定稿,待开发
>
> 来源露露Opus 4.6× 小周GPT-5.3-Codex10轮讨论
---
## 1. 目标
将 aggTrades 成交流数据转化为**可执行的短线做多/做空交易信号**,实现从"监控工具"到"交易工具"的升级。
## 2. 信号体系
### 2.1 核心门槛3/3 必须全部满足)
| # | 条件 | 做多 | 做空 |
|---|------|------|------|
| 1 | CVD_fast (30m 滚动) | > 0 且斜率正 | < 0 且斜率负 |
| 2 | CVD_mid (4h 滚动) | > 0 | < 0 |
| 3 | VWAP 位置 | price > VWAP_30m | price < VWAP_30m |
**说明**CVD = Cumulative Volume Delta累计买卖差额是成交流分析最核心的指标。
- **CVD_fast**30分钟滚动窗口捕捉短线动量
- **CVD_mid**4小时滚动窗口确认大方向
- **CVD_day**UTC日内重置作为盘中强弱基线参考不作为入场条件
> ⚠️ CVD_fast 在剧烈波动时自适应当1m成交量超过均值3倍时窗口自动拉长到60m防噪音误判。
### 2.2 加分条件决定仓位大小满分60分
| 条件 | 分值 | 说明 |
|------|------|------|
| ATR 压缩→扩张 | +25 | 5m ATR分位从 <40% 突破 >60%,波动开始放大 |
| 无反向 P99 超大单 | +20 | 最近15分钟内无极端反向成交 |
| 资金费率配合 | +15 | 做多时费率<0.01%,做空时费率>0.01% |
### 2.3 仓位映射
| 加分总分 | 仓位等级 | 占总资金 |
|----------|----------|----------|
| 0-15 | 最小仓 | 2% |
| 20-40 | 中仓 | 5% |
| 45-60 | 满仓 | 8% |
### 2.4 预估信号频率
核心3条件同时满足概率约 20-30%每5分钟检查一次。去掉冷却期重复后预计**日均 5-15 个有效信号**。
## 3. 指标计算
### 3.1 CVDCumulative Volume Delta
```
CVD = Σ(主动买量) - Σ(主动卖量)
三轨并行:
- CVD_fast滚动30m窗口入场信号
- CVD_mid滚动4h窗口方向过滤
- CVD_dayUTC日内重置盘中基线
```
入场优先看 fast方向必须与 mid 同向。
### 3.2 大单阈值(动态分位数)
```
基于最近24h成交量分布
- P99超大单阈值
- P95大单阈值
- 兜底下限max(P95, 5 BTC)
区分"大单买"和"大单卖"的不对称性:
- 上涨趋势中出现P99大卖单意义远大于P99大买单
```
### 3.3 ATRAverage True Range
```
周期5分钟K线14根
用于:
1. 波动压缩→扩张判断(分位数)
2. 止损距离计算
```
### 3.4 VWAPVolume Weighted Average Price
```
滚动30分钟VWAP_30m = Σ(price × qty) / Σ(qty)
用于:价格位置过滤(做多 price > VWAP做空 price < VWAP
```
## 4. 风控体系
### 4.1 止盈止损
| 参数 | 值 | 说明 |
|------|-----|------|
| 止损 (SL) | 1.2 × ATR(5m, 14) | 动态止损,适应波动 |
| 止盈1 (TP1) | 1.0R | 减仓50% |
| 止盈2 (TP2) | 2.0R | 剩余仓位移动止损 |
| 时间止损 | 30分钟 | 无延续即平仓,防磨损 |
> R = 1倍止损距离
### 4.2 冲突与冷却
- **信号冲突**:持仓中出现反向信号 → 先平仓 + 10分钟冷却再接受新信号
- **同方向冷却**10分钟内不重复入场
- **单币限频**每小时最多2次入场
### 4.3 多品种风控
- BTC / ETH 独立出信号
- 两个同时同向时,总仓位上限 10%
- 需要相关性过滤ETH Delta 经常跟 BTC 走)
## 5. 回测达标线
| 指标 | 达标线 |
|------|--------|
| 胜率 | ≥ 45% |
| 盈亏比 (Avg Win / Avg Loss) | ≥ 1.5 |
| 最大回撤 (MDD) | ≤ 5% |
| 日均信号数 | 2-8 个 |
| 扣手续费后 | 正收益 |
**手续费模型**
- Maker: 0.02%, Taker: 0.04%Portfolio Margin 档位)
- 按 Taker 0.04% 双向估算(保守)
- 回测报告必须包含净收益/毛收益对比
**额外统计**
- 持仓时长分布验证30min时间止损合理性
- Rate-limit 重试统计(回补脚本用)
## 6. 技术架构
### 6.1 进程拓扑
```
┌─────────────────┐
Binance WS ──────→ │ agg-collector │ ──→ agg_trades_YYYYMM
└─────────────────┘
┌─────────────────┐
│ signal-engine │ ──→ signal_indicators (5s)
│ │ ──→ signal_indicators_1m (聚合)
│ │ ──→ signal_trades
│ │ ──→ Discord推送
└─────────────────┘
┌─────────────────┐
│ backtest.py │ ──→ 回测报告
└─────────────────┘
```
- **agg-collector**:只做采集+落库,不动(已有)
- **signal-engine**:新建独立进程,指标计算 + 信号生成
- **backtest.py**:离线回测脚本
### 6.2 数据库新增表
| 表名 | 用途 | 保留策略 |
|------|------|----------|
| signal_indicators | 每5秒指标快照CVD/ATR/VWAP/P95等 | 30天 |
| signal_indicators_1m | 1分钟聚合前端默认读此表 | 长期 |
| signal_trades | 信号触发的开仓/平仓记录 | 长期 |
### 6.3 指标计算策略
- **内存滚动 + 增量更新**不是每次SQL全量聚合
- 启动时回灌历史窗口30m/4h/24h到内存
- 之后只处理新增 agg_id 增量
- 每5秒把快照落库幂等
### 6.4 冷启动处理
signal-engine 重启后:
1. 从DB回读最近4h的aggTrades重算所有指标
2. 前N根标记为 `warmup`,不出信号
3. warmup 完成后开始正常信号生成
## 7. 历史数据回补
### 7.1 回补脚本backfill_agg_trades.py
```
参数:--symbol BTCUSDT --days 7 --batch-size 1000
流程:
1. 查DB中最早的agg_id
2. 从最早agg_id向前REST分页补拉
3. 每次1000条sleep 200ms防限流
4. INSERT OR IGNORE 写入agg_trades_YYYYMM
5. 断点续传记录进度到meta表
6. 完成后输出统计+连续性检查
```
### 7.2 速率控制
- Binance aggTrades REST 限流weight 20/min
- 每请求 sleep 200ms实际约 3-5 req/s
- 带指数退避重试429后等60s
- 记录 rate-limit 统计429次数、退避次数
## 8. 开发时间线
| Day | 任务 | 交付物 | 负责 |
|-----|------|--------|------|
| 1 | 回补脚本 + 1天小样本 | backfill跑通BTC/ETH各1天入库 | 露露开发,小周部署 |
| 2 | 全量7天回补 + 连续性验证 | 完整7天aggTrades缺口=0 | 小周跑+验收 |
| 3-4 | signal-engine + 前端指标展示 | CVD三轨/ATR/VWAP/大单标记实时可视化 | 露露开发,小周部署 |
| 5 | 回测框架 + 首版回测报告 | 胜率/盈亏比/MDD/持仓分布/净收益 | 露露开发 |
| 6+ | 调参优化 → 达标后模拟盘 | 模拟交易记录 | 协同 |
## 9. 前置依赖
| 依赖 | 状态 | 影响范围 |
|------|------|----------|
| aggTrades 实时采集 | ✅ 已运行 | Phase 1-3 已满足 |
| 历史数据回补 | ⏳ Day 1-2 | 回测需要 |
| Binance API Key | ⏳ 等范总 | 仅Phase 4实盘 |
| Portfolio Margin | ⏳ 等范总 | 仅Phase 4实盘 |
| 资金准备 | ⏳ 等范总 | 仅Phase 4实盘 |
> Phase 1-3 不依赖范总,可立即开工。
## 10. 版本历史
| 版本 | 日期 | 内容 |
|------|------|------|
| v2-v4 | 2026-02-27 | 权限管控+aggTrades采集+成交流面板 |
| **v5.0** | **2026-02-27** | **短线交易信号系统方案定稿** |

View File

@ -0,0 +1,146 @@
---
title: V5.1 优化方案
date: 2026-03-03
---
# V5.1 优化方案
> 基于V5.1模拟盘执行分析报告2026-03-03
> 核心目标在不重建信号系统的前提下将净R从-96.98R拉回正值
> 策略:**降低手续费暴露 + 提升单笔期望值**
---
## 优化原则
信号层毛R为+11.98R(微弱正收益),说明信号有效性存在但边际极薄。
改造优先级:**先降频降费 → 再提盈亏比 → 最后优化信号质量**。
---
## 方向一:提高入场门槛(降频)
### 当前问题
- 75分以上即可入场触发频率过高500笔/历史周期)
- 各分数段胜率差异不大85+仅比75-79高1.7%说明75-84大量交易性价比差
### 建议改动
| 参数 | 当前值 | 建议值 |
|------|--------|--------|
| 入场阈值 | 75 | **82** |
| 预期效果 | 500笔 | 约~200笔减少约60%交易频次) |
| 手续费节省 | - | ~65R108×60% |
> 根据数据82分以上样本约170笔需重新统计。需要验证胜率是否提升。
---
## 方向二:时段过滤(砍亏损时段)
### 当前问题
以下时段(北京时间)胜率<40%是系统性亏损区
| 时段 | 胜率 | 合计R |
|------|------|-------|
| 01:00 | 31.8% | -15.69R |
| 06:00 | 33.3% | -13.73R |
| 07:00 | 36.4% | -7.40R |
| 09:00 | 38.7% | -16.71R |
| 11:00 | 28.6% | -5.51R |
| 13:00 | 31.8% | -13.62R |
| 18:00 | 30.0% | -6.47R |
合计约7个亏损时段贡献约-79R亏损。
### 建议改动
禁止在以下北京时间开仓:**01:00, 06:00, 07:00, 09:00, 11:00, 13:00, 18:00**
→ 预计减少交易约~100笔直接节省约79R亏损
---
## 方向三暂停BTC交易
### 当前问题
| 币种 | 胜率 | 合计R |
|------|------|-------|
| BTCUSDT | 49.3% | -45.61R |
BTC胜率低于随机水平49.3%<50%是最大单一亏损来源贡献总亏损47%
### 建议改动
**暂停BTC交易**等积累足够新数据calc_version=2后再评估是否恢复。
→ 直接避免-45.61R历史口径减少约27%交易频次。
---
## 方向四拉大TP/SL比提盈亏比
### 当前问题
- sl_multiplier=1.4, tp1=1.05, tp2=2.1
- tp1_r=0.75, tp2_r=1.5
- 平均TP净收益=0.90R平均SL净亏损=-1.23R
- 盈亏比=0.73,手续费后需要胜率>58%才能打平
### 建议改动
| 参数 | 当前值 | 建议值 |
|------|--------|--------|
| sl_multiplier | 1.4 | 2.0(扩大止损空间,减少噪声止损) |
| tp1_multiplier | 1.05 | 1.5 |
| tp2_multiplier | 2.1 | 3.0 |
> 注意扩大止损会增大单笔手续费fee_r=2×0.0005×entry/rdrd变大则fee_r变小
> 同时能减少被噪声打止损的次数SL平均仅18分钟持仓
---
## 组合改动预期效果(粗估)
| 改动 | 预期节省R |
|------|----------|
| 提高入场门槛至82 | ~65R |
| 过滤7个亏损时段 | ~79R |
| 暂停BTC | ~46R |
| **合计** | **~190R** |
> 当前净亏损-96.98R三项改动合计节省190R理论上净R可到+93R乐观估计存在重叠
> 实际效果需要在模拟盘上验证后才能确认
---
## 实施计划
### Phase 1参数调整立即可做不改代码
1. 修改 `backend/strategies/v51_baseline.json`
- threshold: 75 → 82
- 添加 `forbidden_hours_bj: [1, 6, 7, 9, 11, 13, 18]`
- 添加 `disabled_symbols: ["BTCUSDT"]`
2. 修改 `backend/paper_config.json` 对应字段(如果有覆盖)
3. 重启 signal-engine
### Phase 2TP/SL调整需验证历史数据影响
1. 模拟不同sl_multiplier在历史数据上的表现
2. 确认新参数下预期胜率和盈亏比
3. 更新 `v51_baseline.json`
### Phase 3数据验证
1. 积累150-200笔新口径数据calc_version=2
2. 对比优化前后各项指标
3. 根据实际结果再次迭代
---
## 注意事项
1. **不要同时改太多参数**每次只改1-2个变量方便归因
2. **记录每次改动时间**:便于后续对比数据
3. **备份当前配置**`v51_baseline.json` 改前先备份
4. **V5.2同步评估**V5.2目前-15.94R比V5.1好但仍亏损,后续需同步分析
---
## 待讨论问题
- [ ] 入场门槛从75提到82合适吗是否要先看82-84分的历史胜率数据
- [ ] 时段过滤是全部禁止还是只禁BTC
- [ ] TP/SL比调整是否应该先做回测再上模拟盘
- [ ] 暂停BTC是否需要范总确认

View File

@ -0,0 +1,163 @@
---
title: V5.1 模拟盘执行分析报告
date: 2026-03-03
---
# V5.1 模拟盘执行分析报告
> 数据口径真实成交价agg_trades+ 手续费扣除calc_version=2
> 分析日期2026-03-03
> 参与分析露露Sonnet 4.6、小范GPT-5.3-Codex
---
## 一、总体概况
| 指标 | 数值 |
|------|------|
| 总交易笔数 | 503笔含3笔活跃 |
| 已闭合笔数 | 500笔 |
| 有效样本score≥75| 496笔 |
| 净R含手续费| **-96.98R** |
| 毛R不含手续费| **+11.98R** |
| 总手续费 | **108.97R** |
| 平均单笔手续费 | 0.218R |
| 胜率 | 55.4% |
| 平均每笔净R | -0.193R |
> 本金10,000 USD1R=200 USD → 净亏损约19,396 USD
---
## 二、出场类型分布
| 状态 | 笔数 | 平均R | 合计R |
|------|------|-----------|-------|
| sl止损| 189 | -1.232R | **-232.85R** |
| tp止盈| 132 | +0.904R | +119.36R |
| sl_be保本止损| 118 | +0.161R | +19.04R |
| timeout超时| 41 | +0.073R | +2.99R |
| signal_flip翻转| 20 | -0.276R | -5.51R |
**关键发现**SL次数189远超TP132SL吃掉232.85RTP只回收119.36R,实际盈亏比=0.77:1。
### SL均值拆解
| 组成 | 数值 |
|------|------|
| SL基础R | -1.000R(止损公式正确) |
| 手续费 | -0.232R |
| 净SL | **-1.232R** |
---
## 三、方向分析
| 方向 | 笔数 | 胜率 | 合计R |
|------|------|------|-------|
| LONG | 281 | 54.7% | -46.32R |
| SHORT | 222 | 56.3% | -50.67R |
**结论**:多空双向均亏,非方向性问题。
---
## 四、币种分析
| 币种 | 笔数 | 胜率 | 合计R |
|------|------|------|-------|
| BTCUSDT | 137 | **49.3%** | **-45.61R** |
| ETHUSDT | 119 | 54.2% | -19.37R |
| XRPUSDT | 129 | 62.0% | -16.05R |
| SOLUSDT | 118 | 56.4% | -15.95R |
**关键发现**BTC胜率仅49.3%低于随机是最大亏损来源亏损占总量47%。
---
## 五、信号分数段分析
| 分数段 | 笔数 | 胜率 | 合计R |
|--------|------|------|-------|
| 75-79 | 179 | 57.3% | -30.54R |
| 80-84 | 214 | 54.7% | -45.21R |
| 85+ | 103 | 55.3% | -15.91R |
**关键发现**高分85+)胜率与低分段基本持平,评分体系对预测质量的区分度不足。
> 另有6笔score=70-72早期历史数据入场门槛未设75时不计入有效样本。
---
## 六、时段分析(北京时间)
### 盈利时段合计R>0
| 时段 | R | 胜率 |
|------|---|------|
| 03:00 | +2.24R | 69.2% |
| 05:00 | +6.18R | 78.6% |
| 08:00 | +5.22R | 82.6% |
| 17:00 | +2.40R | 85.7% |
| 19:00 | +2.27R | 83.3% |
| 23:00 | +7.97R | 71.4% |
### 重度亏损时段(胜率<40%
| 时段 | R | 胜率 |
|------|---|------|
| 01:00 | -15.69R | 31.8% |
| 06:00 | -13.73R | 33.3% |
| 07:00 | -7.40R | 36.4% |
| 09:00 | -16.71R | 38.7% |
| 11:00 | -5.51R | 28.6% |
| 13:00 | -13.62R | 31.8% |
| 18:00 | -6.47R | 30.0% |
---
## 七、持仓时间分析
| 出场类型 | 平均持仓 |
|----------|---------|
| timeout | 60.0分钟 |
| sl_be | 23.8分钟 |
| tp | 20.5分钟 |
| sl | **18.1分钟** |
| flip | 20.2分钟 |
**发现**SL平均仅持仓18分钟即被打出说明入场时机存在问题短时噪声触发入场
---
## 八、风险统计
| 指标 | 数值 |
|------|------|
| 单笔最大亏损 | -1.47R |
| 单笔最大盈利 | +1.04R |
| 标准差 | 0.89R |
| 中位数 | +0.12R |
| P25 | -1.19R |
| P75 | +0.78R |
> 中位数为正(+0.12R)但均值为负(-0.19R),说明少数大亏拖累整体,分布右偏。
---
## 九、核心结论
### 最关键发现毛R为正费用致亏
- **毛R不含手续费+11.98R** → 信号层有微弱预测优势,未完全失效
- **总手续费108.97R** → 手续费将毛R从+12压到-97
- **结论V5.1不是"不会预测",而是"预测优势太薄,被执行成本碾碎"**
### 四大结构性问题
1. **盈亏比天然劣势**SL:TP=189:132每次输更多赢的次数更少
2. **BTC信号质量差**胜率49.3%低于随机应考虑暂停或单独优化BTC
3. **评分体系区分度不足**85+高分与75-79低分胜率差不多评分无效
4. **时段敏感**约6-7个时段胜率<40%是系统性亏损区间
---
## 十、优化方向(待讨论)
详见:[V5.1优化方案](./v51-optimization-plan.md)

View File

@ -0,0 +1,250 @@
---
title: V5.1 信号增强方案
description: V5.1 信号评分体系 + 仓位管理 + TP/SL + 风控 + 回测框架
---
# V5.1 信号增强方案
> 讨论参与:露露(Opus 4.6) + 小周(GPT-5.3-Codex) + 范总审核
> 定稿时间2026-02-28
## 1. 概述
V5.0 以 aggTrades 原始成交流为核心CVD三轨 + ATR + VWAP + P95/P99大单V5.1 在此基础上增加 4 个数据维度 + 完善交易管理系统。
**核心理念**aggTrades 是我们的独特优势(别人没有原始成交流),新增数据源作为方向确认和风控补充,不替代 aggTrades 的核心地位。
## 2. 信号评分体系100分制
### 2.1 权重分配
| 层级 | 数据源 | 权重 | 角色 |
|------|--------|------|------|
| **方向层** | aggTradesCVD三轨 + P95/P99大单 | **45%** | 核心方向判断 |
| **拥挤层** | L/S Ratio + Top Trader Position | **20%** | 市场拥挤度 |
| **环境层** | Open Interest 变化 | **15%** | 资金活跃度/可交易性门槛 |
| **确认层** | 多时间框架一致性 | **15%** | 方向确认 |
| **辅助层** | Coinbase Premium | **5%** | 机构资金流向 |
### 2.2 各层级详细计算
#### 方向层45分
- **CVD_fast30m滚动方向**:与信号方向一致 +15
- **CVD_mid4h滚动方向**:与信号方向一致 +15
- **P95/P99 大单**:无反向 P99 大单 +10有同向 P99 大单 +15
- **CVD_fast 斜率加速**:斜率 > 阈值 +5额外加分
#### 拥挤层20分
- **L/S Ratio**
- L/S > 2.0(做空信号)或 L/S < 0.5做多信号+10
- L/S 1.5-2.0 / 0.5-0.67+5
- 中性区间0
- **Top Trader Position Ratio**
- 大户方向与信号一致:+10
- 大户方向中性:+5
- 大户方向反向0
#### 环境层15分
- **OI 变化**(不判断方向,判断活跃度):
- OI 15分钟变化率 > 阈值(活跃):+15
- OI 变化温和:+10
- OI 萎缩(市场冷清):+5
#### 确认层15分
- **多时间框架确认规则**
- `1m` = 触发层(入场点)
- `5m/15m` = 方向确认层
- `1h` = 风险闸门
- **评分**
- 5m AND 15m 同向:+15
- 5m OR 15m 同向:+10
- 无同向确认:+0
- **1h 反向处理**:不在评分里扣分,而是在仓位管理里降仓(见 3.2
#### 辅助层5分
- **Coinbase Premium**
- Premium 方向与信号一致且 > 阈值:+5
- 中性:+2
- 反向0
### 2.3 开仓门槛
| 总分 | 操作 |
|------|------|
| < 60 | **不开仓** |
| 60 - 74 | 轻仓(基础仓位 × 0.5 |
| 75 - 84 | 标准仓位 |
| ≥ 85 | 允许加仓(基础仓位 × 1.3 |
## 3. 仓位管理
### 3.1 基础仓位
- **默认**:总资金的 10%
- **杠杆**3X可调
- **单笔最大风险**:总资金的 2%
### 3.2 1h 时间框架降仓规则
| 1h 状态 | 仓位调整 |
|---------|---------|
| 1h 同向 | 正常仓位 |
| 1h 弱反向 | 仓位 × 0.7 |
| 1h 强反向CVD + 趋势都反) | 仓位 × 0.5,且仅允许 ≥85 分信号 |
### 3.3 Funding Rate 偏置
- FR 不做触发因子,做"慢变量偏置"
- 计算 `FR z-score(7d)` + `FR 斜率(近3个结算点)`
- 映射为 `bias`-1 ~ +1叠加到总分
- FR 极端且与信号方向冲突时:仅降仓,不反向开仓
## 4. TP/SL 管理双ATR融合
### 4.1 ATR 计算
```
risk_atr = 0.7 × ATR_5m + 0.3 × ATR_1h
```
- ATR_5m5分钟K线14周期 → 管入场灵敏度
- ATR_1h1小时K线14周期 → 管极端波动保护
- 好处分钟级不钝化靠5m又不被短时噪音洗掉靠1h兜底
### 4.2 止盈止损参数
| 参数 | 计算 | 说明 |
|------|------|------|
| **SL** | Entry ± 2.0 × risk_atr | 初始止损 |
| **TP1** | Entry ∓ 1.5 × risk_atr | 第一目标 |
| **TP2** | Entry ∓ 3.0 × risk_atr | 第二目标 |
### 4.3 分批平仓逻辑
1. **TP1 触发**:平 50% 仓位SL 移至成本价 + 手续费Breakeven
2. **TP2 触发**:平剩余 50%,信号标记 "tp"
3. **SL 触发TP1 已达)**:标记 "sl_be"(保本止损,实际盈亏 ≈ +0.5R
4. **SL 触发TP1 未达)**:标记 "sl"(完整止损,亏损 -1.0R
### 4.4 期望值计算
假设 60% 胜率TP1 命中率):
- 60% × 2.0R = +1.2R
- 40% × -1.0R = -0.4R
- **期望值 = +0.8R/笔**
## 5. 风控系统
### 5.1 自适应冷却期
| 条件 | 冷却时间 |
|------|---------|
| 基础(同向信号开仓后) | 10 分钟 |
| 近 30min 同向连续 2 笔止损 | 升到 20 分钟 |
| 上一笔同向达到 TP1 | 缩短到 5 分钟 |
| 第 4 个同向信号 | 默认不开除非上一笔已TP1 + 当前≥85分 + 1h不强反向 |
- **反向信号**不受同向冷却限制,但需过最小反转阈值(防止来回翻单)
### 5.2 清算瀑布检测
- **主通道(实时)**aggTrades 异常成交密度 + 价格加速度 + 点差扩张 → 推断清算瀑布
- **辅通道(校验)**Binance `forceOrders` API → 事后校验和阈值再训练
- 交易决策吃主通道,模型校准吃辅通道
### 5.3 盘口轻量监控(资源受限版)
- 仅采集 Top-of-Book + 前5档聚合每 100-250ms 采样)
- 保留 3 个指标:`microprice`、`imbalance`、`spread`
- 只存特征,不存全量快照
- 后续评估是否升到10档
## 6. 数据源汇总
### 6.1 Binance 免费 APIV5.1 新增)
| 数据 | 接口 | 更新频率 |
|------|------|---------|
| 多空比 | `GET /futures/data/globalLongShortAccountRatio` | 5min |
| 大户持仓比 | `GET /futures/data/topLongShortPositionRatio` | 5min |
| OI 历史 | `GET /futures/data/openInterestHist` | 5min |
| Funding Rate | `GET /fapi/v1/fundingRate` | 8h结算 |
### 6.2 Coinbase Premium
- 对比 Coinbase BTC/USD 与 Binance BTC/USDT 实时价差
- 正 Premium = 机构买入(看多信号)
- 负 Premium = 机构卖出(看空信号)
### 6.3 已有数据源V5.0
| 数据 | 来源 | 存储 |
|------|------|------|
| aggTrades | Binance WebSocket 实时 + REST 回补 | PostgreSQL agg_trades 表 |
| CVD三轨 | signal_engine 内存计算 | signal_indicators 表 |
| ATR/VWAP | signal_engine 内存计算 | signal_indicators 表 |
| P95/P99大单 | signal_engine 24h滚动统计 | signal_indicators 表 |
| Funding Rate | agg_trades_collector 定时采集 | rate_snapshots 表 |
## 7. 回测框架
### 7.1 架构逐tick事件回放
**不用逐分钟K线回测**(会系统性高估策略),用 aggTrades 逐tick回放。
### 7.2 三层数据结构
```
FeatureStore
├── 按时间索引缓存 1m/5m/15m/1h 特征
├── CVD, L/S, OI, FR bias, 盘口因子
└── 滚动窗口自动过期
SignalEvent
├── ts, symbol, side, score, regime
├── factors (各层评分明细)
└── entry_rule_id
PositionState
├── entry_ts, entry_px, size
├── sl_px, tp1_px, tp2_px
├── status (active/tp1_hit/tp/sl/sl_be)
└── cooldown_until
```
### 7.3 撮合逻辑
1. 每个 tick 到来 → 先更新未平仓位(检查 TP/SL/时间止损)
2. 再评估新信号(检查冷却期、评分、仓位规则)
3. 输出交易记录
### 7.4 统计输出
- 胜率 (Win Rate)
- 总盈亏 (Total PnL in R)
- 盈亏比 (Profit Factor)
- 夏普比率 (Sharpe Ratio)
- 最大回撤 (MDD)
- 平均持仓时间 (Avg Hold)
- 滑点影响评估 (Slippage Impact)
## 8. 远期规划
### V5.2(远期备选)
- **Twitter 新闻情绪面**监控关键账号AI分析利好/利空
- **范总判断**:新闻最终反映在 aggTrades 里,信号跑通后不急
### V5.3(数据充足后)
- ML模型替换规则引擎XGBoost/LightGBM集成
- 需要足够回测数据训练
---
*文档版本V5.1-draft | 待范总最终确认*

View File

@ -0,0 +1,132 @@
---
title: V5.1 信号系统文档
---
# V5.1 基线信号系统v51_baseline
## 概述
V5.1 是基于市场微观结构的短线交易信号系统,使用 5 层 100 分评分体系,通过 CVD累积成交量差、大单流、持仓结构等 6 个信号源综合判断交易方向和强度。
## 评分体系5层100分
| 层级 | 权重 | 信号源 | 逻辑 |
|------|------|--------|------|
| 方向层 | 45分 | CVD_fast(30m) + CVD_mid(4h) + P99大单 + 加速度 | 三票共振定方向 |
| 拥挤层 | 20分 | 多空比 + 大户持仓 | 反向拥挤 = 机会 |
| 环境层 | 15分 | OI变化率 | 资金流入确认趋势 |
| 确认层 | 15分 | CVD_fast + CVD_mid 同向 | 双周期共振确认 |
| 辅助层 | 5分 | Coinbase Premium | 美国机构动向 |
### 方向层详解45分 + 5分加速奖金
信号方向由 CVD_fast 和 CVD_mid 综合判断:
```
CVD_fast > 0 且 CVD_mid > 0 → LONG
CVD_fast < 0 CVD_mid < 0 SHORT
不一致 → 以 CVD_fast 方向为准,但标记 no_direction
```
评分项:
- CVD_fast 方向一致:+15
- CVD_mid 方向一致:+15
- P99 大单顺向流入:+15无反向大单时+10
- 加速度奖金CVD_fast 加速度方向一致 → +5可超过45
### 拥挤层详解20分
| 子项 | 满分 | 逻辑 |
|------|------|------|
| 多空比LSR | 10分 | 做空+LSR>2.0=满分,做多+LSR<0.5=满分 |
| 大户持仓比 | 10分 | 做多+多头占比≥55%=满分 |
数据缺失时给中间分5分避免因采集失败误杀信号。
### 环境层详解15分
基于 OI持仓量变化率评分
- OI 显著增长 → 趋势确认 → 满分
- OI 变化不大 → 中等分
- OI 下降 → 低分
### 确认层详解15分
CVD_fast 和 CVD_mid 同时为正LONG或同时为负SHORT→ 15分否则 0分。
> **已知问题**:与方向层存在同源重复(两者都用 CVD_fast/CVD_mid。两周后根据数据评估是否重构。
### 辅助层详解5分
Coinbase Premium = Coinbase 价格 vs Binance 价格差。
- 正溢价 + 做多 → 5分美国机构买入
- 负溢价 + 做空 → 5分
- 溢价绝对值 ≤ 0.05% → 2分中性
- 反向溢价 → 0分
## 开仓规则
| 档位 | 分数 | 行为 |
|------|------|------|
| 不开仓 | < 75 | 不触发 |
| 标准 | 75-84 | 正常开仓 |
| 加仓 | ≥ 85 | 加重仓位 |
- **冷却期**:同币种同策略 10 分钟
- **最大持仓**4 笔/策略
- **反向翻转**:收到反向 ≥75 分信号 → 平旧仓 + 开新仓
## TP/SL 设置
| 参数 | 值 | 说明 |
|------|------|------|
| SL | 1.4 × ATR | 止损 |
| TP1 | 1.05 × ATR | 第一止盈平50%仓位) |
| TP2 | 2.1 × ATR | 第二止盈(平剩余仓位) |
TP1命中后 SL 移到保本价Break Even
> 对应R倍数tp1_r=0.75, tp2_r=1.5以SL为1R
## 信号源
| 信号 | 数据源 | 更新频率 |
|------|--------|----------|
| CVD_fast | aggTrades 30分钟窗口 | 15秒 |
| CVD_mid | aggTrades 4小时窗口 | 15秒 |
| P99 大单 | aggTrades P99分位数 | 实时 |
| 加速度 | CVD_fast 二阶导数 | 15秒 |
| 多空比/大户持仓 | Binance API | 5分钟 |
| OI | Binance API | 5分钟 |
| Coinbase Premium | 计算值 | 5分钟 |
## 策略配置
```json
{
"name": "v51_baseline",
"version": "5.1",
"threshold": 75,
"weights": {
"direction": 45,
"crowding": 20,
"environment": 15,
"confirmation": 15,
"auxiliary": 5
},
"accel_bonus": 5,
"tp_sl": {
"sl_multiplier": 1.4,
"tp1_multiplier": 1.05,
"tp2_multiplier": 2.1
},
"signals": ["cvd", "p99", "accel", "ls_ratio", "oi", "coinbase_premium"]
}
```
## 历史表现(修正后数据,截至 2026-03-02
- 总交易282+ 笔
- 胜率:~65%
- 盈亏比:~0.72
- 按档位85+ 胜率73.6%75-79 胜率78%(最佳档位)

View File

@ -0,0 +1,175 @@
明白!既然马上要进入开发阶段,我们就不能只停留在“概念”上,必须精确到**参数配比、计算公式和逻辑边界**。
作为你的量化分析师我将《V5.3 双轨信号系统设计案》中的高层设计,直接翻译成研发可以直接参考的**“伪代码级”拆解**。这份拆解去除了所有冗余,直指核心代码逻辑。
---
### 一、 轨道 A山寨币模型 (ALT Track - 适用于 ETH, XRP, SOL)
ALT 轨的核心是**线性评分加法模型**。满分 100 分。
#### 1. 方向层 (Direction) —— 总权重55 分
**设计说明:** V5.1 中方向层45分和确认层15分存在多重共线性V5.3 将其合并为 55 分。
* **子项 A快慢期 CVD 共振 (30 分)**
* **逻辑:** `CVD_fast (30m)``CVD_mid (4h)` 必须同向。
* **打分:**
* `CVD_fast` > 0 且 `CVD_mid` > 0做多共振 $\rightarrow$ 给 30 分。
* `CVD_fast` < 0 `CVD_mid` < 0做空共振 $\rightarrow$ 30
* 方向不一致 $\rightarrow$ 0 分(并且整个信号应标记为 `no_direction` 终止计算)。
* **子项 BP99 大单流入 (20 分)**
* **逻辑:** 捕捉极值大单资金方向。
* **打分:**
* P99 大单净流入方向与 CVD 共振方向一致 $\rightarrow$ 给 20 分。
* 无明显反向大单阻击 $\rightarrow$ 给 10 分。
* 反向大单压制 $\rightarrow$ 0 分。
* **子项 C加速度奖金 (Accel Bonus) (5 分)**
* **逻辑:** `CVD_fast` 的二阶导数(动能正在增强)。
* **打分:** 加速度方向与共振方向一致 $\rightarrow$ 给 5 分。
#### 2. 拥挤层 (Crowding) —— 总权重25 分
**设计说明:** 寻找散户的反向流动性(拔高了 V5.1 中该层的权重)。
* **子项 A多空比 LSR (15 分)**
* **做多场景:** 如果 LSR < 0.5散户极度看空 $\rightarrow$ 15 分满分
* **做空场景:** 如果 LSR > 2.0(散户极度看多) $\rightarrow$ 15 分满分。
* *缺省/常态:* 数据缺失或在 0.8 - 1.2 之间 $\rightarrow$ 给 7.5 分中间分。
* **子项 B大户持仓比例 (10 分)**
* **逻辑:** 跟着大户吃散户。
* **打分:** 做多且大户多头占比 $\ge$ 55% $\rightarrow$ 10 分满分(做空同理)。缺省给 5 分。
#### 3. 环境层 (Environment) —— 总权重15 分
* **信号源:** OI (Open Interest) 5 分钟/30 分钟变化率。
* **打分:**
* OI 显著正增长(有新资金入场,趋势真实) $\rightarrow$ 15 分。
* OI 变化平缓 $\rightarrow$ 7.5 分。
* OI 显著下降(说明只是存量平仓导致的假突破) $\rightarrow$ 0 分。
#### 4. 辅助层 (Auxiliary) —— 总权重5 分
* **信号源:** Coinbase Premium (Coinbase 现货价格 - Binance 现货价格)。
* **打分:**
* 做多且正溢价(美国资金在买) $\rightarrow$ 5 分。
* 做空且负溢价(美国资金在砸) $\rightarrow$ 5 分。
* 溢价绝对值 $\le$ 0.05% (中性) $\rightarrow$ 2 分。
* 反向溢价 $\rightarrow$ 0 分。
> ⚙️ **ALT 轨开发执行参数:**
> * `open_threshold`: **75** (总分 $\ge$ 75 触发开仓)
> * `flip_threshold`: **85** (反向信号总分 $\ge$ 85 才允许平旧开新)
>
>
---
### 二、 轨道 B大饼专属模型 (BTC Track)
BTC 轨**抛弃了线性加分**,采用**“布尔逻辑门控 (Boolean Logic Gates)”**。必须同时满足所有通过条件,且不触发任何否决条件,才允许开仓。
#### 1. 门控特征一:波动率状态过滤 (atr_percent_1h) —— 决定“能不能做”
* **公式:** $Volatility = \frac{ATR(1h)}{Close Price}$
* **开发逻辑 (Veto 否决条件)**
设定一个最小波动率阈值(如 `min_vol_threshold = 0.002`,即 0.2%)。
`IF atr_percent_1h < min_vol_threshold THEN BLOCK_SIGNAL ("Garbage Time")`
*理由BTC 在极低波动时,微观特征全是做市商噪音。*
#### 2. 门控特征二:巨鲸 CVD (tiered_cvd_whale) —— 决定“真实方向”
* **计算方式:** 在 aggTrades 聚合时,过滤掉单笔价值 $< \$100k$ 的成交只对 $> \$100k$ 的大单计算 Net Flow。
* **开发逻辑:**
`IF tiered_cvd_whale > strong_positive_threshold THEN Direction = LONG`
#### 3. 门控特征三:前 10 档订单薄失衡 (obi_depth_10) —— 决定“有没有阻力”
* **公式:** $OBI = \frac{Bid Volume - Ask Volume}{Bid Volume + Ask Volume}$ (取盘口前 10 档挂单量)
* **开发逻辑 (Veto 否决条件)**
*做多场景:* 如果 `Direction = LONG`,但 $OBI < -0.3$上方有极重的卖盘墙压制)。
`THEN BLOCK_SIGNAL ("Sell Wall Imbalance")`
#### 4. 门控特征四:期现背离 (spot_perp_divergence) —— 决定“是不是陷阱”
* **计算方式:** Binance BTCUSDT 现货 CVD 减去 BTCUSDT 永续合约 CVD。
* **开发逻辑 (Veto 否决条件)**
*做多场景:* 合约 CVD 在疯狂飙升(散户开多),但现货 CVD 为负(机构在现货抛售)。
`IF perp_cvd > 0 AND spot_cvd < 0 THEN BLOCK_SIGNAL ("Spot Selling Divergence")`
> ⚙️ **BTC 轨开发执行伪代码:**
> `IF (Passed Volatility Gate) AND (Whale CVD confirms Direction) AND NOT (Blocked by OBI) AND NOT (Blocked by Divergence) THEN Execute Trade`
---
### 三、 执行引擎边界条件(影响所有轨)
这些是直接写死在 `signal_engine.py` 或订单执行模块里的硬性规则,用来保卫净收益。
1. **止损/止盈 (SL/TP) 基准:**
* `SL_multiplier`: **2.0** (止损设为入场价 $\pm 2.0 \times ATR$)
* `TP1_multiplier`: **1.5**
* `TP2_multiplier`: **3.0**
2. **Break-Even (BE) 滑点补偿:**
* 打到 TP1 后SL 移动的位置不是 `Entry Price`
* `New_SL = Entry_Price + (Direction * 0.2 * ATR)` (这里的 0.2 ATR 是预留给手续费和滑点的缓冲值)。
3. **TP 兜底状态机 (Fallback Logic)**
* *Step 1:* 信号触发下市价单Taker开仓。
* *Step 2:* 立即挂出 TP1/TP2 的**限价单 (Maker)**。
* *Step 3 (Monitor):* 如果最新标记价格穿过了 TP 触发价,但限价单未成交,启动 `timeout` 计时器(例如 2 秒)。
* *Step 4 (Fallback):* 超时未成交 $\rightarrow$ 发送 `Cancel Order` $\rightarrow$ 发送 `Market Order (Taker)` 强平。
---
## 四、开发注意事项(补充)
1. **缺失数据默认策略(必须写死)**
- BTC 四个门控特征(`atr_percent_1h`, `tiered_cvd_whale`, `obi_depth_10`, `spot_perp_divergence`)任一缺失时,默认 `BLOCK_SIGNAL`
- 必须记录 `block_reason=missing_feature:<name>`,避免静默放行。
2. **阈值治理(避免拍脑袋改参)**
- `min_vol_threshold`、`obi_veto_threshold`、`whale_flow_threshold` 必须配置化(不可硬编码散落在代码中)。
- 文档标注为“初始值”,并明确回测校准窗口与更新频率。
3. **标签口径统一(防止回填偏差)**
- `Y_binary_60m` 使用 `Mark Price` 判定触发顺序。
- ATR 必须使用信号触发时快照 `atr_value`,禁止回填时二次重算 ATR。
4. **TP 兜底状态机补全部分成交分支**
- 触发兜底前先查询订单成交量。
- 若部分成交,只对剩余仓位执行 `Cancel -> Taker Market Close`,避免超平或漏平。
5. **并发与幂等保护**
- `Cancel -> Market` 流程增加订单状态锁(或行级锁)和幂等键。
- 防止重复撤单、重复平仓、双写成交记录。
6. **发布闸门指标字段统一**
- 统一报表输出字段:`maker_ratio`, `avg_friction_cost_r`, `flip_loss_r`
- 发布闸门自动判断基于同一口径,避免人工解释偏差。
**给开发者的最终建议:**
你现在可以拿着这份拆解,直接去写 `v53_alt_config.json` 和 BTC 轨的条件判断代码了。建议你先从 **ALT 轨的 `v53_alt_config.json` 重写**开始,因为这个改动最小,见效最快。是否需要我帮你直接生成这个 JSON 文件的模板?

View File

@ -0,0 +1,383 @@
---
title: V5.2 开发文档(完整版)
description: 套利引擎V5.2 — Bug修复 + 策略优化 + 8信号源 + 策略配置化 + AB测试
---
# V5.2 开发文档(完整版)
> **版本**V5.2 | **状态**:待开发 | **负责人**:露露
> **创建**2026-03-01 | **前置**V5.1 tag `v5.1` commit `d8ad879`
> **V5.1-hotfix**commits `45bad25``4b841bc`P0修复已上线
---
## 一、V5.1 现状总结
### 模拟盘数据截至2026-03-01
| 指标 | 数值 |
|------|------|
| 总交易 | 181笔 |
| 总盈亏 | +37.07R |
| 每笔平均 | +0.20R |
| 初始资金 | $10,000 |
| 当前余额 | ~$17,414 |
### 按档位统计(关键数据)
| 档位 | 笔数 | 胜率 | 总PnL | 每笔平均 | 结论 |
|------|------|------|-------|---------|------|
| **85+ (heavy)** | 53 | 73.6% | +15.00R | +0.28R | ✅ 优质 |
| **80-84** | 81 | 65.4% | +4.11R | +0.05R | ⚠️ 平庸 |
| **75-79** | 41 | 78.0% | +17.05R | +0.42R | ✅ 最佳 |
| **70-74** | 6 | 50.0% | -2.65R | -0.44R | ❌ 亏钱 |
### 手续费分析(核心发现)
| 币种 | SL距离% | 仓位价值 | 隐含杠杆 | 手续费/R |
|------|---------|---------|---------|---------|
| BTC | 0.43% | $46,000 | 4.6x | 0.23R |
| ETH | 0.56% | $36,000 | 3.6x | 0.18R |
| XRP | 0.44% | $45,000 | 4.5x | 0.05R |
| SOL | 0.58% | $34,000 | 3.4x | 0.04R |
**手续费公式**`fee_R = 2 × 0.05% × position_value / risk_usd`
**BTC盈亏比问题**TP2净利+0.89R vs SL净亏-1.23R盈亏比仅0.72需55%以上胜率才保本。
### V5.1已修复的P0 Bughotfix已上线
| Bug | 影响 | 修复Commit |
|-----|------|-----------|
| pnl_r虚高2倍 | 统计数据全部失真 | `45bad25` |
| 冷却期阻断反向平仓 | 反向信号无法关仓 | `45bad25` |
| 分区月份Bug | 月底数据写入失败 | `45bad25` |
| SL/TP用市价不是限价 | SL超过1R | `2f9dce4` |
| 浮盈没算半仓 | 持仓盈亏虚高 | `4b841bc` |
---
## 二、V5.2 目标
### 核心目标
1. **修复所有已知Bug**Claude Code审阅 + 实际使用发现的)
2. **FR+清算加入评分**8信号源完整版
3. **开仓阈值提到75分**砍掉70-74垃圾信号
4. **策略配置化框架**(一套代码多份配置)
5. **AB测试**V5.1 vs V5.2并行对比)
6. **24小时warmup**(消除冷启动)
### 设计原则
- **55%胜率必须盈利**盈亏比至少0.82:1
- **无限趋近实盘**:模拟盘和实盘逻辑完全一致
- **数据驱动**:所有决策基于数据,不拍脑袋
---
## 三、Bug修复清单
### 后端15项
| ID | 优先级 | 文件 | 问题 | 修复方案 |
|----|--------|------|------|---------|
| P0-3 | **P1** | signal_engine.py:285 | 开仓价用30分VWAP而非实时价 | `price = win_fast.trades[-1][2]` |
| P0-4 | P2 | signal_engine+paper_monitor | 双进程并发写paper_trades | `SELECT FOR UPDATE SKIP LOCKED` |
| P1-2 | P2 | signal_engine.py:143-162 | 浮点精度漂移(buy_vol/sell_vol) | 每10000次trim从deque重算sums |
| P1-3 | **P1** | market_data_collector.py:51 | 单连接无重连 | 改用`db.get_sync_conn()`连接池 |
| P1-4 | P3 | db.py:36-43 | 连接池初始化线程不安全 | `threading.Lock`双重检查 |
| P2-1 | P2 | market_data_collector.py:112 | XRP/SOL coinbase_premium KeyError | `if symbol not in pair_map: return` |
| P2-3 | P2 | agg_trades_collector.py:77 | flush_buffer每秒调ensure_partitions | 移到定时任务(每小时一次) |
| P2-4 | P3 | liquidation_collector.py:127 | elif条件冗余 | 改为`else` |
| P2-5 | P2 | signal_engine.py:209 | atr_percentile @property有写副作用 | 拆成`update_atr_history()`方法 |
| P2-6 | P2 | main.py:554 | 1R=$200硬编码 | 从paper_config.json动态读取 |
| P3-1 | P2 | auth.py:15 | JWT密钥硬编码默认值 | 启动时强制校验`JWT_SECRET`环境变量 |
| P3-2 | P3 | main.py:17 | CORS allow_origins=["*"] | 限制为`https://arb.zhouyangclaw.com` |
| P3-3 | P3 | auth.py:316 | refresh token刷新非原子 | `UPDATE...WHERE revoked=0 RETURNING` |
| P3-4 | P3 | auth.py:292 | 登录无频率限制 | slowapi或Redis计数器 |
| NEW-1 | **P1** | signal_engine.py:664 | 冷启动warmup只有4小时 | 分批加载24小时加载完再出信号 |
### 前端12项
| ID | 优先级 | 文件 | 问题 | 修复方案 |
|----|--------|------|------|---------|
| FE-P1-1 | **P1** | lib/auth.tsx:113 | 并发401多次refresh竞态 | 单例Promise + `_refreshPromise` |
| FE-P1-2 | **P1** | lib/auth.tsx:127 | 刷新失败AuthContext未同步 | `window.dispatchEvent("auth:session-expired")` |
| FE-P1-3 | **P1** | 所有页面 | catch{}静默吞掉API错误 | 每个组件加`error` state + 红色提示 |
| FE-P1-4 | P2 | paper/page.tsx:119 | LatestSignals串行4请求 | `Promise.allSettled`并行 |
| FE-P2-1 | P3 | app/page.tsx:52 | MiniKChart每30秒销毁重建 | 只更新data不重建chart |
| FE-P2-3 | P2 | paper/page.tsx:20 | ControlPanel非admin可见 | 校验`isAdmin`非admin隐藏 |
| FE-P2-4 | **P1** | paper/page.tsx:181 | WebSocket无断线重连 | 指数退避重连 + 断线提示 |
| FE-P2-5 | P2 | paper/page.tsx:217 | 1R=$200前端硬编码 | 从`/api/paper/config`读取 |
| FE-P2-6 | P2 | signals/page.tsx:101 | 5秒轮询5分钟数据 | 改为300秒间隔 |
| FE-P2-8 | P3 | paper/signals | 大量`any`类型 | 定义TypeScript interface |
| FE-P3-1 | P3 | lib/auth.tsx:33 | Token存localStorage | 评估httpOnly cookie方案 |
| FE-P3-3 | P3 | app/page.tsx:144 | Promise.all任一失败全丢 | 改`Promise.allSettled` |
---
## 四、新功能8信号源评分
### 当前6信号源 → V5.2增加2个
| # | 信号源 | 层级 | 当前 | V5.2 |
|---|--------|------|------|------|
| 1 | CVD三轨(fast/mid) | 方向层 | ✅ 评分中 | 保持 |
| 2 | P99大单流 | 方向层 | ✅ 评分中 | 保持 |
| 3 | CVD加速度 | 方向层 | ✅ 评分中 | 保持 |
| 4 | 多空比+大户持仓比 | 拥挤层 | ✅ 评分中 | 保持 |
| 5 | OI变化率 | 环境层 | ✅ 评分中 | 保持 |
| 6 | Coinbase Premium | 辅助层 | ✅ 评分中 | 保持 |
| **7** | **资金费率(FR)** | **拥挤层** | ⬜ 仅采集 | **✅ 加入评分** |
| **8** | **清算数据** | **确认层** | ⬜ 仅采集 | **✅ 加入评分** |
### FR评分逻辑草案
```python
# 资金费率 → 拥挤层加分
# FR > 0.03% = 多头过度拥挤 → SHORT加分
# FR < -0.03% = 空头过度拥挤 LONG加分
# |FR| > 0.1% = 极端值 → 反向强信号
funding_rate = self.market_indicators.get("funding_rate")
if funding_rate is not None:
if direction == "LONG" and funding_rate < -0.0003:
fr_score = 5 # 空头拥挤,做多有利
elif direction == "SHORT" and funding_rate > 0.0003:
fr_score = 5 # 多头拥挤,做空有利
elif direction == "LONG" and funding_rate > 0.001:
fr_score = -5 # 多头极度拥挤,做多危险(减分)
elif direction == "SHORT" and funding_rate < -0.001:
fr_score = -5 # 空头极度拥挤,做空危险
else:
fr_score = 0
```
### 清算数据评分逻辑(草案)
```python
# 大额清算 → 确认层加分
# 大额空单清算 = 空头被清洗 → SHORT可能结束LONG加分
# 大额多单清算 = 多头被清洗 → LONG可能结束SHORT加分
# 5分钟内清算量 > 阈值 = 趋势加速信号
liq_long = self.market_indicators.get("long_liq_usd", 0)
liq_short = self.market_indicators.get("short_liq_usd", 0)
if direction == "LONG" and liq_short > 500000:
liq_score = 5 # 大量空单被清算,趋势确认
elif direction == "SHORT" and liq_long > 500000:
liq_score = 5
else:
liq_score = 0
```
### V5.2权重分配(草案)
| 层级 | V5.1权重 | V5.2权重 | 变化 |
|------|---------|---------|------|
| 方向层 | 45+5 | 40+5 | -5 |
| 拥挤层 | 20 | 25+FR 5分 | +5 |
| 环境层 | 15 | 15 | 不变 |
| 确认层 | 15 | 20+清算 5分 | +5 |
| 辅助层 | 5 | 5 | 不变 |
| **总计** | **100+5** | **105+5** | +5 |
> 注总分超过100不影响阈值按绝对分数判断75/80/85
---
## 五、策略调整
### 开仓阈值调整
| | V5.1 | V5.2 | 原因 |
|---|------|------|------|
| 最低开仓分 | 60分 | **75分** | 70-74档位50%胜率,扣手续费亏钱 |
| light档 | 60-74 | **取消** | 数据证明低分信号无价值 |
| standard档 | 75-84 | 75-84 | 保持 |
| heavy档 | 85+ | 85+ | 保持 |
### TP/SL倍数待AB测试确认
| 参数 | V5.1方案A | V5.2候选方案B |
|------|-------------|-----------------|
| SL | 2.0 × risk_atr | 3.0 × risk_atr |
| TP1 | 1.5 × risk_atr | 2.0 × risk_atr |
| TP2 | 3.0 × risk_atr | 4.0 × risk_atr |
| BTC手续费占比 | 0.23R | 0.15R |
| TP2净利 | +0.89R | +0.97R |
| SL净亏 | -1.23R | -1.15R |
| 盈亏比 | 0.72 | 0.84 |
| 保本胜率 | 58% | 54% |
---
## 六、策略配置化框架
### 设计目标
一个signal-engine进程支持多套策略配置并行。
### 配置文件结构
```json
// strategies/v51.json
{
"name": "v51_baseline",
"threshold": 75,
"weights": {
"direction": 45,
"crowding": 20,
"environment": 15,
"confirmation": 15,
"auxiliary": 5
},
"tp_sl": {
"sl_multiplier": 2.0,
"tp1_multiplier": 1.5,
"tp2_multiplier": 3.0
},
"signals": ["cvd", "p99", "accel", "ls_ratio", "oi", "coinbase_premium"]
}
// strategies/v52.json
{
"name": "v52_8signals",
"threshold": 75,
"weights": {
"direction": 40,
"crowding": 25,
"environment": 15,
"confirmation": 20,
"auxiliary": 5
},
"tp_sl": {
"sl_multiplier": 3.0,
"tp1_multiplier": 2.0,
"tp2_multiplier": 4.0
},
"signals": ["cvd", "p99", "accel", "ls_ratio", "oi", "coinbase_premium", "funding_rate", "liquidation"]
}
```
### paper_trades表新增字段
```sql
ALTER TABLE paper_trades ADD COLUMN strategy VARCHAR(32) DEFAULT 'v51_baseline';
```
---
## 七、AB测试方案
### 架构
```
signal_engine.py
├── evaluate_signal(state, strategy="v51") → score_A, signal_A
├── evaluate_signal(state, strategy="v52") → score_B, signal_B
├── paper_open_trade(strategy="v51", ...) → paper_trades.strategy='v51'
└── paper_open_trade(strategy="v52", ...) → paper_trades.strategy='v52'
```
### 独立资金池
- V5.1$10,000虚拟资金独立统计
- V5.2$10,000虚拟资金独立统计
### 对比指标
| 指标 | V5.1 | V5.2 |
|------|------|------|
| 总笔数 | ? | ? |
| 胜率 | ? | ? |
| 每笔平均R | ? | ? |
| 最大回撤 | ? | ? |
| 盈亏比 | ? | ? |
| PF | ? | ? |
### 测试周期
- **两周**目标每策略100+笔)
- 结束后选表现更好的上实盘
---
## 八、24小时Warmup
### 当前问题
signal-engine启动时只加载4小时数据P99大单需要24小时窗口。
### 方案(范总确认)
```python
def main():
states = {sym: SymbolState(sym) for sym in SYMBOLS}
# 分批加载24小时每批100万条避免OOM
for sym, state in states.items():
load_historical_chunked(state, 24 * 3600 * 1000)
logger.info(f"[{sym}] warmup complete")
logger.info("=== 全部币种warmup完成开始出信号 ===")
# 这之后才开始评估循环
```
### 预估
- 4币种24小时约2000-3000万条
- 加载时间1-3分钟
- 启动后信号质量从第一笔就是100%
---
## 九、V5.1-hotfix已修复清单参考
| Commit | 内容 |
|--------|------|
| `45bad25` | P0-1: 反向信号绕过冷却 |
| `45bad25` | P0-2: pnl_r统一(exit-entry)/risk_distance |
| `45bad25` | P1-1: 分区月份+UTC修复 |
| `2f9dce4` | TP/SL改限价单模式趋近实盘 |
| `4b841bc` | 前端浮盈半仓计算 |
| `d351949` | 历史pnl_r修正脚本 |
| `8b73500` | Auth从SQLite迁移PG |
| `9528d69` | recent_large_trades去重 |
---
## 十、开发排期(草案)
| 阶段 | 时间 | 内容 | 产出 |
|------|------|------|------|
| Phase 1 | Day 1-2 | Bug修复P1全部 + P2重要的 | 代码+测试 |
| Phase 2 | Day 2-3 | FR+清算评分逻辑 | signal_engine.py |
| Phase 3 | Day 3-4 | 策略配置化框架 | strategies/*.json + 代码 |
| Phase 4 | Day 4-5 | AB测试机制 | paper_trades.strategy字段 |
| Phase 5 | Day 5 | 24h warmup | signal_engine.py |
| Phase 6 | Day 6-7 | 前端Bug修复 + 策略对比页面 | 前端代码 |
| **部署** | Day 7 | 全部写好测好,一次性部署 | 减少重启 |
---
## 十一、数据需求
### 当前数据量
- aggTrades7039万条BTC 23天 + ETH 3天 + XRP/SOL 3天
- signal_indicators2.7万+条
- paper_trades181笔
- market_indicators5400+条
- liquidations3600+条
### V5.2需要的数据量
- **AB测试**每策略至少100笔 → 两周
- **权重优化Phase 1统计分析**300+笔
- **回归分析**500+笔
- **ML优化**1000+笔
---
## 十二、风险与注意事项
1. **手续费是盈亏关键** — BTC手续费0.23R/笔,必须控制交易频率
2. **样本量不足** — 当前181笔按分数段分析可能不稳定
3. **过拟合风险** — 两周一次微调每次±10-20%,不做大改
4. **AB测试期间** — 两套策略共享最大持仓4个需要分配
5. **24h warmup** — 启动时间变长,需要告知运维(小周)
6. **策略配置化** — 改动signal_engine核心代码必须充分测试
---
*本文档为V5.2开发的完整参考,开发过程中持续更新。*
*商业机密:策略细节不对外暴露。*

View File

@ -0,0 +1,269 @@
---
title: V5.2 策略进化路线图
description: 信号引擎V5.2 — 数据驱动的策略迭代框架,将信号源视为特征、权重视为模型参数,通过模拟盘持续优化
---
# V5.2 策略进化路线图
> **优先级P0最高** | 负责人:露露 | 创建2026-02-28
---
## 一、核心思路 ⭐⭐⭐
### 信号源 = 特征Feature
每一个数据源都是模型的一个**输入特征**。特征越多、越有效,模型的预测能力越强。
当前V5.1的5层评分体系本质上就是一个**手动设计的线性模型**
```
总分 = W1×方向层 + W2×拥挤层 + W3×环境层 + W4×确认层 + W5×辅助层
```
### 权重 = 模型参数Parameter
当前权重是人工拍的45/20/15/15/5不一定是最优的。
**目标**:用真实交易数据(模拟盘+实盘),自动学习每个特征的最优权重。
### 迭代循环(正向飞轮)
```
┌─────────────────────────────────────────────┐
│ │
│ ① 加入新信号源(特征) │
│ ↓ │
│ ② 模拟盘跑数据每笔记录5层分数+盈亏) │
│ ↓ │
│ ③ 分析数据(哪些特征有效、最优权重) │
│ ↓ │
│ ④ 调整权重 / 加减特征 │
│ ↓ │
│ ⑤ 实盘验证 │
│ ↓ │
│ ⑥ 数据反馈 → 回到① │
│ │
│ 数据越多 → 模型越准 → 赚越多 → 数据越多 │
│ │
└─────────────────────────────────────────────┘
```
这就是量化交易的**核心方法论**,和大模型训练思路一样:
- 大模型:数据 → 特征提取 → 权重训练 → 验证 → 迭代
- 信号引擎:行情数据 → 信号源/指标 → 评分权重 → 模拟盘验证 → 调参迭代
**护城河**:积累的数据和调优后的参数,是别人无法复制的。
---
## 二、当前架构V5.1
### 已有信号源(特征)
| 层 | 权重 | 信号源 | 数据来源 | 状态 |
|----|------|--------|---------|------|
| 方向层 | 45分 | CVD_fast(30m) | agg_trades计算 | ✅ 真实数据 |
| 方向层 | - | CVD_mid(4h) | agg_trades计算 | ✅ 真实数据 |
| 方向层 | - | P99大单流 | agg_trades计算 | ✅ 真实数据 |
| 方向层 | +5 | CVD加速度 | agg_trades计算 | ✅ 真实数据 |
| 拥挤层 | 20分 | 多空比 | 币安API globalLongShortAccountRatio | ✅ 真实数据 |
| 拥挤层 | - | 大户持仓比 | 币安API topLongShortAccountRatio | ✅ 真实数据 |
| 环境层 | 15分 | OI变化率 | 币安API openInterestHist | ✅ 真实数据 |
| 确认层 | 15分 | 多时间框架CVD一致性 | agg_trades计算 | ✅ 真实数据 |
| 辅助层 | 5分 | Coinbase Premium | 币安+CB价差 | ✅ 真实数据 |
### 每笔交易记录的数据
```json
{
"score": 85,
"score_factors": {
"direction": {"score": 45, "cvd_fast": 15, "cvd_mid": 15, "p99_flow": 15, "accel_bonus": 5},
"crowding": {"score": 15, "long_short_ratio": 10, "top_trader_position": 5},
"environment": {"score": 10, "open_interest_hist": 0.02},
"confirmation": {"score": 15},
"auxiliary": {"score": 5, "coinbase_premium": 0.0012}
},
"pnl_r": 2.25,
"status": "tp"
}
```
---
## 三、V5.2 待加入信号源(按优先级)
### 第一批(数据容易获取)
| 信号源 | 类型 | 获取方式 | 预期价值 |
|--------|------|---------|---------|
| 资金费率(Funding Rate) | 拥挤指标 | 币安API /fapi/v1/fundingRate | 高 — 极端FR是反转信号 |
| 清算数据(Liquidation) | 情绪指标 | 币安WS forceOrder | 高 — 大额清算=趋势加速 |
| 期权PCR(Put/Call Ratio) | 情绪指标 | Deribit API | 中 — 机构对冲意愿 |
| 波动率指数(DVOL) | 环境指标 | Deribit API | 中 — 波动率扩张/收缩 |
### 第二批(需要爬取/计算)
| 信号源 | 类型 | 获取方式 | 预期价值 |
|--------|------|---------|---------|
| Twitter/X情绪 | 情绪指标 | Agent-Reach xsearch | 中 — 散户情绪反指标 |
| 恐贪指数 | 情绪指标 | alternative.me API | 低 — 日级更新太慢 |
| 链上大额转账 | 鲸鱼行为 | Etherscan/Blockchain API | 中 — 鲸鱼动向 |
| 交易所净流入 | 资金流 | CryptoQuant/Glassnode | 高 — 抛压预警 |
### 第三批(高级)
| 信号源 | 类型 | 获取方式 | 预期价值 |
|--------|------|---------|---------|
| 订单簿深度不对称 | 微观结构 | 币安WS depth | 中 — 支撑/阻力判断 |
| 跨交易所价差 | 套利信号 | 多交易所API | 低 — 实现复杂 |
| 新闻事件检测 | 事件驱动 | LLM分析 | 中 — 黑天鹅预警 |
---
## 四、权重优化方法(数据足够后实施)
### Phase 1统计分析200+笔交易后)
```python
# 按各层分数分桶,看胜率
# 例如:方向层>=40分时胜率65%<30分时胜率45%
# → 说明方向层有效,保持高权重
# 按拥挤层分桶
# 例如:拥挤层>=15分时胜率60%<10分时胜率58%
# → 说明拥挤层区分度低,可以降权重
```
### Phase 2回归分析500+笔交易后)
```python
# 逻辑回归:各层分数 → 是否盈利
# 输出:每个特征的系数 = 最优权重
from sklearn.linear_model import LogisticRegression
model = LogisticRegression()
model.fit(X_factors, y_win) # X=5层分数, y=盈利/亏损
optimal_weights = model.coef_
```
### Phase 3机器学习1000+笔交易后)
```python
# XGBoost/随机森林:自动发现非线性关系
# 例如:方向层高+拥挤层低 = 最佳组合(简单线性模型发现不了)
from xgboost import XGBClassifier
model = XGBClassifier()
model.fit(X_factors, y_win)
# 特征重要性排序 → 指导信号源增减
```
---
## 五、2026-02-28 开发记录
### V5.1完善
- **5层评分真实数据修复**signal_engine之前没正确解析market_indicators的JSONB拥挤/环境/辅助层全是默认中间分。修了fetch_market_indicators正确解析JSONB → commit `317031a`
- **前端UI全面压缩**:标题改"⚡ 信号引擎 V5.1",所有面板字体/间距/padding缩小 → commits `9382d35`, `271658c`
### 模拟盘上线Paper Trading
- **paper_trades表** + signal_engine集成 + 5个API → commit `e054db1`
- **开关机制**默认关闭前端按钮控制API热更新 → commit `282aed1`
- **手续费**Taker 0.05%×2=0.1%来回 → commit `47004ec`
- **反向信号翻转**:持多仓来空信号→先平后开 → commit `6681070`
- **WebSocket实时TP/SL**独立paper_monitor.py进程毫秒级平仓 → commit `7b901a2`
- **前端aggTrade实时价格**:逐笔成交推送 → commit `1d23042`
- **当前资金显示** → commit `d0e626a`
- **冷启动保护**重启后跳过前3轮防重复开仓 → commit `95b45d0`
- **5层评分明细记录**score_factors JSONB字段 → commit `022ead6`
### Bug修复
- arb-api缩进错误 → commit `cd17c76`
- Request未导入 → commit `b232270`
- useAuth登录检测 → commit `59910fe`
- 现价不准改币安API → commit `d177d28`
- 最新信号symbol参数修复 → commit `404cc68`
- 资金字体超框 → commit `95fec35`
- gitignore __pycache__ → commit `961cbc6`
---
## 六、下一步行动
| 阶段 | 时间 | 内容 |
|------|------|------|
| 现在 | 2026-02-28 ~ 03-14 | 模拟盘跑两周积累200-300笔交易数据 |
| Phase 1 | 03-14 | 统计分析各层贡献,初步调权重 |
| Phase 2 | 03-21 | 加入资金费率+清算数据(第一批新信号源) |
| Phase 3 | 04-01 | 回归分析自动优化权重 |
| Phase 4 | 04-15 | 小仓实盘验证 |
| 持续 | 长期 | 不断加入新信号源,数据驱动迭代 |
---
## 七、行业竞品与信号源调研2026-02-28
### 与我们思路相似的项目
#### 1. Hyper-Alpha-ArenaGitHub开源— 相似度85%
- **地址**https://github.com/HammerGPT/Hyper-Alpha-Arena
- **核心**监控Order Flow + OI变化 + Funding Rate极端值触发自动交易
- 支持币安合约+Hyperliquid用LLMGPT-5/Claude/DeepSeek做策略决策
- **区别**他们用LLM自然语言描述策略做决策我们用评分模型
- **我们的优势**5层评分+权重训练更可量化、可回测、可优化
#### 2. FinRL-AlphaSeek哥伦比亚大学ACM竞赛— 相似度80%
- **地址**https://github.com/Open-Finance-Lab/FinRL_Contest_2025
- **论文**https://arxiv.org/html/2504.02281v4
- **核心**:因子挖掘(Factor Mining) + 集成学习(Ensemble Learning)
- 两阶段:① 特征工程+因子选择 ② 多模型集成
- **映射关系**:他们的"因子"=我们的"信号源",他们的"集成权重"=我们的"评分权重"
- **区别**:他们用强化学习(RL)训练Agent我们用统计回归优化权重
- **可借鉴**:遗传算法优化权重(比逻辑回归更强)
#### 3. ACM论文ML驱动多因子量化模型ETH市场— 相似度75%
- **地址**https://dl.acm.org/doi/10.1145/3766918.3766922
- **核心**:把交易因子分三类 — 传统技术因子 + 链上因子 + ML生成因子
- 用IC值信息系数衡量每个因子的预测力
- 用**遗传算法**自动优化因子权重
- 信号用Z-score阈值触发>1买入<-1卖出
- **启发**我们也可以用IC值来量化每个信号源的贡献
#### 4. CoinGlass CDRI衍生品风险指数— 相似度70%
- **地址**https://www.coinglass.com/pro/i/CDRI
- **核心**综合OI/FR/清算/CVD等多指标打分评分>80或<20触发信号
- **区别**:他们只做风险预警展示,不做自动交易
### 行业信号源使用频率排名
| 排名 | 信号源 | 行业使用频率 | 我们状态 | 数据源 | 获取难度 |
|------|--------|------------|---------|--------|---------|
| 1 | CVD/Order Flow | ⭐⭐⭐⭐⭐ | ✅ 已有 | agg_trades | - |
| 2 | Open Interest | ⭐⭐⭐⭐⭐ | ✅ 已有 | 币安API | - |
| 3 | Funding Rate | ⭐⭐⭐⭐⭐ | ⬜ 待加 | 币安API免费 | ⭐ |
| 4 | 清算数据 | ⭐⭐⭐⭐ | ⬜ 待加 | 币安WS forceOrder | ⭐⭐ |
| 5 | 多空比 | ⭐⭐⭐⭐ | ✅ 已有 | 币安API | - |
| 6 | 链上净流入/流出 | ⭐⭐⭐⭐ | ⬜ 待加 | CryptoQuant付费 | ⭐⭐⭐ |
| 7 | Coinbase Premium | ⭐⭐⭐ | ✅ 已有 | 价差计算 | - |
| 8 | 社交情绪 | ⭐⭐⭐ | ⬜ 待加 | Santiment/LLM | ⭐⭐⭐ |
| 9 | 期权PCR/DVOL | ⭐⭐⭐ | ⬜ 待加 | Deribit API | ⭐⭐ |
| 10 | 鲸鱼钱包追踪 | ⭐⭐⭐ | ⬜ 待加 | Nansen付费 | ⭐⭐⭐ |
| 11 | 清算热力图 | ⭐⭐ | ⬜ 待加 | CoinGlass API付费 | ⭐⭐ |
| 12 | 订单簿深度 | ⭐⭐ | ⬜ 待加 | 币安WS depth | ⭐⭐ |
### 关键结论
1. **没有人做得和我们完全一样** — 大多数用传统技术指标或纯ML黑箱用CVD+多空比+OI做多层评分的几乎没有
2. **行业趋势明确** — 多因子 + ML权重优化和范总定的方向完全一致
3. **Funding Rate是最高优先级** — 行业使用率最高、免费获取、我们还没加
4. **权重优化可升级** — 从逻辑回归→IC值+遗传算法参考ACM论文方法
### 数据供应商参考
| 平台 | 核心能力 | 价格 | 适合 |
|------|---------|------|------|
| CoinGlass | 衍生品数据OI/FR/清算/热力图) | 免费基础+付费API | 清算数据 |
| CryptoQuant | 链上数据(净流入/矿工/交易所储备) | $29/月起 | 链上因子 |
| Santiment | 社交情绪+链上+开发活跃度 | 免费基础+付费 | 情绪因子 |
| Glassnode | 链上高级指标SOPR/NUPL/STH-LTH | $39/月起 | 深度链上 |
| Nansen | 鲸鱼钱包追踪+Smart Money | $100/月起 | 鲸鱼行为 |

View File

@ -0,0 +1,193 @@
---
title: V5.2 模拟盘执行分析报告
date: 2026-03-03
---
# V5.2 模拟盘执行分析报告
> 数据口径真实成交价agg_trades+ 手续费扣除calc_version=2
> 分析日期2026-03-03
> 策略名称v52_8signals8信号源
> 参与分析露露Sonnet 4.6、小范GPT-5.3-Codex
---
## 一、总体概况
| 指标 | 数值 |
|------|------|
| 总交易笔数 | 156笔含4笔活跃/tp1_hit |
| 已闭合笔数 | 152笔 |
| 净R含手续费| **-25.07R** |
| 毛R不含手续费| **-3.27R** |
| 总手续费 | **21.80R** |
| 平均单笔手续费 | 0.143R |
| 胜率 | 51.3% |
| 平均每笔净R | -0.165R |
> 本金10,000 USD1R=200 USD → 净亏损约5,014 USD
> 与V5.1对比笔数少152 vs 500手续费率低0.143R vs 0.218R**但毛R为负(-3.27R)**,信号层本身无优势
---
## 二、V5.2 vs V5.1 对比
| 指标 | V5.1 | V5.2 |
|------|------|------|
| 已闭合笔数 | 500 | 152 |
| 净R | -99.73R | -25.07R |
| 毛R | **+10.73R** | **-3.27R** |
| 总手续费 | 110.46R | 21.80R |
| 平均单笔费 | 0.218R | 0.143R |
| 胜率 | 55.4% | 51.3% |
| 平均净R | -0.193R | -0.165R |
**关键差异V5.1毛R为正+11RV5.2毛R为负-3R。V5.2交易频率低但信号质量更差。**
---
## 三、出场类型分布
| 状态 | 笔数 | 平均R | 合计R | 平均手续费 |
|------|------|-----------|-------|----------|
| sl止损| 43 | -1.161R | **-49.90R** | 0.161R |
| timeout超时| 32 | +0.055R | +1.74R | 0.132R |
| sl_be保本止损| 30 | +0.179R | +5.38R | 0.154R |
| tp止盈| 29 | +0.964R | +27.97R | 0.119R |
| signal_flip翻转| 18 | -0.570R | -10.25R | 0.145R |
**关键发现**
- SL 43笔亏49.90RTP只29笔赚27.97R,差额-21.93R
- signal_flip出场损耗大-0.570R均值),说明信号方向频繁切换
- **SL均值-1.161R低于V5.1的-1.232R单笔止损更小SL更宽**
### SL均值拆解
| 组成 | 数值 |
|------|------|
| SL基础R | -1.000R |
| 手续费 | -0.161R |
| 净SL | -1.161R |
---
## 四、方向分析
| 方向 | 笔数 | 胜率 | 合计R |
|------|------|------|-------|
| LONG | 103 | 49.5% | -13.18R |
| SHORT | 53 | 54.9% | -11.88R |
**结论**LONG胜率仅49.5%(低于随机),多空均亏。
---
## 五、币种分析
| 币种 | 笔数 | 胜率 | 合计R |
|------|------|------|-------|
| BTCUSDT | 40 | **35.9%** | **-13.24R** |
| XRPUSDT | 37 | 52.8% | -9.17R |
| ETHUSDT | 37 | 58.3% | -3.40R |
| SOLUSDT | 42 | 58.5% | **+0.74R** |
**关键发现**
- BTC胜率35.9%严重低于随机V5.2比V5.1更差V5.1是49.3%
- SOL是唯一正R币种+0.74R胜率58.5%
- ETH胜率58.3%但仍净亏(手续费拖累)
---
## 六、信号分数段分析
| 分数段 | 笔数 | 胜率 | 合计R |
|--------|------|------|-------|
| 75-79 | 105 | 50.5% | -21.33R |
| 80-84 | 42 | 45.2% | -9.51R |
| 85+ | 9 | **88.9%** | **+5.77R** |
**重要发现**
- V5.2的85+高分段胜率88.9%,合计+5.77R**与V5.1完全相反**V5.1高分无效)
- 但样本量太少只有9笔统计意义有限
- 75-84分段表现极差80-84甚至只有45.2%胜率
---
## 七、时段分析(北京时间)
### 盈利时段合计R>0
| 时段 | R | 胜率 |
|------|---|------|
| 05:00 | +0.94R | 75.0% |
| 08:00 | +2.93R | 85.7% |
| 22:00 | +4.76R | 80.0% |
| 23:00 | +7.62R | 85.7% |
### 重度亏损时段(胜率<30%
| 时段 | R | 胜率 |
|------|---|------|
| 00:00 | -4.97R | 14.3% |
| 09:00 | -5.88R | 14.3% |
| 12:00 | -4.38R | 33.3% |
| 13:00 | -5.22R | 40.0% |
**V5.1和V5.2共同亏损时段**09:00、13:00两个策略均在这两个时段表现极差
---
## 八、持仓时间分析
| 出场类型 | 平均持仓 |
|----------|---------|
| timeout | 60.0分钟 |
| sl_be | 26.8分钟 |
| tp | 27.0分钟 |
| sl | **28.1分钟** |
| flip | 27.0分钟 |
**与V5.1对比**V5.2的SL持仓时间更长28min vs 18min说明SL空间更宽sl=2.1×ATR vs 1.4×ATR但仍被打出。
---
## 九、风险统计
| 指标 | 数值 |
|------|------|
| 单笔最大亏损 | -1.29R |
| 单笔最大盈利 | +1.02R |
| 标准差 | 0.796R |
| 中位数 | +0.079R |
---
## 十、核心结论
### V5.2 vs V5.1 关键差异
| 维度 | V5.1 | V5.2 | 解读 |
|------|------|------|------|
| 毛R | +10.73R | -3.27R | V5.2信号质量更差 |
| 胜率 | 55.4% | 51.3% | V5.2信号未改善胜率 |
| 单笔费 | 0.218R | 0.143R | V5.2手续费率更低SL更宽 |
| BTC胜率 | 49.3% | 35.9% | V5.2在BTC上更差 |
| 85+分段 | 无效 | 88.9%9笔| 样本太少,不可靠 |
### V5.2失败原因
1. **信号质量比V5.1更差**毛R从+11R变成-3R8个信号源的叠加没有提升预测能力反而带来更多噪声
2. **BTC更差**35.9%胜率说明额外信号源对BTC的预测无帮助
3. **signal_flip损耗大**-0.570R均值,方向频繁切换,每次翻转都有损耗
4. **统计样本不足**152笔相对于策略评估太少结论不确定性高
### 与Gemini分析对照
- CVD双重计分问题在V5.2同样存在
- V5.2增加的8个信号源相比V5.1的6个未能提升正交性
- V5.3应从根本上解决因子多重共线性问题
---
## 十一、对V5.3设计的启示
1. **V5.1有微弱信号毛R正V5.2没有**V5.3应保留V5.1的核心因子,不是简单增加信号
2. **BTC在两个版本都表现差**V5.3可考虑完全不交易BTC专注ETH/XRP/SOL
3. **08:00、22:00、23:00是两个策略共同盈利时段**:这些时段可能有结构性因素(美盘/亚盘交替)
4. **删除确认层CVD重复是最优先改动**在V5.1和V5.2中均可验证这是评分失真的根源

View File

@ -0,0 +1,151 @@
---
title: V5.2 信号系统文档
---
# V5.2 八信号源系统v52_8signals
## 概述
V5.2 在 V5.1 基础上新增 **资金费率Funding Rate****清算数据Liquidation** 两个信号源,形成 7 层 100 分评分体系。目标是提高信号精准度,减少无效开仓。
## 与 V5.1 的核心差异
| 对比项 | V5.1 | V5.2 |
|--------|------|------|
| 信号源 | 6个 | 8个+FR, +清算) |
| 评分层 | 5层 | 7层+FR层, +清算层) |
| 方向权重 | 45分 | 40分 |
| TP/SL | SL=2.0×ATR | SL=3.0×ATR更宽 |
| 盈亏比目标 | 0.72 | 0.84+ |
## 评分体系7层100分
| 层级 | 权重 | 信号源 | 说明 |
|------|------|--------|------|
| 方向层 | 40分 | CVD_fast + CVD_mid + P99大单 | 同V5.1但权重降低 |
| 拥挤层 | 18分 | 多空比 + 大户持仓 | 结构性仓位判断 |
| **FR层** | **5分** | **资金费率** | **持仓成本顺风度** |
| 环境层 | 12分 | OI变化率 | 同V5.1但权重降低 |
| 确认层 | 15分 | CVD双周期共振 | 同V5.1 |
| **清算层** | **5分** | **清算比率** | **市场清洗力度** |
| 辅助层 | 5分 | Coinbase Premium | 同V5.1 |
### FR层详解5分— 线性评分
**数据源**Binance fundingRate每5分钟采集每8小时结算。
**评分公式**
```
raw_score = (|当前FR| / 历史最大FR) × 5上限5分
有利方向 → fr_score = raw_score0~5
不利方向 → fr_score = 0
```
**方向判断**
- 做多 + FR为负空头付费给多头= 有利 → 给分
- 做空 + FR为正多头付费给空头= 有利 → 给分
- 其他 = 不利 → 0分不扣分
**历史最大FR**:从数据库实时计算,每小时缓存一次。数据越多越精准。
| 币种 | 历史最大FR | 说明 |
|------|-----------|------|
| BTC | ~0.0046% | 波动最小 |
| ETH | ~0.0095% | 中等 |
| XRP | ~0.022% | 波动大 |
| SOL | ~0.022% | 波动大 |
**设计原则**
- 信号方向已确定FR只评估"有多顺风"
- 不利方向不扣分方向决策不由FR层负责
- 线性映射,不分层不设阈值,灵活精准
- 分数带小数(如 +2.27分、+0.33分)
### 清算层详解5分— 梯度评分
**数据源**liq-collector 实时采集清算事件。
**评分逻辑**
```
ratio = 对手方清算USD / 己方清算USD
做多 → ratio = 空头清算 / 多头清算
做空 → ratio = 多头清算 / 空头清算
ratio ≥ 2.0 → 5分
ratio ≥ 1.5 → 3分
ratio ≥ 1.2 → 1分
ratio < 1.2 0分
```
**逻辑**:对手方爆仓越多,说明市场正在朝我们的方向清洗,有利信号。
## TP/SL 设置
| 参数 | V5.1 | V5.2 | 变化原因 |
|------|------|------|----------|
| SL | 2.0 × ATR | 3.0 × ATR | 更宽止损,减少噪声出局 |
| TP1 | 1.5 × ATR | 2.0 × ATR | 更远止盈,提高盈亏比 |
| TP2 | 3.0 × ATR | 4.5 × ATR | 大幅提升盈亏比目标 |
理论盈亏比从 0.72 提升到 0.84。
## 开仓规则
与 V5.1 相同:
- 阈值75分标准85分加仓
- 冷却10分钟
- 最大持仓4笔
- 反向翻转反向≥75分 → 平旧开新
## 策略配置
```json
{
"name": "v52_8signals",
"version": "5.2",
"threshold": 75,
"weights": {
"direction": 40,
"crowding": 18,
"funding_rate": 5,
"environment": 12,
"confirmation": 15,
"liquidation": 5,
"auxiliary": 5
},
"accel_bonus": 0,
"tp_sl": {
"sl_multiplier": 3.0,
"tp1_multiplier": 2.0,
"tp2_multiplier": 4.5
},
"signals": ["cvd", "p99", "accel", "ls_ratio", "oi", "coinbase_premium", "funding_rate", "liquidation"]
}
```
## AB测试观测清单2026-03-02 ~ 03-16
### 冻结期规则
- 不改权重、不改阈值、不改评分逻辑
- 单写入源(小周生产环境)
- 目标V5.1 500+笔V5.2 200+笔
### 两周后评审项
1. **确认层重复计分审计** — 方向层和确认层同源,看区分度
2. **拥挤层 vs FR相关性**`corr(FR_score, crowd_score)`>0.7则降一层
3. **OI持续性审计**`oi_persist_n=1` vs `>=2` 胜率差异
4. **清算触发率审计** — 按币种,避免触发不均衡
5. **config_hash落库** — 权重调整前补版本标识
### 权重优化路径
- 200+笔 → 统计分析(各层分布+胜率关联)
- 500+笔 → 回归分析(哪些层对盈亏贡献最大)
- 1000+笔 → MLXGBoost等
## 已知问题
1. **方向层与确认层同源重复** — 等数据验证
2. **清算样本少** — 才积累2天ratio波动大
3. **币种间FR基准差异大** — BTC max=0.0046% vs SOL=0.022%,线性映射已自动处理

View File

@ -0,0 +1,304 @@
---
title: V5.3 统一信号系统设计案
date: 2026-03-03
updated: 2026-03-03
---
# V5.3 统一信号系统设计案
> 目标:让策略从"手工打分规则"升级为"可持续训练和迭代的小模型系统"。统一架构覆盖 BTC/ETH/XRP/SOLper-symbol 参数化门控,消除双轨维护成本。
## 1. 设计原则
1. **统一评分、差异化门控**:四层评分逻辑完全一致,通过 `symbol_gates` 参数化各币种的门控阈值。
2. **先数据、后调参**:先补齐特征与标签落库,再做参数优化。
3. **反过拟合优先**任何优化必须先过样本外验证OOS
4. **信号与执行解耦**Alpha信号与成本执行分开归因。
5. **版本可追溯**:每次信号和交易都可回溯到 `strategy_version + config_hash + engine_instance`
## 2. 现状问题归纳V5.1/V5.2
- V5.1毛R为正、净R为负说明有 Alpha 但被手续费和执行摩擦吞噬。
- V5.2交易频率下降但毛R转负说明新增因子未提升预测力存在噪声与冗余。
- 评分结构存在共线性:方向层与确认层同源(重复使用 CVD_fast/CVD_mid
- BTC 与 ALT 使用同构逻辑,忽略了市场微结构差异。
## 3. V5.3 总体架构
```
Market Data → Feature Snapshot → _evaluate_v53() → Gate Check → Signal Decision → Execution → Label Backfill → Walk-Forward Eval
```
### 3.1 统一策略v53
单一策略文件 `backend/strategies/v53.json`,覆盖 BTC/ETH/XRP/SOL。
**四层评分总分100**
| 层 | 权重 | 子项 |
|---|---|---|
| Direction | 55 | CVD共振(30) + P99大单对齐(20) + 加速奖励(5) |
| Crowding | 25 | LSR反向(15) + 大户持仓(10) |
| Environment | 15 | OI变化率 |
| Auxiliary | 5 | Coinbase Premium |
**Per-symbol 四门控制symbol_gates**
| 门 | BTC | ETH | XRP | SOL |
|---|---|---|---|---|
| 波动率下限 | 0.2% | 0.3% | 0.4% | 0.6% |
| 鲸鱼阈值/逻辑 | whale_cvd_ratio >$100k | 大单否决 $50k | 大单否决 $30k | 大单否决 $20k |
| OBI否决 | ±0.30 | ±0.35 | ±0.40 | ±0.45 |
| 期现背离否决 | ±0.3% | ±0.5% | ±0.6% | ±0.8% |
**开仓档位**
- < 75分不开仓
- 7584分标准仓1×R
- ≥ 85分加仓档1.5×R
- 冷却期10分钟
### 3.2 实时数据流
| 数据 | 来源 | 频率 | 覆盖币种 |
|---|---|---|---|
| OBI订单簿失衡 | `@depth10@100ms` perp WS | 100ms | BTC/ETH/XRP/SOL |
| 期现背离 | `@bookTicker` spot + `@markPrice@1s` perp | 1s | BTC/ETH/XRP/SOL |
| 巨鲸CVD | aggTrades 流内计算(>$100k | 实时 | BTC |
| 大单方向 | aggTrades 流内计算 | 实时 | ETH/XRP/SOL |
## 4. 训练数据飞轮Phase 3
```
signal_feature_events (raw features, 每轮评分写入)
↓ label_backfill.py (T+60m打标签)
signal_label_events (y_binary_60m, mfe_r_60m, mae_r_60m)
↓ walk_forward.py
权重优化 → v53.json 更新
```
**Walk-Forward 规则(严防过拟合)**
- 训练窗口30天步长7天
- 验证集:永远在训练集之后,不交叉
- 评估指标OOS 净R、胜率、MDD
## 5. 版本演进记录
| 版本 | 时间 | 变更摘要 |
|---|---|---|
| V5.1 | 2026-02 | 基础CVD评分有毛Alpha净R为负 |
| V5.2 | 2026-02 | 新增8信号层频率下降但净R未改善 |
| V5.3 Phase0 | 2026-03-03 | 建立feature/label落库表ATR列 |
| V5.3 Phase1 | 2026-03-03 | 四层评分+双轨(alt/btc),删确认层 |
| V5.3 Phase2 | 2026-03-03 | RT-WS接入(OBI+期现背离)覆盖所有symbol |
| V5.3 统一版 | 2026-03-03 | 合并alt/btc为单一v53策略per-symbol门控 |
- **Feature Snapshot**:每次评估时落库原始特征和中间分数(含 `atr_value` 快照)。
- **Track Router**:按 symbol 路由到 ALT/BTC 模型。
- **Signal Decision**:输出开仓/不开仓/翻转决策和原因。
- **Execution**:独立处理 maker/taker、TP/SL、BE、flip。
- **Label Backfill**:按 15/30/60 分钟回填标签。
- **Walk-Forward Eval**:滚动训练与验证,驱动版本迭代。
## 4. 双轨模型定义
## 4.1 ALT 轨ETH/XRP/SOL
- 目标:保留 V5.1 有效微观结构 Alpha去除冗余。
- 关键变更:取消独立 Confirmation 层(避免与 Direction 共线性重复计分)。
- 决策机制:线性加权评分(总分 100+ 门控 + 阈值。
### 4.1.1 ALT 权重总表V5.3 初版)
| 层级 | 权重 | 子特征 | 子特征权重 | 说明 |
|------|------|--------|------------|------|
| Direction | 55 | `cvd_resonance` | 30 | `cvd_fast``cvd_mid` 同向共振 |
| Direction | 55 | `p99_flow_alignment` | 20 | P99 大单方向与主方向一致 |
| Direction | 55 | `cvd_accel_bonus` | 5 | CVD 加速度同向奖励 |
| Crowding | 25 | `lsr_contrarian` | 15 | 多空比反向拥挤 |
| Crowding | 25 | `top_trader_position` | 10 | 大户持仓方向确认 |
| Environment | 15 | `oi_delta_regime` | 15 | OI 变化状态 |
| Auxiliary | 5 | `coinbase_premium` | 5 | 美系现货溢价辅助 |
### 4.1.2 ALT 子特征评分函数(标准化)
- `cvd_resonance`0/30
- LONG`cvd_fast > 0 && cvd_mid > 0`
- SHORT`cvd_fast < 0 && cvd_mid < 0`
- 否则 `0``gate_no_direction=true`
- `p99_flow_alignment`0/10/20
- 强同向净流20
- 无明显反向压制10
- 明显反向0
- `cvd_accel_bonus`0/5
- 加速度方向与主方向一致5
- `lsr_contrarian`0~15
- LONG`lsr<=0.5` 高分,`0.5<lsr<1.0` 线性递减
- SHORT`lsr>=2.0` 高分,`1.0<lsr<2.0` 线性递减
- `top_trader_position`0~10
- 与方向一致比例越高分越高
- `oi_delta_regime`0/7.5/15
- 资金显著流入15
- 平稳7.5
- 显著流出0
- `coinbase_premium`0/2/5
- 顺向溢价5
- 中性2
- 反向0
### 4.1.3 ALT 决策阈值
- `open_threshold = 75`
- `flip_threshold = 85`
- `max_positions_per_track = 4`
- `cooldown_seconds = 300`
### 4.1.4 ALT 信号总分公式
```text
score_alt = direction(55) + crowding(25) + environment(15) + auxiliary(5)
open if score_alt >= 75 and no veto
flip if reverse_score_alt >= 85 and no veto
```
## 4.2 BTC 轨(独立模型)
- 目标:针对机构主导盘口,提升信号有效性。
- 决策方式:先用"条件门控 + 否决条件",不与 ALT 共用线性总分。
### 4.2.1 BTC 核心特征
- `atr_percent_1h`1小时 ATR 占当前价格百分比(波动率门控)
- `tiered_cvd_whale`:按成交额分层的大单净流(建议主桶 `>100k`
- `obi_depth_10`:盘口前 10 档失衡
- `spot_perp_divergence`:现货与永续价量背离
### 4.2.2 BTC 门控与否决逻辑
- 波动率门控:`atr_percent_1h < min_vol_threshold` -> veto
- 方向门控:巨鲸净流未达阈值 -> veto
- 挂单墙否决:方向与 OBI 显著冲突 -> veto
- 期现背离否决perp 强多但 spot 弱(或反向)-> veto
### 4.2.3 BTC 参数(初始值,可配置)
- `min_vol_threshold = 0.002`0.2%
- `obi_veto_threshold = 0.30`
- `whale_flow_threshold`:按币价与流动性分档配置
- 以上参数均定义为配置项,禁止散落硬编码。
### 4.2.4 BTC 决策伪代码
```text
if missing_any_feature:
block("missing_feature")
if atr_percent_1h < min_vol_threshold:
block("low_vol_regime")
if abs(tiered_cvd_whale) < whale_flow_threshold:
block("weak_whale_flow")
if direction_conflict_with_obi:
block("obi_imbalance_veto")
if spot_perp_divergence_is_trap:
block("spot_perp_divergence_veto")
otherwise:
allow_open
```
## 5. 数据基建ML Ready
## 5.1 表设计
### `signal_feature_events`
- 用途:每次信号评估快照(无论是否开仓)。
- 关键字段:
- 元数据:`event_id, ts, symbol, track, side`
- 版本:`strategy, strategy_version, config_hash, engine_instance`
- 原始特征:`cvd_fast_raw, cvd_mid_raw, p99_flow_raw, accel_raw, ls_ratio_raw, top_pos_raw, oi_delta_raw, coinbase_premium_raw, fr_raw, liq_raw, obi_raw, tiered_cvd_whale_raw, atr_value`
- 决策:`score_total, score_direction, score_crowding, score_environment, score_aux, gate_passed, block_reason`
### `signal_label_events`
- 用途:延迟回填标签,评估信号纯预测能力。
- 字段:`event_id, y_binary_30m, y_binary_60m, y_return_15m, y_return_30m, y_return_60m, mfe_r_60m, mae_r_60m`
### `execution_cost_events`
- 用途:独立归因执行成本。
- 字段:`trade_id, entry_type, exit_type, fee_bps, slippage_bps, maker_ratio, flip_flag, hold_seconds, friction_cost_r`
## 5.2 标签定义
- `Y_binary_60m`(严格定义):从信号触发时间 `ts` 起 60 分钟内,使用 `Mark Price` 序列判定,若价格先触及 `+2.0 * atr_value`,且在该触发时刻之前从未触及 `-1.0 * atr_value`,则记为 `1`,否则记为 `0`
- 时间顺序要求Chronological Order若 60 分钟窗口内先触及 `-1.0 * atr_value`,即使后续再触及 `+2.0 * atr_value`,也必须记为 `0`
- `Y_return_t`固定时间窗15m/30m/60m净收益率含成本估计
说明:
- 标签优先评价"信号有效性",而不是被具体 TP/SL 参数污染的最终交易结果。
- 统一使用 `Mark Price` + `atr_value` 快照,避免插针和重算偏差。
## 6. 执行引擎改造
1. **TP 优先 Maker + Taker 兜底**:入场后预挂 TP1/TP2 限价单;若价格已越过 TP 触发价且挂单在超时窗口(如 2 秒)内仍未成交,立即撤单并用 Taker 市价平仓兜底。
2. **部分成交分支**:兜底前查询成交量,仅对剩余仓位执行 `Cancel -> Taker Close`
3. **Break-Even 费用感知**BE 触发价需覆盖手续费与滑点缓冲,避免"名义保本、账户实亏"。
4. **Flip 双门槛**:开仓阈值 `75`,翻转阈值 `85`
5. **并发和幂等**`Cancel -> Market` 需要状态锁和幂等键,防止重复平仓。
6. **执行质量指标化**:持续监控 `maker_ratio / avg_friction_cost_r / flip_loss_r`
## 7. 反过拟合协议(强制)
1. **Walk-Forward Optimization**:训练窗与验证窗严格时间隔离。
2. **参数冻结**:一个评估周期内禁止改权重、阈值。
3. **特征预算**:样本不足时严格限制特征数量,新增特征先 shadow 记录。
4. **升级门槛**:样本外结果不达标不得进入下一阶段。
5. **可解释性检查**:无金融逻辑支撑的"高胜率规则"禁止上线。
## 8. 模型权重训练与更新机制(新增)
## 8.1 参数分层
- `static_params`:交易风控硬约束(如最大仓位、最大回撤阈值)
- `tunable_params`可训练参数ALT 子特征权重、阈值、BTC 门控阈值)
- `release_params`:版本发布参数(`strategy_version`, `config_hash`
## 8.2 ALT 权重训练流程
1. 用 `signal_feature_events + signal_label_events` 生成训练集。
2. 先做单变量稳定性审计IC、分箱胜率、PSI
3. 再做有约束优化:
- 权重非负
- 总和固定为 100
- 单层权重变化设上限(如不超过上版的 30%
4. 在 OOS 上评估,未达标不发布。
## 8.3 BTC 阈值训练流程
1. 针对每个门控特征做阈值网格搜索。
2. 以 OOS `net_r + drawdown` 共同评分。
3. 选择 Pareto 最优点,不追单一胜率最优。
## 8.4 参数更新节奏
- 建议频率:每 1-2 周滚动一次。
- 每次仅允许小步更新,避免参数跳变。
- 每次更新必须附带变更记录:`old -> new`、样本窗口、验证结果。
## 9. 发布与回滚机制
- 每次策略升级必须生成新 `strategy_version``config_hash`
- 发版前必须附带:训练窗结果 + 验证窗OOS结果 + 执行成本变化。
- 任一核心指标触发阈值告警如净R断崖、回撤超限立即回滚到上一稳定版本。
## 10. 版本验收标准V5.3
- ALT 轨样本外连续两个窗口净R为正。
- BTC 轨样本外净R非负且胜率不低于随机基线。
- 执行层:`maker_ratio >= 40%`,且 `avg_friction_cost_r`(滑点+手续费)较 V5.1 基线下降 >= 30%。
- 稳定性:最大回撤不显著劣化。
## 11. 里程碑
- M1完成三张新表与事件落库。
- M2完成 ALT/BTC 路由与首版决策逻辑。
- M3完成执行成本改造maker/BE/flip
- M4跑通首轮 Walk-Forward 并产出 V5.3 首次评估报告。

View File

@ -0,0 +1,108 @@
---
title: V5.3 实施清单
status: draft
updated: 2026-03-03
---
# V5.3 实施清单
## Phase 0 - 数据与追溯基建P0
- [ ] 新增 `signal_feature_events` 表(含索引)
- [ ] 新增 `signal_label_events` 表(含索引)
- [ ] 新增 `execution_cost_events` 表(含索引)
- [ ] `signal_feature_events` 增加 `atr_value` 字段(信号触发时 ATR 绝对值快照)
- [ ] 在信号评估循环中落库 feature snapshot每次评估都写
- [ ] 打通统一追溯字段:`strategy_version/config_hash/engine_instance`
- [ ] 标签回填任务上线15m/30m/60m
- [ ] 标签回填强制使用 Mark Price并按时间顺序判定先触发条件
- [ ] 标签计算强制使用快照 `atr_value`(禁止回填时重算 ATR
## Phase 1 - 决策引擎重构P0
- [ ] 实现 ALT/BTC 路由器(按 symbol 分流)
- [ ] ALT 轨移除独立 confirmation 层,改为四层结构
- [ ] ALT 轨实现权重分配:`55/25/15/5`
- [ ] ALT 轨实现阈值:`open=75`, `flip=85`
- [ ] BTC 轨首版特征:`tiered_cvd_whale`, `obi_depth_10`, `spot_perp_divergence`, `atr_percent_1h`
- [ ] BTC 轨门控逻辑上线(含 veto 条件)
- [ ] BTC 轨缺失特征默认 `BLOCK_SIGNAL`,并写入 `block_reason`
- [ ] BTC 轨阈值配置化:`min_vol_threshold`, `obi_veto_threshold`, `whale_flow_threshold`
## Phase 2 - 执行层与摩擦成本优化P0
- [ ] 执行层支持 TP 预挂单maker 优先)
- [ ] 增加 TP 未成交兜底:越价+超时后撤 maker 改 taker 强平
- [ ] 增加“部分成交”分支:仅对剩余仓位执行兜底
- [ ] Break-Even 改为费用感知(含手续费+滑点缓冲)
- [ ] `Cancel -> Market` 增加状态锁与幂等键
- [ ] 新增执行成本统计任务fee/slippage/maker_ratio/friction_cost_r
## Phase 3 - 评估与发布闸门P1
- [ ] 新增按 `config_hash` 分组报表接口
- [ ] 新增按 `track` 分组报表接口ALT/BTC 分开看)
- [ ] 建立 Walk-Forward 评估脚本(训练窗+验证窗)
- [ ] 产出首版 V5.3 OOS 报告模板
- [ ] 新增参数变更记录模板(`old -> new` + 样本窗口 + OOS结果
## Phase 4 - 持续优化P2
- [ ] 特征 shadow 机制(新因子先记录不参与决策)
- [ ] 自动化回滚钩子核心KPI超阈值触发
- [ ] 分层 CVD 桶参数自动校准
- [ ] OBI 深度档位自适应5档/10档切换
- [ ] 评估 XGBoost/LightGBM 离线实验管道
## 数据库建议(草案)
## `signal_feature_events`
- 主键:`event_id`
- 必要索引:
- `(ts)`
- `(symbol, ts DESC)`
- `(track, ts DESC)`
- `(strategy_version, config_hash, ts DESC)`
## `signal_label_events`
- 主键:`event_id`
- 必要索引:
- `(y_binary_60m, ts)`
- `(symbol, ts DESC)`
## `execution_cost_events`
- 主键:`trade_id`
- 必要索引:
- `(ts)`
- `(symbol, ts DESC)`
- `(entry_type, exit_type, ts DESC)`
## 上线前验证清单
- [ ] feature 事件写入无丢失,延迟可接受
- [ ] label 回填任务无时间错位
- [ ] ALT/BTC 路由正确BTC 不落入 ALT
- [ ] BTC 缺失特征不会静默放行
- [ ] maker 优先在真实成交中可观测
- [ ] TP 兜底分支含部分成交路径可复现
- [ ] BE 逻辑覆盖成本后,不再出现“保本但净亏”异常
- [ ] flip 频次和 flip 损耗下降
- [ ] OOS 报告通过预设阈值
## 发布闸门(量化指标)
- [ ] ALT连续两个 OOS 窗口净R > 0
- [ ] BTCOOS 净R >= 0 且胜率 >= 随机基线
- [ ] `maker_ratio >= 40%`
- [ ] `avg_friction_cost_r`(滑点+手续费)较 V5.1 下降 >= 30%
- [ ] 最大回撤不高于风险红线
## 任务分配建议
- 后端核心:`signal_engine.py`, `paper_monitor.py`, `main.py`, `db.py`
- 数据任务:新增 migration + 回填 job
- 评估任务:新增 `scripts/train_eval_walkforward.py`
- 文档任务:每次发版补充 `strategy_version` 变更记录

View File

@ -0,0 +1,265 @@
# V5.4 Strategy Factory 需求文档
**版本**v1.0
**日期**2026-03-11
**作者**:露露
**状态**:待范总 + 小范 Review
---
## 1. 背景与目标
当前系统V5.3)使用单体 `signal_engine.py`,所有策略逻辑耦合在一起,存在以下问题:
- 修改任意策略参数需重启整个引擎,中断数据采集
- 不同策略无法独立运行和对比A/B 测试成本高
- 参数配置分散在 JSON 文件中,无法通过前端界面管理
- 无法按币种独立优化权重
V5.4 目标:构建 **Strategy Factory策略工厂**,将信号引擎解耦为数据总线 + 独立策略 Worker支持前端可视化管理策略生命周期和参数配置。
---
## 2. 核心架构
### 2.1 整体架构
```
Signal Engine数据总线
├── 采集原始数据aggTrades / OBI / 清算 / 市场数据)
├── 计算基础 FeatureCVD / ATR / VWAP / whale flow / OBI / spot-perp div
└── 广播 feature_event每15秒一次
Strategy Workers策略工厂
├── Worker-1我的BTC策略01BTCUSDTasyncio协程
├── Worker-2我的ETH策略01ETHUSDTasyncio协程
├── Worker-N...
└── 每个 Worker 订阅 feature_event独立打分、开仓、管仓
```
### 2.2 关键设计原则
- **同一进程 + asyncio 协程**:所有 Worker 共享同一 Python 进程feature_event 内存传递,省资源
- **独立资金池**:每个 Worker 有独立的 paper trading 余额,互不影响
- **15秒内热生效**前端修改参数后Worker 在下一个评估周期≤15秒自动从 DB 读取新参数
- **配置存 DB**所有策略配置存入数据库JSON 文件废弃
- **直接切换**V5.4 上线后直接替换 V5.3 单体,不并行
---
## 3. 策略生命周期
### 3.1 状态定义
```
created已创建→ running运行中→ paused已暂停→ running
deprecated已废弃→ running重新启用
```
- **只有「废弃」,没有「删除」**
- 废弃的策略数据永久保留,可在废弃列表中检索
- 废弃策略可重新启用,继续使用原有余额和历史数据
### 3.2 策略标识
- 用户填写**显示名称**(自由命名,如"我的BTC激进策略"
- 后台自动生成**UUID**作为唯一标识,用户不感知
- `paper_trades` 等表通过 strategy_idUUID关联
### 3.3 余额管理
- 创建时设置初始资金(默认 10,000 USDT
- 支持**追加余额**:追加后,`initial_balance` 同步增加,`current_balance` 同步增加
- 废弃后重新启用,继续使用废弃时的余额和历史数据
---
## 4. 策略配置参数
每个策略实例(每个币种独立)包含以下可配置参数,均存入数据库,前端可编辑。
### 4.1 基础信息
| 参数 | 说明 | 类型 | 默认值 | 范围 |
|------|------|------|--------|------|
| display_name | 策略显示名称 | string | — | 1-50字符 |
| symbol | 交易对 | enum | BTCUSDT | BTCUSDT / ETHUSDT / SOLUSDT / XRPUSDT |
| direction | 交易方向 | enum | both | long_only / short_only / both |
| initial_balance | 初始资金(USDT) | float | 10000 | 1000-1000000 |
### 4.2 CVD 窗口配置
| 参数 | 说明 | 类型 | 默认值 | 可选项 |
|------|------|------|--------|--------|
| cvd_fast_window | 快线CVD窗口 | enum | 30m | 5m / 15m / 30m |
| cvd_slow_window | 慢线CVD窗口 | enum | 4h | 1h / 4h |
### 4.3 四层权重(合计必须 = 100
| 参数 | 说明 | 默认值 | 范围 |
|------|------|--------|------|
| weight_direction | 方向得分权重 | 55 | 10-80 |
| weight_env | 环境得分权重 | 25 | 5-60 |
| weight_aux | 辅助因子权重 | 15 | 0-40 |
| weight_momentum | 动量权重 | 5 | 0-20 |
> 前端校验:四项之和必须 = 100否则不允许保存
### 4.4 入场阈值
| 参数 | 说明 | 默认值 | 范围 |
|------|------|--------|------|
| entry_score | 入场最低总分 | 75 | 60-95 |
### 4.5 四道 Gate过滤门
每道 Gate 有独立开关和阈值:
| Gate | 说明 | 开关默认 | 阈值参数 | 默认值 | 范围 |
|------|------|----------|----------|--------|------|
| gate_obi | 订单簿失衡门 | ON | obi_threshold | 0.3 | 0.1-0.9 |
| gate_whale_cvd | 大单CVD门 | ON | whale_cvd_threshold | 0.0 | -1.0-1.0 |
| gate_vol_atr | 波动率ATR门 | ON | atr_percentile_min | 20 | 5-80 |
| gate_spot_perp | 现货/永续溢价门 | OFF | spot_perp_threshold | 0.002 | 0.0005-0.01 |
### 4.6 风控参数
| 参数 | 说明 | 默认值 | 范围 |
|------|------|--------|------|
| sl_atr_multiplier | SL宽度×ATR | 1.5 | 0.5-3.0 |
| tp1_ratio | TP1×RD | 0.75 | 0.3-2.0 |
| tp2_ratio | TP2×RD | 1.5 | 0.5-4.0 |
| timeout_minutes | 持仓超时(分钟) | 240 | 30-1440 |
| flip_threshold | 反转平仓阈值(分) | 80 | 60-95 |
---
## 5. 前端功能需求
### 5.1 策略广场列表页(已有基础,新增以下)
- **右上角「+ 新增策略」按钮**:点击进入参数配置创建界面
- **每个卡片新增「调整参数」按钮**:点击进入参数配置编辑界面(预填当前参数)
- **每个卡片新增「废弃」按钮**:点击弹出二次确认,确认后策略进入废弃状态
- **每个卡片新增「追加余额」功能**:输入追加金额,确认后更新余额
### 5.2 参数配置界面(新建/编辑共用)
- 基础信息:名称、币种、交易方向、初始资金
- CVD窗口快线/慢线各独立选择
- 四层权重滑块或数字输入实时显示合计合计≠100时禁止保存
- 四道Gate开关 + 阈值输入,各有说明文字和合理范围提示
- 风控参数:数字输入,有最小/最大值限制
- 底部:「保存并启动」(新建)/ 「保存」(编辑)按钮
### 5.3 策略详情页(已有基础,新增以下)
- 新增第三个 Tab**「参数配置」**
- 展示当前所有参数,可点击编辑跳转到编辑界面
### 5.4 侧边栏新增入口
- **「废弃策略」**:点击进入废弃策略列表
- 展示格式与正常卡片相同,额外显示废弃时间
- 每个废弃策略有「重新启用」按钮
- 重新启用后恢复至运行中状态,继续原余额和数据
### 5.5 权限
- 所有页面需登录才能访问(复用现有 JWT
- 登录后有全部操作权限,无角色区分
---
## 6. 后端架构需求
### 6.1 数据库新增表
**`strategies` 表**(策略配置主表):
```sql
strategy_id UUID PRIMARY KEY
display_name TEXT NOT NULL
symbol TEXT NOT NULL
direction TEXT NOT NULL DEFAULT 'both'
status TEXT NOT NULL DEFAULT 'running' -- running/paused/deprecated
initial_balance FLOAT NOT NULL DEFAULT 10000
current_balance FLOAT NOT NULL DEFAULT 10000
cvd_fast_window TEXT NOT NULL DEFAULT '30m'
cvd_slow_window TEXT NOT NULL DEFAULT '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
deprecated_at TIMESTAMP
created_at TIMESTAMP DEFAULT NOW()
updated_at TIMESTAMP DEFAULT NOW()
```
### 6.2 现有表调整
- `paper_trades``strategy` 字段改为存 `strategy_id`UUID兼容现有数据v53/v53_middle/v53_fast 保留字符串形式)
- `signal_indicators`:同上
### 6.3 API 新增端点
```
POST /api/strategies 创建策略
GET /api/strategies 获取所有策略列表(含废弃)
GET /api/strategies/{id} 获取单个策略详情
PATCH /api/strategies/{id} 更新策略参数
POST /api/strategies/{id}/pause 暂停策略
POST /api/strategies/{id}/resume 恢复策略
POST /api/strategies/{id}/deprecate 废弃策略
POST /api/strategies/{id}/restore 重新启用
POST /api/strategies/{id}/add-balance 追加余额
```
### 6.4 Signal Engine 改造
- Signal Engine 只负责 feature 计算和广播,不再包含评分/开仓逻辑
- 每个 Strategy Worker 作为 asyncio 协程,订阅 feature_event
- Worker 启动时从 DB 读取配置每15秒评估时重新读取捕获参数变更
---
## 7. 迁移计划
V5.4 上线时:
1. 将现有 v53、v53_middle、v53_fast 三个策略迁移为 `strategies` 表中的三条记录
2. 历史 `paper_trades` 数据通过 strategy 名称映射到对应 strategy_id
3. 直接切换,不保留 V5.3 单体并行
---
## 8. 不在本期范围内
- 实盘交易(本期只做 paper trading
- 多用户/多账户体系
- 策略算法类型选择(本期只支持四层评分算法)
- 自动化参数优化Optuna 集成)
---
## 9. Review 检查清单
- [ ] 范总确认需求无遗漏
- [ ] 小范审阅数据结构合理性
- [ ] 确认 `strategies` 表字段完整性
- [ ] 确认 API 端点覆盖所有前端操作
- [ ] 确认迁移方案不丢失历史数据
- [ ] 需求文档 Review 通过后,再开始写数据合约文档

View File

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

View File

@ -1,653 +0,0 @@
"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 AllSignalRow {
ts: number;
score: number;
signal: string | null;
price?: number;
// factors 结构与 LatestIndicator.factors 基本一致,兼容 string/json
factors?: LatestIndicator["factors"] | string | null;
}
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 AllSignalsModal({
open,
onClose,
symbol,
strategyName,
}: {
open: boolean;
onClose: () => void;
symbol: string;
strategyName: string;
}) {
const [rows, setRows] = useState<AllSignalRow[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
const fetchAll = async () => {
setLoading(true);
setError(null);
try {
const res = await authFetch(
`/api/signals/history?symbol=${symbol}&limit=200&strategy=${strategyName}`
);
if (!res.ok) {
setError(`加载失败 (${res.status})`);
setRows([]);
return;
}
const json = await res.json();
setRows(json.items || []);
} catch (e) {
console.error(e);
setError("加载失败,请稍后重试");
setRows([]);
} finally {
setLoading(false);
}
};
fetchAll();
}, [open, symbol, strategyName]);
const parseFactors = (r: AllSignalRow): LatestIndicator["factors"] | null => {
const f = r.factors;
if (!f) return null;
if (typeof f === "string") {
try {
return JSON.parse(f);
} catch {
return null;
}
}
return f;
};
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl shadow-2xl w-full max-w-3xl max-h-[80vh] flex flex-col border border-slate-200">
<div className="px-4 py-3 border-b border-slate-100 flex items-center justify-between gap-2">
<div>
<h3 className="text-sm font-semibold text-slate-900">
</h3>
<p className="text-[11px] text-slate-500">
200 · {symbol} · {strategyName}
</p>
</div>
<button
onClick={onClose}
className="px-2 py-1 rounded-lg border border-slate-200 text-[11px] text-slate-600 hover:bg-slate-50"
>
</button>
</div>
<div className="flex-1 overflow-y-auto">
{loading ? (
<div className="py-8 text-center text-slate-400 text-sm">
...
</div>
) : error ? (
<div className="py-8 text-center text-red-500 text-sm">
{error}
</div>
) : rows.length === 0 ? (
<div className="py-8 text-center text-slate-400 text-sm">
</div>
) : (
<table className="w-full text-[11px] text-left">
<thead className="bg-slate-50 border-b border-slate-200 sticky top-0">
<tr>
<th className="px-3 py-2 text-slate-500 font-medium"></th>
<th className="px-3 py-2 text-slate-500 font-medium"></th>
<th className="px-3 py-2 text-slate-500 font-medium"></th>
<th className="px-3 py-2 text-slate-500 font-medium">
</th>
<th className="px-3 py-2 text-slate-500 font-medium"></th>
</tr>
</thead>
<tbody>
{rows.map((r, idx) => {
const f = parseFactors(r);
const dirScore = f?.direction?.score ?? 0;
const envScore = f?.environment?.score ?? 0;
const auxScore = f?.auxiliary?.score ?? 0;
const momScore = f?.crowding?.score ?? 0;
const gateBlock =
(f?.gate_block as string | undefined) ||
(f?.block_reason as string | undefined) ||
"";
const gatePassed =
typeof f?.gate_passed === "boolean"
? f?.gate_passed
: !gateBlock;
return (
<tr
key={`${r.ts}-${idx}`}
className="border-b border-slate-100 last:border-b-0 hover:bg-slate-50/60"
>
<td className="px-3 py-1.5 text-slate-500">
{bjtFull(r.ts)}
</td>
<td className="px-3 py-1.5">
<span
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full border ${
r.signal === "LONG"
? "border-emerald-300 bg-emerald-50 text-emerald-600"
: r.signal === "SHORT"
? "border-red-300 bg-red-50 text-red-500"
: "border-slate-200 bg-slate-50 text-slate-400"
}`}
>
<span className="text-[10px]">
{r.signal === "LONG"
? "多"
: r.signal === "SHORT"
? "空"
: "无"}
</span>
</span>
</td>
<td className="px-3 py-1.5 font-mono text-slate-800">
{r.score}
</td>
<td className="px-3 py-1.5">
<div className="flex flex-wrap gap-1 text-[10px] text-slate-500">
<span>:{dirScore}</span>
<span>:{envScore}</span>
<span>:{auxScore}</span>
<span>:{momScore}</span>
</div>
</td>
<td className="px-3 py-1.5">
{gatePassed ? (
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full bg-emerald-50 text-emerald-600 border border-emerald-200 text-[10px]">
</span>
) : (
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full bg-red-50 text-red-500 border border-red-200 text-[10px]">
{gateBlock ? ` · ${gateBlock}` : ""}
</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</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)}`;
const [showAllSignals, setShowAllSignals] = useState(false);
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>
<div className="flex items-center gap-2">
<button
onClick={() => setShowAllSignals(true)}
className="px-2 py-0.5 rounded-lg border border-slate-200 text-[10px] text-slate-600 hover:bg-slate-50"
>
</button>
<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>
</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>
<AllSignalsModal
open={showAllSignals}
onClose={() => setShowAllSignals(false)}
symbol={symbol}
strategyName={strategyName}
/>
</div>
);
}

View File

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

View File

@ -11,7 +11,6 @@ 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 ───────────────────
@ -21,20 +20,10 @@ 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 {
strategy_id?: string; id: string;
id?: string;
display_name: string; display_name: string;
status: string; status: string;
started_at: number; started_at: number;
@ -50,42 +39,7 @@ 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 ──────────────────────────────────────────────────────
@ -100,127 +54,24 @@ 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-600 font-medium"><CheckCircle size={12} /></span>; if (status === "running") return <span className="flex items-center gap-1 text-xs text-emerald-400"><CheckCircle 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>; if (status === "paused") return <span className="flex items-center gap-1 text-xs text-yellow-400"><PauseCircle size={12} /></span>;
return <span className="flex items-center gap-1 text-xs text-red-500 font-medium"><AlertCircle size={12} /></span>; return <span className="flex items-center gap-1 text-xs text-red-400"><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, symbol, detail }: { strategyId: string; symbol?: string; detail?: StrategyDetail | null }) { function SignalsContent({ strategyId }: { strategyId: string }) {
const legacy = UUID_TO_LEGACY[strategyId] || strategyId; if (strategyId === "v53") return <SignalsV53 />;
if (legacy === "v53") return <SignalsV53 />; if (strategyId === "v53_fast") return <SignalsV53Fast />;
if (legacy === "v53_fast") return <SignalsV53Fast />; if (strategyId === "v53_middle") return <SignalsV53Middle />;
if (legacy === "v53_middle") return <SignalsV53Middle />; return <div className="p-8 text-gray-400">: {strategyId}</div>;
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, symbol }: { strategyId: string; symbol?: string }) { function PaperContent({ strategyId }: { strategyId: string }) {
const legacy = UUID_TO_LEGACY[strategyId] || strategyId; if (strategyId === "v53") return <PaperV53 />;
if (legacy === "v53") return <PaperV53 />; if (strategyId === "v53_fast") return <PaperV53Fast />;
if (legacy === "v53_fast") return <PaperV53Fast />; if (strategyId === "v53_middle") return <PaperV53Middle />;
if (legacy === "v53_middle") return <PaperV53Middle />; return <div className="p-8 text-gray-400">: {strategyId}</div>;
return <PaperGeneric strategyId={strategyId} symbol={symbol || "BTCUSDT"} />;
} }
// ─── Main Page ──────────────────────────────────────────────────── // ─── Main Page ────────────────────────────────────────────────────
@ -232,67 +83,9 @@ 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 fetchData = useCallback(async () => { const fetchSummary = 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,
symbol: s.symbol,
});
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) {
@ -304,10 +97,10 @@ export default function StrategyDetailPage() {
}, [strategyId]); }, [strategyId]);
useEffect(() => { useEffect(() => {
fetchData().finally(() => setLoading(false)); fetchSummary();
const iv = setInterval(fetchData, 30000); const iv = setInterval(fetchSummary, 30000);
return () => clearInterval(iv); return () => clearInterval(iv);
}, [fetchData]); }, [fetchSummary]);
if (loading) { if (loading) {
return ( return (
@ -318,20 +111,17 @@ 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-slate-500 hover:text-slate-800 text-sm transition-colors"> <Link href="/strategy-plaza" className="flex items-center gap-1 text-gray-400 hover:text-white text-sm transition-colors">
<ArrowLeft size={16} /> <ArrowLeft size={16} />
广 广
</Link> </Link>
<span className="text-slate-300">/</span> <span className="text-gray-600">/</span>
<span className="text-slate-800 font-medium">{summary?.display_name ?? strategyId}</span> <span className="text-white font-medium">{summary?.display_name ?? strategyId}</span>
</div> </div>
{/* Summary Bar */} {/* Summary Bar */}
@ -341,8 +131,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>
{cvdLabel && ( {summary.cvd_windows && (
<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="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="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>
@ -358,7 +148,6 @@ 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}
@ -374,13 +163,12 @@ export default function StrategyDetailPage() {
))} ))}
</div> </div>
{/* Content */} {/* Content — direct render of existing pages */}
<div> <div>
{tab === "signals" && <SignalsContent strategyId={strategyId} symbol={summary?.symbol} detail={detail} />} {tab === "signals" ? (
{tab === "paper" && <PaperContent strategyId={strategyId} symbol={summary?.symbol} />} <SignalsContent strategyId={strategyId} />
{tab === "config" && detail && <ConfigTab detail={detail} strategyId={strategyId} />} ) : (
{tab === "config" && !detail && ( <PaperContent strategyId={strategyId} />
<div className="text-center text-slate-400 text-sm py-16"></div>
)} )}
</div> </div>
</div> </div>

View File

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

View File

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

View File

@ -4,7 +4,6 @@ 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,
@ -13,16 +12,11 @@ import {
AlertCircle, AlertCircle,
CheckCircle, CheckCircle,
PauseCircle, PauseCircle,
Plus,
Settings,
Trash2,
PlusCircle,
} from "lucide-react"; } from "lucide-react";
interface StrategyCard { interface StrategyCard {
strategy_id: string; 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;
@ -88,250 +82,128 @@ function StatusBadge({ status }: { status: string }) {
); );
} }
// ── AddBalanceModal ──────────────────────────────────────────────────────────── function StrategyCardComponent({ s }: { s: StrategyCard }) {
function AddBalanceModal({
strategy,
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div className="bg-white rounded-xl shadow-xl p-5 w-80">
<h3 className="font-semibold text-slate-800 text-sm mb-1"></h3>
<p className="text-[11px] text-slate-500 mb-3">{strategy.display_name}</p>
<div className="mb-3">
<label className="text-xs text-slate-600 mb-1 block"> (USDT)</label>
<input
type="number"
value={amount}
min={100}
step={100}
onChange={(e) => setAmount(parseFloat(e.target.value) || 0)}
className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm"
/>
<p className="text-[10px] text-slate-400 mt-1">
{(strategy.initial_balance + amount).toLocaleString()} USDT /
{(strategy.current_balance + amount).toLocaleString()} USDT
</p>
</div>
{error && <p className="text-xs text-red-500 mb-2">{error}</p>}
<div className="flex gap-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>
<button
onClick={handleSubmit}
disabled={submitting}
className="flex-1 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
>
{submitting ? "追加中..." : "确认追加"}
</button>
</div>
</div>
</div>
);
}
// ── StrategyCardComponent ──────────────────────────────────────────────────────
function StrategyCardComponent({
s,
onDeprecate,
onAddBalance,
}: {
s: StrategyCard;
onDeprecate: (s: StrategyCard) => void;
onAddBalance: (s: StrategyCard) => void;
}) {
const isProfit = s.net_usdt >= 0; const isProfit = s.net_usdt >= 0;
const is24hProfit = s.pnl_usdt_24h >= 0; const is24hProfit = s.pnl_usdt_24h >= 0;
const balancePct = ((s.current_balance / s.initial_balance) * 100).toFixed(1); const balancePct = ((s.current_balance / s.initial_balance) * 100).toFixed(1);
const symbolShort = s.symbol?.replace("USDT", "") || "";
return ( return (
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden hover:border-blue-300 hover:shadow-md transition-all group"> <Link href={`/strategy-plaza/${s.id}`}>
{/* Header */} <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="px-4 py-3 border-b border-slate-100 flex items-center justify-between"> {/* Header */}
<div className="flex items-center gap-2"> <div className="px-4 py-3 border-b border-slate-100 flex items-center justify-between">
<Link href={`/strategy-plaza/${s.strategy_id}`}> <div className="flex items-center gap-2">
<h3 className="font-semibold text-slate-800 text-sm group-hover:text-blue-700 transition-colors cursor-pointer hover:underline"> <h3 className="font-semibold text-slate-800 text-sm group-hover:text-blue-700 transition-colors">
{s.display_name} {s.display_name}
</h3> </h3>
</Link> <StatusBadge status={s.status} />
<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>
<div className="text-right"> <span className="text-[10px] text-slate-400 flex items-center gap-1">
<div className="text-[10px] text-slate-400 mb-0.5"></div> <Clock size={9} />
<div className={`text-lg font-bold ${isProfit ? "text-emerald-600" : "text-red-500"}`}> {formatDuration(s.started_at)}
{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> </span>
</div> </div>
<div className="flex items-center gap-1 text-[10px] text-slate-400">
<Activity size={9} /> {/* Main PnL */}
{s.open_positions > 0 ? ( <div className="px-4 pt-3 pb-2">
<span className="text-amber-600 font-medium">{s.open_positions}</span> <div className="flex items-end justify-between mb-2">
) : ( <div>
<span>: {formatTime(s.last_trade_at)}</span> <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> </div>
</div> </div>
</Link>
{/* 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);
type SymbolFilter = "ALL" | "BTCUSDT" | "ETHUSDT" | "XRPUSDT" | "SOLUSDT";
type StatusFilter = "all" | "running" | "paused" | "error";
type PnlFilter = "all" | "positive" | "negative";
type PositionFilter = "all" | "with_open" | "no_open";
type SortKey = "recent" | "net_usdt_desc" | "net_usdt_asc" | "pnl24h_desc" | "pnl24h_asc";
const [symbolFilter, setSymbolFilter] = useState<SymbolFilter>("ALL");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [pnlFilter, setPnlFilter] = useState<PnlFilter>("all");
const [positionFilter, setPositionFilter] = useState<PositionFilter>("all");
const [sortKey, setSortKey] = useState<SortKey>("net_usdt_desc");
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
try { try {
const res = await authFetch("/api/strategies"); const res = await authFetch("/api/strategy-plaza");
const data = await res.json(); const data = await res.json();
setStrategies(data.strategies || []); setStrategies(data.strategies || []);
setLastUpdated(new Date()); setLastUpdated(new Date());
@ -348,49 +220,6 @@ 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);
}
};
const filteredStrategies = strategies
.filter((s) => {
if (symbolFilter !== "ALL" && s.symbol !== symbolFilter) return false;
if (statusFilter !== "all" && s.status !== statusFilter) return false;
if (pnlFilter === "positive" && s.net_usdt <= 0) return false;
if (pnlFilter === "negative" && s.net_usdt >= 0) return false;
if (positionFilter === "with_open" && s.open_positions <= 0) return false;
if (positionFilter === "no_open" && s.open_positions > 0) return false;
return true;
})
.sort((a, b) => {
switch (sortKey) {
case "net_usdt_desc":
return b.net_usdt - a.net_usdt;
case "net_usdt_asc":
return a.net_usdt - b.net_usdt;
case "pnl24h_desc":
return b.pnl_usdt_24h - a.pnl_usdt_24h;
case "pnl24h_asc":
return a.pnl_usdt_24h - b.pnl_usdt_24h;
case "recent":
default: {
const aTs = a.last_trade_at ?? a.started_at ?? 0;
const bTs = b.last_trade_at ?? b.started_at ?? 0;
return bTs - aTs;
}
}
});
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">
@ -405,172 +234,25 @@ 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>
<div className="flex items-center gap-3"> {lastUpdated && (
{lastUpdated && ( <div className="text-[10px] text-slate-400 flex items-center gap-1">
<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" />
<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 })}
{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>
{/* Filters & Sorting */}
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
{/* 左侧:币种 + 盈亏过滤 */}
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1 text-[11px] text-slate-400">
<span>:</span>
{(["ALL", "BTCUSDT", "ETHUSDT", "XRPUSDT", "SOLUSDT"] as SymbolFilter[]).map((sym) => {
const label = sym === "ALL" ? "全部" : sym.replace("USDT", "");
const active = symbolFilter === sym;
return (
<button
key={sym}
onClick={() => setSymbolFilter(sym)}
className={`px-2 py-0.5 rounded-full border text-[11px] ${
active
? "border-blue-500 bg-blue-50 text-blue-600"
: "border-slate-200 text-slate-500 hover:bg-slate-50"
}`}
>
{label}
</button>
);
})}
</div> </div>
<div className="flex items-center gap-1 text-[11px] text-slate-400"> )}
<span>:</span>
{[
{ key: "all" as PnlFilter, label: "全部" },
{ key: "positive" as PnlFilter, label: "仅盈利" },
{ key: "negative" as PnlFilter, label: "仅亏损" },
].map((opt) => {
const active = pnlFilter === opt.key;
return (
<button
key={opt.key}
onClick={() => setPnlFilter(opt.key)}
className={`px-2 py-0.5 rounded-full border text-[11px] ${
active
? "border-emerald-500 bg-emerald-50 text-emerald-600"
: "border-slate-200 text-slate-500 hover:bg-slate-50"
}`}
>
{opt.label}
</button>
);
})}
</div>
</div>
{/* 右侧:状态 + 持仓 + 排序 */}
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1 text-[11px] text-slate-400">
<span>:</span>
{[
{ key: "all" as StatusFilter, label: "全部" },
{ key: "running" as StatusFilter, label: "运行中" },
{ key: "paused" as StatusFilter, label: "已暂停" },
{ key: "error" as StatusFilter, label: "异常" },
].map((opt) => {
const active = statusFilter === opt.key;
return (
<button
key={opt.key}
onClick={() => setStatusFilter(opt.key)}
className={`px-2 py-0.5 rounded-full border text-[11px] ${
active
? "border-slate-700 bg-slate-800 text-white"
: "border-slate-200 text-slate-500 hover:bg-slate-50"
}`}
>
{opt.label}
</button>
);
})}
</div>
<div className="flex items-center gap-1 text-[11px] text-slate-400">
<span>:</span>
{[
{ key: "all" as PositionFilter, label: "全部" },
{ key: "with_open" as PositionFilter, label: "有持仓" },
{ key: "no_open" as PositionFilter, label: "无持仓" },
].map((opt) => {
const active = positionFilter === opt.key;
return (
<button
key={opt.key}
onClick={() => setPositionFilter(opt.key)}
className={`px-2 py-0.5 rounded-full border text-[11px] ${
active
? "border-amber-500 bg-amber-50 text-amber-600"
: "border-slate-200 text-slate-500 hover:bg-slate-50"
}`}
>
{opt.label}
</button>
);
})}
</div>
<div className="flex items-center gap-1 text-[11px] text-slate-400">
<span>:</span>
<select
value={sortKey}
onChange={(e) => setSortKey(e.target.value as SortKey)}
className="border border-slate-200 rounded-lg px-2 py-1 text-[11px] text-slate-700 bg-white"
>
<option value="net_usdt_desc"></option>
<option value="net_usdt_asc"></option>
<option value="pnl24h_desc">24h </option>
<option value="pnl24h_asc">24h </option>
<option value="recent"></option>
</select>
</div>
</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">
{filteredStrategies.map((s) => ( {strategies.map((s) => (
<StrategyCardComponent <StrategyCardComponent key={s.id} s={s} />
key={s.strategy_id}
s={s}
onDeprecate={handleDeprecate}
onAddBalance={setAddBalanceTarget}
/>
))} ))}
</div> </div>
{filteredStrategies.length === 0 && ( {strategies.length === 0 && (
<div className="text-center text-slate-400 text-sm py-16"> <div className="text-center text-slate-400 text-sm py-16"></div>
<p className="mb-3"></p>
<button
onClick={() => router.push("/strategy-plaza/create")}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl bg-blue-600 text-white text-sm hover:bg-blue-700"
>
<Plus size={14} />
</button>
</div>
)}
{/* Add Balance Modal */}
{addBalanceTarget && (
<AddBalanceModal
strategy={addBalanceTarget}
onClose={() => setAddBalanceTarget(null)}
onSuccess={fetchData}
/>
)} )}
</div> </div>
); );

View File

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

View File

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

View File

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

View File

@ -1,244 +0,0 @@
#!/usr/bin/env python3
"""
bootstrap_eth_xrp_sol_strategies.py
基于 v5.3 Optuna 结果 + v53.json symbol_gates为策略工厂批量创建
ETH/XRP/SOL 54 条单币种策略配置
约定
- 仍沿用 BTC 工厂当前的 CVD 组合与 TP 档位
* CVD 窗口: (5m,30m), (5m,1h), (15m,1h), (15m,4h), (30m,1h), (30m,4h)
* TP 档位: 保守 / 平衡 / 激进sl=2ATRTP=0.75/1.0/1.5R 1.5/2.0/2.5R
- 每个 symbol 的门控阈值复刻 v53.json symbol_gates
* ETHUSDT: min_vol=0.003, whale_usd=50000, obi=0.35, spd=0.005
* XRPUSDT: min_vol=0.0025, whale_usd=30000, obi=0.40, spd=0.006
* SOLUSDT: min_vol=0.004, whale_usd=20000, obi=0.45, spd=0.008
- 五门开关与 BTC 工厂当前配置保持一致
* 波动率门 / CVD / 巨鲸门 / OBI 启用
* 期现门关闭仅写入阈值保留以后启用的空间
- 权重与开仓阈值取自 optuna_results_v3_cn.xlsx 中各 symbol v53/v53_fast Top1
* ETHUSDT (v53): dir=51, env=18, aux=28, mom=3, threshold=75
* XRPUSDT (v53): dir=58, env=8, aux=32, mom=2, threshold=80
* SOLUSDT (v53_fast): dir=38, env=42, aux=8, mom=12, threshold=65
注意
- 如果某个 display_name 已存在于 strategies 将跳过不会重复插入
- 连接参数走 backend.db.get_sync_conn()运行脚本时请设置
PG_HOST=127.0.0.1 PG_PORT=9470 PG_DB=arb_engine PG_USER=arb PG_PASS=...
或在服务器上直接使用 Cloud SQL 内网地址
"""
from __future__ import annotations
import os
import sys
from dataclasses import dataclass
# 确保可以从 backend 导入 db.get_sync_conn
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "backend"))
from db import get_sync_conn # type: ignore
@dataclass
class SymbolProfile:
symbol: str
weight_direction: int
weight_env: int
weight_aux: int
weight_momentum: int
entry_score: int
min_vol: float
whale_usd_threshold: float
whale_flow_pct: float
obi_threshold: float
spot_perp_threshold: float
# 基于 optuna_results_v3_cn.xlsx Top1 Summary + v53.json symbol_gates
SYMBOL_PROFILES: list[SymbolProfile] = [
SymbolProfile(
symbol="ETHUSDT",
weight_direction=51,
weight_env=18,
weight_aux=28,
weight_momentum=3,
entry_score=75,
min_vol=0.003,
whale_usd_threshold=50_000,
whale_flow_pct=0.5, # ALT 分支主要用 whale_usd_threshold此处保持默认
obi_threshold=0.35,
spot_perp_threshold=0.005,
),
SymbolProfile(
symbol="XRPUSDT",
weight_direction=58,
weight_env=8,
weight_aux=32,
weight_momentum=2,
entry_score=80,
min_vol=0.0025,
whale_usd_threshold=30_000,
whale_flow_pct=0.5,
obi_threshold=0.40,
spot_perp_threshold=0.006,
),
SymbolProfile(
symbol="SOLUSDT",
weight_direction=38,
weight_env=42,
weight_aux=8,
weight_momentum=12,
entry_score=65,
min_vol=0.004,
whale_usd_threshold=20_000,
whale_flow_pct=0.5,
obi_threshold=0.45,
spot_perp_threshold=0.008,
),
]
# 与 BTC 工厂一致的 CVD 组合
CVD_COMBOS: list[tuple[str, str]] = [
("5m", "30m"),
("5m", "1h"),
("15m", "1h"),
("15m", "4h"),
("30m", "1h"),
("30m", "4h"),
]
# TP 档位:保守 / 平衡 / 激进(统一 sl_multiplier=2.0
TP_PROFILES: dict[str, dict[str, float]] = {
"保守": {"sl_atr_multiplier": 2.0, "tp1_ratio": 0.75, "tp2_ratio": 1.5},
"平衡": {"sl_atr_multiplier": 2.0, "tp1_ratio": 1.0, "tp2_ratio": 2.0},
"激进": {"sl_atr_multiplier": 2.0, "tp1_ratio": 1.5, "tp2_ratio": 2.5},
}
def build_display_name(symbol: str, fast_win: str, slow_win: str, tp_label: str) -> str:
"""
生成与 BTC 工厂一致的 display_name例如
BTC_CVD5x30m_TP保守 ETH_CVD5x30m_TP保守
"""
base = symbol.replace("USDT", "")
fast_label = fast_win.replace("m", "") # "5m" → "5"
return f"{base}_CVD{fast_label}x{slow_win}_TP{tp_label}"
def main() -> None:
created = 0
skipped = 0
with get_sync_conn() as conn:
with conn.cursor() as cur:
for profile in SYMBOL_PROFILES:
sym = profile.symbol
for fast_win, slow_win in CVD_COMBOS:
for tp_label, tp_cfg in TP_PROFILES.items():
display_name = build_display_name(sym, fast_win, slow_win, tp_label)
# 避免重复插入:按 display_name 检查
cur.execute(
"SELECT 1 FROM strategies WHERE display_name=%s",
(display_name,),
)
if cur.fetchone():
skipped += 1
continue
cur.execute(
"""
INSERT INTO strategies (
display_name,
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
)
VALUES (
%s, -- display_name
%s, -- symbol
%s, -- direction
%s, -- cvd_fast_window
%s, -- cvd_slow_window
%s, -- weight_direction
%s, -- weight_env
%s, -- weight_aux
%s, -- weight_momentum
%s, -- entry_score
%s, -- gate_vol_enabled
%s, -- vol_atr_pct_min
%s, -- gate_cvd_enabled
%s, -- gate_whale_enabled
%s, -- whale_usd_threshold
%s, -- whale_flow_pct
%s, -- gate_obi_enabled
%s, -- obi_threshold
%s, -- gate_spot_perp_enabled
%s, -- spot_perp_threshold
%s, -- sl_atr_multiplier
%s, -- tp1_ratio
%s, -- tp2_ratio
%s, -- timeout_minutes
%s -- flip_threshold
)
""",
(
display_name,
sym,
"both", # 方向:多空双向
fast_win,
slow_win,
profile.weight_direction,
profile.weight_env,
profile.weight_aux,
profile.weight_momentum,
profile.entry_score,
True, # gate_vol_enabled
profile.min_vol,
True, # gate_cvd_enabled
True, # gate_whale_enabled
profile.whale_usd_threshold,
profile.whale_flow_pct,
True, # gate_obi_enabled
profile.obi_threshold,
False, # gate_spot_perp_enabled与当前 BTC 工厂一致,先关闭)
profile.spot_perp_threshold,
tp_cfg["sl_atr_multiplier"],
tp_cfg["tp1_ratio"],
tp_cfg["tp2_ratio"],
240, # timeout_minutes沿用 BTC 工厂
80, # flip_threshold沿用 v5.4 设计
),
)
created += 1
conn.commit()
print(f"[bootstrap] created={created}, skipped={skipped}")
if __name__ == "__main__":
main()

View File

@ -1,84 +0,0 @@
#!/usr/bin/env python3
"""
清理 signal_indicators strategy_id 对应 symbol 不一致的历史脏数据
默认 dry-run --apply 才会真正删除
"""
from __future__ import annotations
import argparse
import os
try:
import psycopg2
except Exception:
psycopg2 = None
def get_conn():
if psycopg2 is None:
raise RuntimeError("缺少 psycopg2 依赖请先安装pip install psycopg2-binary")
host = os.getenv("PG_HOST") or os.getenv("DB_HOST") or "127.0.0.1"
port = int(os.getenv("PG_PORT") or os.getenv("DB_PORT") or 5432)
dbname = os.getenv("PG_DB") or os.getenv("DB_NAME") or "arb_engine"
user = os.getenv("PG_USER") or os.getenv("DB_USER") or "arb"
password = os.getenv("PG_PASS") or os.getenv("DB_PASS") or "arb_engine_2026"
return psycopg2.connect(host=host, port=port, dbname=dbname, user=user, password=password)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--apply", action="store_true", help="执行删除")
args = parser.parse_args()
conn = get_conn()
conn.autocommit = False
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT
COUNT(*) AS mismatch_rows,
to_timestamp(min(si.ts)/1000.0) AS first_ts_utc,
to_timestamp(max(si.ts)/1000.0) AS last_ts_utc
FROM signal_indicators si
JOIN strategies s ON s.strategy_id = si.strategy_id
WHERE si.symbol <> s.symbol
"""
)
mismatch_rows, first_ts, last_ts = cur.fetchone()
print(f"mismatch_rows={mismatch_rows}, range={first_ts} -> {last_ts}")
if mismatch_rows == 0:
print("无需清理")
conn.rollback()
return 0
if not args.apply:
print("dry-run 模式,仅输出统计;加 --apply 执行删除")
conn.rollback()
return 0
cur.execute(
"""
DELETE FROM signal_indicators si
USING strategies s
WHERE si.strategy_id = s.strategy_id
AND si.symbol <> s.symbol
"""
)
print(f"deleted_rows={cur.rowcount}")
conn.commit()
return 0
except Exception:
conn.rollback()
raise
finally:
conn.close()
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -239,7 +239,7 @@ def replay_trade(cur, tid, symbol, direction, strategy, entry_ts, atr):
def main(dry_run=False): def main(dry_run=False):
conn = psycopg2.connect(host='127.0.0.1', dbname='arb_engine', user='arb', password='arb_engine_2026') conn = psycopg2.connect(host='10.106.0.3', dbname='arb_engine', user='arb', password='arb_engine_2026')
cur = conn.cursor() cur = conn.cursor()
cur.execute(""" cur.execute("""

196
signal-engine.log Normal file
View File

@ -0,0 +1,196 @@
2026-03-01 23:06:35,118 [INFO] signal-engine: 已加载策略配置: v51_baseline, v52_8signals
2026-03-01 23:06:37,990 [INFO] signal-engine: [BTCUSDT] 冷启动完成: 加载425,636条历史数据 (窗口=4h)
2026-03-01 23:06:41,185 [INFO] signal-engine: [ETHUSDT] 冷启动完成: 加载474,707条历史数据 (窗口=4h)
2026-03-01 23:06:41,583 [INFO] signal-engine: [XRPUSDT] 冷启动完成: 加载63,246条历史数据 (窗口=4h)
2026-03-01 23:06:42,041 [INFO] signal-engine: [SOLUSDT] 冷启动完成: 加载70,472条历史数据 (窗口=4h)
2026-03-01 23:06:42,041 [INFO] signal-engine: === Signal Engine (PG) 启动完成 ===
2026-03-01 23:06:42,270 [INFO] signal-engine: [BTCUSDT] 🚨 信号[v51_baseline]: LONG score=90 price=65362.8
2026-03-01 23:06:42,270 [INFO] signal-engine: [BTCUSDT] 🚨 信号[v52_8signals]: LONG score=90 price=65362.8
2026-03-01 23:06:42,726 [INFO] signal-engine: [SOLUSDT] 🚨 信号[v51_baseline]: LONG score=92 price=82.8
2026-03-01 23:06:42,726 [INFO] signal-engine: [SOLUSDT] 🚨 信号[v52_8signals]: LONG score=92 price=82.8
2026-03-01 23:07:14,105 [INFO] signal-engine: 冷启动保护期结束,模拟盘开仓已启用
2026-03-01 23:14:12,791 [INFO] signal-engine: 已加载策略配置: v51_baseline, v52_8signals
2026-03-01 23:14:15,845 [INFO] signal-engine: [BTCUSDT] 冷启动完成: 加载446,015条历史数据 (窗口=4h)
2026-03-01 23:14:19,122 [INFO] signal-engine: [ETHUSDT] 冷启动完成: 加载486,995条历史数据 (窗口=4h)
2026-03-01 23:14:19,543 [INFO] signal-engine: [XRPUSDT] 冷启动完成: 加载64,234条历史数据 (窗口=4h)
2026-03-01 23:14:20,034 [INFO] signal-engine: [SOLUSDT] 冷启动完成: 加载72,230条历史数据 (窗口=4h)
2026-03-01 23:14:20,034 [INFO] signal-engine: === Signal Engine (PG) 启动完成 ===
2026-03-01 23:14:20,277 [INFO] signal-engine: [BTCUSDT] 🚨 信号[v51_baseline]: LONG score=90 price=65517.8
2026-03-01 23:14:20,277 [INFO] signal-engine: [BTCUSDT] 🚨 信号[v52_8signals]: LONG score=90 price=65517.8
2026-03-01 23:14:20,717 [INFO] signal-engine: [SOLUSDT] 🚨 信号[v51_baseline]: LONG score=92 price=83.2
2026-03-01 23:14:20,717 [INFO] signal-engine: [SOLUSDT] 🚨 信号[v52_8signals]: LONG score=97 price=83.2
2026-03-01 23:14:52,024 [INFO] signal-engine: 冷启动保护期结束,模拟盘开仓已启用
2026-03-01 23:23:48,162 [INFO] signal-engine: 已加载策略配置: v51_baseline, v52_8signals
2026-03-01 23:23:51,129 [INFO] signal-engine: [BTCUSDT] 冷启动完成: 加载454,017条历史数据 (窗口=4h)
2026-03-01 23:23:54,321 [INFO] signal-engine: [ETHUSDT] 冷启动完成: 加载489,974条历史数据 (窗口=4h)
2026-03-01 23:23:54,744 [INFO] signal-engine: [XRPUSDT] 冷启动完成: 加载65,115条历史数据 (窗口=4h)
2026-03-01 23:23:55,256 [INFO] signal-engine: [SOLUSDT] 冷启动完成: 加载72,235条历史数据 (窗口=4h)
2026-03-01 23:23:55,257 [INFO] signal-engine: === Signal Engine (PG) 启动完成 ===
2026-03-01 23:23:55,497 [INFO] signal-engine: [BTCUSDT] 🚨 信号[v51_baseline]: LONG score=95 price=65616.4
2026-03-01 23:23:55,497 [INFO] signal-engine: [BTCUSDT] 🚨 信号[v52_8signals]: LONG score=95 price=65616.4
2026-03-01 23:23:55,960 [INFO] signal-engine: [SOLUSDT] 🚨 信号[v51_baseline]: LONG score=92 price=83.4
2026-03-01 23:23:55,961 [INFO] signal-engine: [SOLUSDT] 🚨 信号[v52_8signals]: LONG score=92 price=83.4
2026-03-01 23:24:27,328 [INFO] signal-engine: 冷启动保护期结束,模拟盘开仓已启用
2026-03-01 23:32:18,630 [INFO] signal-engine: [XRPUSDT] 🚨 信号[v51_baseline]: SHORT score=82 price=1.4
2026-03-01 23:32:18,630 [INFO] signal-engine: [XRPUSDT] 🚨 信号[v52_8signals]: SHORT score=82 price=1.4
2026-03-01 23:32:18,652 [INFO] signal-engine: [XRPUSDT] 📝 模拟开仓: SHORT @ 1.35 score=82 tier=standard strategy=v52_8signals TP1=1.34 TP2=1.33 SL=1.36
2026-03-01 23:34:08,143 [INFO] signal-engine: [BTCUSDT] 🚨 信号[v51_baseline]: LONG score=85 price=65781.9
2026-03-01 23:34:08,144 [INFO] signal-engine: [BTCUSDT] 🚨 信号[v52_8signals]: LONG score=85 price=65781.9
2026-03-01 23:34:08,165 [INFO] signal-engine: [BTCUSDT] 📝 模拟开仓: LONG @ 65781.85 score=85 tier=heavy strategy=v52_8signals TP1=66122.21 TP2=66547.66 SL=65271.31
2026-03-01 23:34:08,664 [INFO] signal-engine: [SOLUSDT] 🚨 信号[v51_baseline]: LONG score=87 price=83.5
2026-03-01 23:34:08,664 [INFO] signal-engine: [SOLUSDT] 🚨 信号[v52_8signals]: LONG score=87 price=83.5
2026-03-01 23:34:08,686 [INFO] signal-engine: [SOLUSDT] 📝 模拟开仓: LONG @ 83.54 score=87 tier=heavy strategy=v52_8signals TP1=84.10 TP2=84.80 SL=82.70
2026-03-01 23:35:46,904 [INFO] signal-engine: 已加载策略配置: v51_baseline, v52_8signals
2026-03-01 23:35:50,048 [INFO] signal-engine: [BTCUSDT] 冷启动完成: 加载459,950条历史数据 (窗口=4h)
2026-03-01 23:35:53,307 [INFO] signal-engine: [ETHUSDT] 冷启动完成: 加载486,670条历史数据 (窗口=4h)
2026-03-01 23:35:53,760 [INFO] signal-engine: [XRPUSDT] 冷启动完成: 加载64,640条历史数据 (窗口=4h)
2026-03-01 23:35:54,229 [INFO] signal-engine: [SOLUSDT] 冷启动完成: 加载71,505条历史数据 (窗口=4h)
2026-03-01 23:35:54,229 [INFO] signal-engine: === Signal Engine (PG) 启动完成 ===
2026-03-01 23:35:54,463 [INFO] signal-engine: [BTCUSDT] 🚨 信号[v51_baseline]: LONG score=95 price=65794.8
2026-03-01 23:35:54,464 [INFO] signal-engine: [BTCUSDT] 🚨 信号[v52_8signals]: LONG score=95 price=65794.8
2026-03-01 23:35:54,823 [INFO] signal-engine: [XRPUSDT] 🚨 信号[v51_baseline]: SHORT score=92 price=1.4
2026-03-01 23:35:54,823 [INFO] signal-engine: [XRPUSDT] 🚨 信号[v52_8signals]: SHORT score=92 price=1.4
2026-03-01 23:35:54,901 [INFO] signal-engine: [SOLUSDT] 🚨 信号[v51_baseline]: LONG score=92 price=83.6
2026-03-01 23:35:54,901 [INFO] signal-engine: [SOLUSDT] 🚨 信号[v52_8signals]: LONG score=92 price=83.6
2026-03-01 23:36:26,291 [INFO] signal-engine: 冷启动保护期结束,模拟盘开仓已启用
2026-03-01 23:45:20,597 [INFO] signal-engine: [ETHUSDT] 🚨 信号[v51_baseline]: SHORT score=85 price=1937.2
2026-03-01 23:45:20,598 [INFO] signal-engine: [ETHUSDT] 🚨 信号[v52_8signals]: SHORT score=90 price=1937.2
2026-03-01 23:45:20,621 [INFO] signal-engine: [ETHUSDT] 📝 模拟开仓: SHORT @ 1937.23 score=90 tier=heavy strategy=v52_8signals TP1=1923.04 TP2=1905.29 SL=1958.53
2026-03-01 23:46:07,484 [INFO] signal-engine: [BTCUSDT] 🚨 信号[v51_baseline]: LONG score=85 price=65708.5
2026-03-01 23:46:07,484 [INFO] signal-engine: [BTCUSDT] 🚨 信号[v52_8signals]: LONG score=85 price=65708.5
2026-03-01 23:46:07,850 [INFO] signal-engine: [XRPUSDT] 🚨 信号[v51_baseline]: SHORT score=82 price=1.3
2026-03-01 23:46:07,850 [INFO] signal-engine: [XRPUSDT] 🚨 信号[v52_8signals]: SHORT score=82 price=1.3
2026-03-01 23:46:07,939 [INFO] signal-engine: [SOLUSDT] 🚨 信号[v51_baseline]: LONG score=87 price=83.5
2026-03-01 23:46:07,939 [INFO] signal-engine: [SOLUSDT] 🚨 信号[v52_8signals]: LONG score=87 price=83.5
2026-03-01 23:51:22,298 [INFO] signal-engine: [BTCUSDT] 状态: CVD_fast=417.5 CVD_mid=3139.9 ATR=262.21 (11%) VWAP=65702.8
2026-03-01 23:51:22,298 [INFO] signal-engine: [ETHUSDT] 状态: CVD_fast=-892.3 CVD_mid=-33690.7 ATR=8.38 (11%) VWAP=1937.7
2026-03-01 23:51:22,298 [INFO] signal-engine: [XRPUSDT] 状态: CVD_fast=-517373.6 CVD_mid=-3295080.7 ATR=0.01 (11%) VWAP=1.4
2026-03-01 23:51:22,298 [INFO] signal-engine: [SOLUSDT] 状态: CVD_fast=829.6 CVD_mid=204333.9 ATR=0.39 (11%) VWAP=83.6
2026-03-01 23:55:33,622 [INFO] signal-engine: [ETHUSDT] 🚨 信号[v51_baseline]: SHORT score=80 price=1937.9
2026-03-01 23:55:33,622 [INFO] signal-engine: [ETHUSDT] 🚨 信号[v52_8signals]: SHORT score=80 price=1937.9
2026-03-01 23:56:20,475 [INFO] signal-engine: [BTCUSDT] 🚨 信号[v51_baseline]: LONG score=85 price=65700.6
2026-03-01 23:56:20,475 [INFO] signal-engine: [BTCUSDT] 🚨 信号[v52_8signals]: LONG score=85 price=65700.6
2026-03-01 23:56:20,867 [INFO] signal-engine: [XRPUSDT] 🚨 信号[v51_baseline]: SHORT score=82 price=1.4
2026-03-01 23:56:20,867 [INFO] signal-engine: [XRPUSDT] 🚨 信号[v52_8signals]: SHORT score=82 price=1.4
2026-03-01 23:56:20,955 [INFO] signal-engine: [SOLUSDT] 🚨 信号[v51_baseline]: LONG score=87 price=83.6
2026-03-01 23:56:20,955 [INFO] signal-engine: [SOLUSDT] 🚨 信号[v52_8signals]: LONG score=87 price=83.6
2026-03-01 23:57:56,842 [INFO] signal-engine: 已加载策略配置: v51_baseline, v52_8signals
2026-03-01 23:58:00,054 [INFO] signal-engine: [BTCUSDT] 冷启动完成: 加载469,368条历史数据 (窗口=4h)
2026-03-01 23:58:03,491 [INFO] signal-engine: [ETHUSDT] 冷启动完成: 加载480,675条历史数据 (窗口=4h)
2026-03-01 23:58:03,940 [INFO] signal-engine: [XRPUSDT] 冷启动完成: 加载64,627条历史数据 (窗口=4h)
2026-03-01 23:58:04,405 [INFO] signal-engine: [SOLUSDT] 冷启动完成: 加载69,923条历史数据 (窗口=4h)
2026-03-01 23:58:04,405 [INFO] signal-engine: === Signal Engine (PG) 启动完成 ===
2026-03-01 23:58:04,670 [INFO] signal-engine: [BTCUSDT] 🚨 信号[v51_baseline]: LONG score=90 price=65701.1
2026-03-01 23:58:04,671 [INFO] signal-engine: [BTCUSDT] 🚨 信号[v52_8signals]: LONG score=90 price=65701.1
2026-03-01 23:58:05,068 [INFO] signal-engine: [SOLUSDT] 🚨 信号[v51_baseline]: LONG score=92 price=83.6
2026-03-01 23:58:05,069 [INFO] signal-engine: [SOLUSDT] 🚨 信号[v52_8signals]: LONG score=92 price=83.6
2026-03-01 23:58:36,358 [INFO] signal-engine: 冷启动保护期结束,模拟盘开仓已启用
2026-03-02 00:02:15,179 [INFO] signal-engine: [XRPUSDT] 🚨 信号[v51_baseline]: SHORT score=87 price=1.4
2026-03-02 00:02:15,180 [INFO] signal-engine: [XRPUSDT] 🚨 信号[v52_8signals]: SHORT score=87 price=1.4
2026-03-02 00:08:14,751 [INFO] signal-engine: [BTCUSDT] 🚨 信号[v51_baseline]: LONG score=85 price=65785.2
2026-03-02 00:08:14,751 [INFO] signal-engine: [BTCUSDT] 🚨 信号[v52_8signals]: LONG score=90 price=65785.2
2026-03-02 00:08:15,137 [INFO] signal-engine: [SOLUSDT] 🚨 信号[v51_baseline]: LONG score=82 price=83.6
2026-03-02 00:08:15,138 [INFO] signal-engine: [SOLUSDT] 🚨 信号[v52_8signals]: LONG score=82 price=83.6
2026-03-02 00:12:25,824 [INFO] signal-engine: [XRPUSDT] 🚨 信号[v51_baseline]: LONG score=82 price=1.4
2026-03-02 00:12:25,824 [INFO] signal-engine: [XRPUSDT] 🚨 信号[v52_8signals]: LONG score=87 price=1.4
2026-03-02 00:12:57,084 [INFO] signal-engine: [ETHUSDT] 🚨 信号[v51_baseline]: LONG score=90 price=1942.8
2026-03-02 00:12:57,085 [INFO] signal-engine: [ETHUSDT] 🚨 信号[v52_8signals]: LONG score=95 price=1942.8
2026-03-02 00:13:28,619 [INFO] signal-engine: [BTCUSDT] 状态: CVD_fast=492.8 CVD_mid=4524.8 ATR=344.15 (100%) VWAP=65886.1
2026-03-02 00:13:28,619 [INFO] signal-engine: [ETHUSDT] 状态: CVD_fast=12835.6 CVD_mid=5292.2 ATR=10.87 (100%) VWAP=1942.9
2026-03-02 00:13:28,619 [INFO] signal-engine: [XRPUSDT] 状态: CVD_fast=561858.0 CVD_mid=1078138.6 ATR=0.01 (100%) VWAP=1.4
2026-03-02 00:13:28,620 [INFO] signal-engine: [SOLUSDT] 状态: CVD_fast=61604.1 CVD_mid=357198.7 ATR=0.51 (100%) VWAP=83.7
2026-03-02 00:18:25,810 [INFO] signal-engine: [BTCUSDT] 🚨 信号[v51_baseline]: LONG score=82 price=65894.7
2026-03-02 00:18:25,810 [INFO] signal-engine: [BTCUSDT] 🚨 信号[v52_8signals]: LONG score=82 price=65894.7
2026-03-02 00:18:25,829 [INFO] signal-engine: [BTCUSDT] 📝 模拟开仓: LONG @ 65894.67 score=82 tier=standard strategy=v52_8signals TP1=66258.64 TP2=66713.60 SL=65348.71
2026-03-02 00:18:26,217 [INFO] signal-engine: [SOLUSDT] 🚨 信号[v51_baseline]: LONG score=87 price=83.7
2026-03-02 00:18:26,217 [INFO] signal-engine: [SOLUSDT] 🚨 信号[v52_8signals]: LONG score=87 price=83.7
2026-03-02 00:18:26,235 [INFO] signal-engine: [SOLUSDT] 📝 模拟开仓: LONG @ 83.72 score=87 tier=heavy strategy=v52_8signals TP1=84.44 TP2=85.34 SL=82.65
2026-03-02 00:22:37,394 [INFO] signal-engine: [XRPUSDT] 🚨 信号[v51_baseline]: LONG score=77 price=1.4
2026-03-02 00:22:37,395 [INFO] signal-engine: [XRPUSDT] 🚨 信号[v52_8signals]: LONG score=77 price=1.4
2026-03-02 00:23:08,756 [INFO] signal-engine: [ETHUSDT] 🚨 信号[v51_baseline]: LONG score=85 price=1943.5
2026-03-02 00:23:08,756 [INFO] signal-engine: [ETHUSDT] 🚨 信号[v52_8signals]: LONG score=90 price=1943.5
2026-03-02 00:25:13,809 [INFO] signal-engine: 已加载策略配置: v51_baseline, v52_8signals
2026-03-02 00:25:17,084 [INFO] signal-engine: [BTCUSDT] 冷启动完成: 加载467,836条历史数据 (窗口=4h)
2026-03-02 00:25:20,192 [INFO] signal-engine: [ETHUSDT] 冷启动完成: 加载457,043条历史数据 (窗口=4h)
2026-03-02 00:25:20,634 [INFO] signal-engine: [XRPUSDT] 冷启动完成: 加载65,387条历史数据 (窗口=4h)
2026-03-02 00:25:21,081 [INFO] signal-engine: [SOLUSDT] 冷启动完成: 加载67,363条历史数据 (窗口=4h)
2026-03-02 00:25:21,081 [INFO] signal-engine: === Signal Engine (PG) 启动完成 ===
2026-03-02 00:25:21,333 [INFO] signal-engine: [BTCUSDT] 🚨 信号[v51_baseline]: LONG score=90 price=65913.4
2026-03-02 00:25:21,334 [INFO] signal-engine: [BTCUSDT] 🚨 信号[v52_8signals]: LONG score=90 price=65913.4
2026-03-02 00:25:21,612 [INFO] signal-engine: [ETHUSDT] 🚨 信号[v51_baseline]: LONG score=90 price=1943.7
2026-03-02 00:25:21,612 [INFO] signal-engine: [ETHUSDT] 🚨 信号[v52_8signals]: LONG score=90 price=1943.7
2026-03-02 00:25:21,693 [INFO] signal-engine: [XRPUSDT] 🚨 信号[v51_baseline]: LONG score=87 price=1.4
2026-03-02 00:25:21,693 [INFO] signal-engine: [XRPUSDT] 🚨 信号[v52_8signals]: LONG score=82 price=1.4
2026-03-02 00:25:21,780 [INFO] signal-engine: [SOLUSDT] 🚨 信号[v51_baseline]: LONG score=92 price=83.8
2026-03-02 00:25:21,780 [INFO] signal-engine: [SOLUSDT] 🚨 信号[v52_8signals]: LONG score=87 price=83.8
2026-03-02 00:25:53,073 [INFO] signal-engine: 冷启动保护期结束,模拟盘开仓已启用
2026-03-02 00:34:24,612 [INFO] signal-engine: 已加载策略配置: v51_baseline, v52_8signals
2026-03-02 00:34:27,756 [INFO] signal-engine: [BTCUSDT] 冷启动完成: 加载459,517条历史数据 (窗口=4h)
2026-03-02 00:34:30,633 [INFO] signal-engine: [ETHUSDT] 冷启动完成: 加载438,434条历史数据 (窗口=4h)
2026-03-02 00:34:31,056 [INFO] signal-engine: [XRPUSDT] 冷启动完成: 加载64,941条历史数据 (窗口=4h)
2026-03-02 00:34:31,510 [INFO] signal-engine: [SOLUSDT] 冷启动完成: 加载66,780条历史数据 (窗口=4h)
2026-03-02 00:34:31,511 [INFO] signal-engine: === Signal Engine (PG) 启动完成 ===
2026-03-02 00:34:31,763 [INFO] signal-engine: [BTCUSDT] 🚨 信号[v51_baseline]: LONG score=90 price=65953.9
2026-03-02 00:34:31,763 [INFO] signal-engine: [BTCUSDT] 🚨 信号[v52_8signals]: LONG score=83 price=65953.9
2026-03-02 00:34:32,046 [INFO] signal-engine: [ETHUSDT] 🚨 信号[v51_baseline]: LONG score=95 price=1945.0
2026-03-02 00:34:32,046 [INFO] signal-engine: [ETHUSDT] 🚨 信号[v52_8signals]: LONG score=87 price=1945.0
2026-03-02 00:34:32,138 [INFO] signal-engine: [XRPUSDT] 🚨 信号[v51_baseline]: LONG score=87 price=1.4
2026-03-02 00:34:32,235 [INFO] signal-engine: [SOLUSDT] 🚨 信号[v51_baseline]: LONG score=92 price=83.8
2026-03-02 00:34:32,236 [INFO] signal-engine: [SOLUSDT] 🚨 信号[v52_8signals]: LONG score=84 price=83.8
2026-03-02 00:35:03,725 [INFO] signal-engine: 冷启动保护期结束,模拟盘开仓已启用
2026-03-02 00:38:28,367 [INFO] signal-engine: [XRPUSDT] 🚨 信号[v52_8signals]: LONG score=75 price=1.4
2026-03-02 00:44:45,649 [INFO] signal-engine: [BTCUSDT] 🚨 信号[v51_baseline]: LONG score=90 price=66018.3
2026-03-02 00:44:45,649 [INFO] signal-engine: [BTCUSDT] 🚨 信号[v52_8signals]: LONG score=83 price=66018.3
2026-03-02 00:44:45,939 [INFO] signal-engine: [ETHUSDT] 🚨 信号[v51_baseline]: LONG score=90 price=1947.1
2026-03-02 00:44:45,940 [INFO] signal-engine: [ETHUSDT] 🚨 信号[v52_8signals]: LONG score=83 price=1947.1
2026-03-02 00:44:46,036 [INFO] signal-engine: [XRPUSDT] 🚨 信号[v51_baseline]: LONG score=82 price=1.4
2026-03-02 00:44:46,128 [INFO] signal-engine: [SOLUSDT] 🚨 信号[v51_baseline]: LONG score=87 price=84.0
2026-03-02 00:44:46,129 [INFO] signal-engine: [SOLUSDT] 🚨 信号[v52_8signals]: LONG score=80 price=84.0
2026-03-02 00:48:42,855 [INFO] signal-engine: [XRPUSDT] 🚨 信号[v52_8signals]: LONG score=75 price=1.4
2026-03-02 00:50:01,898 [INFO] signal-engine: [BTCUSDT] 状态: CVD_fast=587.6 CVD_mid=5583.3 ATR=230.66 (69%) VWAP=66328.7
2026-03-02 00:50:01,898 [INFO] signal-engine: [ETHUSDT] 状态: CVD_fast=13374.7 CVD_mid=74135.1 ATR=7.58 (69%) VWAP=1953.2
2026-03-02 00:50:01,899 [INFO] signal-engine: [XRPUSDT] 状态: CVD_fast=686323.2 CVD_mid=4294786.9 ATR=0.00 (66%) VWAP=1.4
2026-03-02 00:50:01,899 [INFO] signal-engine: [SOLUSDT] 状态: CVD_fast=159663.5 CVD_mid=524572.1 ATR=0.37 (38%) VWAP=84.4
2026-03-02 00:55:01,241 [INFO] signal-engine: [BTCUSDT] 🚨 信号[v51_baseline]: LONG score=85 price=66391.1
2026-03-02 00:55:01,241 [INFO] signal-engine: [BTCUSDT] 🚨 信号[v52_8signals]: LONG score=83 price=66391.1
2026-03-02 00:55:01,565 [INFO] signal-engine: [ETHUSDT] 🚨 信号[v51_baseline]: LONG score=90 price=1954.9
2026-03-02 00:55:01,565 [INFO] signal-engine: [ETHUSDT] 🚨 信号[v52_8signals]: LONG score=83 price=1954.9
2026-03-02 00:55:01,681 [INFO] signal-engine: [XRPUSDT] 🚨 信号[v51_baseline]: LONG score=77 price=1.4
2026-03-02 00:55:01,797 [INFO] signal-engine: [SOLUSDT] 🚨 信号[v51_baseline]: LONG score=87 price=84.5
2026-03-02 00:55:01,797 [INFO] signal-engine: [SOLUSDT] 🚨 信号[v52_8signals]: LONG score=75 price=84.5
2026-03-02 00:56:24,512 [INFO] signal-engine: 已加载策略配置: v51_baseline, v52_8signals
2026-03-02 00:56:27,834 [INFO] signal-engine: [BTCUSDT] 冷启动完成: 加载487,314条历史数据 (窗口=4h)
2026-03-02 00:56:30,636 [INFO] signal-engine: [ETHUSDT] 冷启动完成: 加载423,049条历史数据 (窗口=4h)
2026-03-02 00:56:31,186 [INFO] signal-engine: [XRPUSDT] 冷启动完成: 加载63,935条历史数据 (窗口=4h)
2026-03-02 00:56:31,669 [INFO] signal-engine: [SOLUSDT] 冷启动完成: 加载67,314条历史数据 (窗口=4h)
2026-03-02 00:56:31,669 [INFO] signal-engine: === Signal Engine (PG) 启动完成 ===
2026-03-02 00:56:31,916 [INFO] signal-engine: [BTCUSDT] 🚨 信号[v51_baseline]: LONG score=95 price=66401.1
2026-03-02 00:56:31,916 [INFO] signal-engine: [BTCUSDT] 🚨 信号[v52_8signals]: LONG score=87.0 price=66401.1
2026-03-02 00:56:32,159 [INFO] signal-engine: [ETHUSDT] 🚨 信号[v51_baseline]: LONG score=95 price=1955.3
2026-03-02 00:56:32,159 [INFO] signal-engine: [ETHUSDT] 🚨 信号[v52_8signals]: LONG score=87.0 price=1955.3
2026-03-02 00:56:32,227 [INFO] signal-engine: [XRPUSDT] 🚨 信号[v51_baseline]: LONG score=87 price=1.4
2026-03-02 00:56:32,296 [INFO] signal-engine: [SOLUSDT] 🚨 信号[v51_baseline]: LONG score=92 price=84.6
2026-03-02 00:56:32,296 [INFO] signal-engine: [SOLUSDT] 🚨 信号[v52_8signals]: LONG score=79.0 price=84.6
2026-03-02 00:57:03,538 [INFO] signal-engine: 冷启动保护期结束,模拟盘开仓已启用
2026-03-02 01:06:43,805 [INFO] signal-engine: [BTCUSDT] 🚨 信号[v51_baseline]: LONG score=85 price=66469.7
2026-03-02 01:06:43,805 [INFO] signal-engine: [BTCUSDT] 🚨 信号[v52_8signals]: LONG score=83.0 price=66469.7
2026-03-02 01:06:44,079 [INFO] signal-engine: [ETHUSDT] 🚨 信号[v51_baseline]: LONG score=90 price=1959.2
2026-03-02 01:06:44,079 [INFO] signal-engine: [ETHUSDT] 🚨 信号[v52_8signals]: LONG score=83.0 price=1959.2
2026-03-02 01:06:44,158 [INFO] signal-engine: [XRPUSDT] 🚨 信号[v51_baseline]: LONG score=82 price=1.4
2026-03-02 01:06:44,232 [INFO] signal-engine: [SOLUSDT] 🚨 信号[v51_baseline]: LONG score=82 price=84.8
2026-03-02 01:06:44,232 [INFO] signal-engine: [SOLUSDT] 🚨 信号[v52_8signals]: LONG score=80.0 price=84.8
2026-03-02 01:06:44,253 [INFO] signal-engine: [SOLUSDT] 📝 模拟开仓: LONG @ 84.81 score=80.0 tier=standard strategy=v52_8signals TP1=85.30 TP2=85.91 SL=84.08
2026-03-02 01:10:55,723 [INFO] signal-engine: [XRPUSDT] 🚨 信号[v52_8signals]: LONG score=75.0 price=1.4
2026-03-02 01:11:58,908 [INFO] signal-engine: [BTCUSDT] 状态: CVD_fast=952.8 CVD_mid=5720.3 ATR=235.00 (100%) VWAP=66586.5
2026-03-02 01:11:58,908 [INFO] signal-engine: [ETHUSDT] 状态: CVD_fast=31499.2 CVD_mid=101911.4 ATR=7.67 (100%) VWAP=1966.7
2026-03-02 01:11:58,908 [INFO] signal-engine: [XRPUSDT] 状态: CVD_fast=763930.9 CVD_mid=3518537.2 ATR=0.01 (100%) VWAP=1.4
2026-03-02 01:11:58,908 [INFO] signal-engine: [SOLUSDT] 状态: CVD_fast=173330.9 CVD_mid=509498.7 ATR=0.38 (46%) VWAP=85.1
2026-03-02 01:16:57,856 [INFO] signal-engine: [BTCUSDT] 🚨 信号[v51_baseline]: LONG score=85 price=66677.4
2026-03-02 01:16:57,856 [INFO] signal-engine: [BTCUSDT] 🚨 信号[v52_8signals]: LONG score=78.0 price=66677.4
2026-03-02 01:16:58,164 [INFO] signal-engine: [ETHUSDT] 🚨 信号[v51_baseline]: LONG score=85 price=1971.5
2026-03-02 01:16:58,164 [INFO] signal-engine: [ETHUSDT] 🚨 信号[v52_8signals]: LONG score=78.0 price=1971.5
2026-03-02 01:16:58,185 [INFO] signal-engine: [ETHUSDT] 📝 模拟开仓: LONG @ 1971.50 score=78.0 tier=standard strategy=v52_8signals TP1=1984.82 TP2=2001.47 SL=1951.52
2026-03-02 01:16:58,264 [INFO] signal-engine: [XRPUSDT] 🚨 信号[v51_baseline]: LONG score=77 price=1.4
2026-03-02 01:16:58,346 [INFO] signal-engine: [SOLUSDT] 🚨 信号[v51_baseline]: LONG score=82 price=85.3
2026-03-02 01:16:58,346 [INFO] signal-engine: [SOLUSDT] 🚨 信号[v52_8signals]: LONG score=75.0 price=85.3
2026-03-02 01:16:58,368 [INFO] signal-engine: [SOLUSDT] 📝 模拟开仓: LONG @ 85.32 score=75.0 tier=standard strategy=v52_8signals TP1=85.98 TP2=86.80 SL=84.34