Compare commits
127 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1ed55382c | ||
|
|
73cb73cde1 | ||
|
|
5a4dded190 | ||
|
|
4b0f30cab8 | ||
|
|
e515063809 | ||
|
|
a6165d8c86 | ||
|
|
9d248c2f8f | ||
|
|
4d5ebbb1b3 | ||
|
|
602d9ae034 | ||
|
|
8cab470231 | ||
|
|
2d64a5524e | ||
|
|
6fbf3566f9 | ||
|
|
18d628359c | ||
|
|
9eeb9a2434 | ||
|
|
9a2d7cf187 | ||
|
|
7accb8ed88 | ||
|
|
e25bc519ec | ||
|
|
83494dc66c | ||
|
|
ccb53cb3a8 | ||
|
|
98a88d7c19 | ||
|
|
84d8535066 | ||
|
|
c8416b4916 | ||
|
|
5becf882e4 | ||
|
|
3352489ce9 | ||
|
|
91a8e7a1ea | ||
|
|
c2cae429ad | ||
|
|
d2ddc3ea46 | ||
|
|
42cb230337 | ||
|
|
546fae39d0 | ||
|
|
67f68f8756 | ||
|
|
dfcf4f43c4 | ||
|
|
2a9e4db756 | ||
|
|
f87edc0339 | ||
|
|
1e982c48f9 | ||
|
|
57f749c094 | ||
|
|
a7cd78e06f | ||
|
|
224ca20666 | ||
|
|
18af5bb711 | ||
|
|
85db47e41f | ||
|
|
17a387b6f4 | ||
|
|
fcac8c2334 | ||
|
|
4c43309c16 | ||
|
|
0a5222a1de | ||
|
|
8280aaf6ea | ||
|
|
cf7756b4e5 | ||
|
|
c645178f7b | ||
|
|
3172300fd0 | ||
|
|
91ed44ad9f | ||
|
|
c0c37c4c7e | ||
|
|
22787b3e0a | ||
|
|
0d9dffa44d | ||
|
|
a17c1434b9 | ||
|
|
ce0bebd6f5 | ||
|
|
b83ab66039 | ||
|
|
fb4e1e06e5 | ||
|
|
18a2c95e40 | ||
|
|
4a5d63621f | ||
|
|
f3d742fd02 | ||
|
|
63440efc6d | ||
|
|
511f011aef | ||
|
|
42ccbd35d7 | ||
|
|
ffd7bed387 | ||
|
|
c8c2d801a2 | ||
|
|
548c7f0e33 | ||
|
|
809fb60a50 | ||
|
|
af732f1438 | ||
|
|
262594f16e | ||
|
|
66b094c711 | ||
|
|
8bd5f8fa84 | ||
|
|
31e6e19ea6 | ||
|
|
22df9152f2 | ||
|
|
18506f2a44 | ||
|
|
27a51b4d19 | ||
|
|
8694e5cf3a | ||
|
|
638589852b | ||
|
|
855df24eba | ||
|
|
3b6e7d8af3 | ||
|
|
b0a463a22c | ||
|
|
b1731f0f79 | ||
|
|
cc1b2c33c1 | ||
|
|
1bf880cebb | ||
|
|
22d06efafe | ||
|
|
d7788d3766 | ||
|
|
fb0c3806b5 | ||
|
|
ab27e5a4da | ||
|
|
7e8f83fd5a | ||
|
|
fe754cf628 | ||
|
|
cb869926e2 | ||
|
|
1ef1f97b5d | ||
|
|
832f78a1d7 | ||
|
|
b08ea8f772 | ||
|
|
fab3a3d909 | ||
|
|
21970038df | ||
|
|
a7dec3fe14 | ||
|
|
88290882f9 | ||
|
|
72ea0ffd0e | ||
|
|
02a769f513 | ||
|
|
526b6359ca | ||
|
|
984019d9ab | ||
|
|
5b704a0a0e | ||
|
|
7ebdb98643 | ||
|
|
7dee6bffbd | ||
|
|
318bcb99a3 | ||
|
|
1a45c5725a | ||
|
|
d4f0bbbcb1 | ||
|
|
1fa6f178b6 | ||
|
|
e405a9c21e | ||
|
|
05673c0850 | ||
|
|
5849bf6522 | ||
|
|
01b1992643 | ||
|
|
a571a221e5 | ||
|
|
8279b16c68 | ||
|
|
ec198db504 | ||
|
|
7d2bb9f392 | ||
|
|
58c72b4d90 | ||
|
|
87f19cb7d8 | ||
|
|
a9c3523a24 | ||
|
|
ee90b8dcfa | ||
|
|
778cf8cce1 | ||
|
|
7ba53a5005 | ||
|
|
f6156a2cfe | ||
|
|
732b01691b | ||
|
|
a7600e8db1 | ||
|
|
4b841bc5f4 | ||
|
|
2f9dce483c | ||
|
|
d351949a7c | ||
|
|
45bad25156 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -1 +1,2 @@
|
||||
__pycache__/
|
||||
logs/*.log
|
||||
|
||||
63
V52_FRONTEND_TASK.md
Normal file
63
V52_FRONTEND_TASK.md
Normal 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
224
V52_TASK.md
Normal 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
|
||||
1
archive/paper-history-2026-03-03/.gitignore
vendored
Normal file
1
archive/paper-history-2026-03-03/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
*.csv
|
||||
19
archive/paper-history-2026-03-03/README.md
Normal file
19
archive/paper-history-2026-03-03/README.md
Normal file
@ -0,0 +1,19 @@
|
||||
# 模拟盘历史归档 - 2026-03-03
|
||||
|
||||
归档时间:2026-03-03,V5.3 统一重置前
|
||||
|
||||
## 文件说明
|
||||
|
||||
| 文件 | 内容 | 行数 |
|
||||
|---|---|---|
|
||||
| signal_indicators_v51_v52.csv | v51_baseline + v52_8signals 历史信号 | 121,795条 |
|
||||
| paper_trades_v51_v52.csv | v51_baseline + v52_8signals 历史交易 | 774笔 |
|
||||
|
||||
## 统计摘要
|
||||
|
||||
- **v51_baseline**:573笔交易,从 2026-02-28 开始,已破产
|
||||
- **v52_8signals**:201笔交易,从 2026-03-01 开始
|
||||
|
||||
## 重置原因
|
||||
|
||||
V5.3 上线后,三个策略(v51/v52/v53)需要同一起点开始跑,才能做公平对照。
|
||||
@ -6,7 +6,9 @@ import httpx
|
||||
DB_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "arb.db")
|
||||
BINANCE_FAPI = "https://fapi.binance.com/fapi/v1"
|
||||
SYMBOLS = ["BTCUSDT", "ETHUSDT"]
|
||||
DISCORD_TOKEN = os.getenv("DISCORD_BOT_TOKEN", "MTQ3Mjk4NzY1NjczNTU1OTg0Mg.GgeYh5.NYSbivZKBUc5S2iKXeB-hnC33w3SUUPzDDdviM")
|
||||
DISCORD_TOKEN = os.getenv("DISCORD_BOT_TOKEN")
|
||||
if not DISCORD_TOKEN:
|
||||
raise RuntimeError("DISCORD_BOT_TOKEN 未设置,请从 GCP Secret Manager 注入")
|
||||
DISCORD_CHANNEL = os.getenv("DISCORD_SIGNAL_CHANNEL", "1472986545635197033")
|
||||
BINANCE_HEADERS = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}
|
||||
|
||||
@ -7,6 +7,7 @@ agg_trades_collector.py — aggTrades全量采集守护进程(PostgreSQL版)
|
||||
- 每分钟巡检:校验agg_id连续性,发现断档自动补洞
|
||||
- 批量写入:攒200条或1秒flush一次
|
||||
- PG分区表:按月自动分区,MVCC并发无锁冲突
|
||||
- 统一写入 Cloud SQL(双写机制已移除)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@ -22,7 +23,7 @@ import psycopg2
|
||||
import psycopg2.extras
|
||||
import websockets
|
||||
|
||||
from db import get_sync_conn, get_sync_pool, get_cloud_sync_conn, ensure_partitions, PG_HOST, PG_PORT, PG_DB, PG_USER, PG_PASS, CLOUD_PG_ENABLED
|
||||
from db import get_sync_conn, get_sync_pool, ensure_partitions, PG_HOST
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
@ -69,11 +70,10 @@ def update_meta(conn, symbol: str, last_agg_id: int, last_time_ms: int):
|
||||
|
||||
|
||||
def flush_buffer(symbol: str, trades: list) -> int:
|
||||
"""写入一批trades到PG(本地+Cloud SQL双写),返回实际写入条数"""
|
||||
"""写入一批trades到Cloud SQL,返回实际写入条数"""
|
||||
if not trades:
|
||||
return 0
|
||||
try:
|
||||
# 确保分区存在
|
||||
ensure_partitions()
|
||||
|
||||
values = []
|
||||
@ -98,7 +98,6 @@ def flush_buffer(symbol: str, trades: list) -> int:
|
||||
ON CONFLICT (time_ms, symbol, agg_id) DO NOTHING"""
|
||||
insert_template = "(%s, %s, %s, %s, %s, %s)"
|
||||
|
||||
# 写本地PG
|
||||
inserted = 0
|
||||
with get_sync_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
@ -111,22 +110,6 @@ def flush_buffer(symbol: str, trades: list) -> int:
|
||||
update_meta(conn, symbol, last_agg_id, last_time_ms)
|
||||
conn.commit()
|
||||
|
||||
# 双写Cloud SQL(失败不影响主流程)
|
||||
if CLOUD_PG_ENABLED:
|
||||
try:
|
||||
with get_cloud_sync_conn() as cloud_conn:
|
||||
if cloud_conn:
|
||||
with cloud_conn.cursor() as cur:
|
||||
psycopg2.extras.execute_values(
|
||||
cur, insert_sql, values,
|
||||
template=insert_template, page_size=1000,
|
||||
)
|
||||
if last_agg_id > 0:
|
||||
update_meta(cloud_conn, symbol, last_agg_id, last_time_ms)
|
||||
cloud_conn.commit()
|
||||
except Exception as e:
|
||||
logger.warning(f"[{symbol}] Cloud SQL write failed (non-fatal): {e}")
|
||||
|
||||
return inserted
|
||||
except Exception as e:
|
||||
logger.error(f"flush_buffer [{symbol}] error: {e}")
|
||||
|
||||
@ -12,7 +12,11 @@ from pydantic import BaseModel, EmailStr
|
||||
|
||||
from db import get_sync_conn
|
||||
|
||||
JWT_SECRET = os.getenv("JWT_SECRET", "arb-engine-jwt-secret-v2-2026")
|
||||
_TRADE_ENV = os.getenv("TRADE_ENV", "testnet")
|
||||
_jwt_default = "arb-engine-jwt-secret-v2-2026" if _TRADE_ENV == "testnet" else None
|
||||
JWT_SECRET = os.getenv("JWT_SECRET") or _jwt_default
|
||||
if not JWT_SECRET or (_TRADE_ENV != "testnet" and len(JWT_SECRET) < 32):
|
||||
raise RuntimeError("JWT_SECRET 未配置或长度不足(>=32),生产环境必须设置环境变量")
|
||||
ACCESS_TOKEN_HOURS = 24
|
||||
REFRESH_TOKEN_DAYS = 7
|
||||
|
||||
|
||||
@ -34,7 +34,7 @@ logging.basicConfig(
|
||||
)
|
||||
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_DB = os.getenv("PG_DB", "arb_engine")
|
||||
PG_USER = os.getenv("PG_USER", "arb")
|
||||
|
||||
242
backend/db.py
242
backend/db.py
@ -1,5 +1,6 @@
|
||||
"""
|
||||
db.py — PostgreSQL 数据库连接层
|
||||
统一连接到 Cloud SQL(PG_HOST 默认 10.106.0.3)
|
||||
同步连接池(psycopg2)供脚本类使用
|
||||
异步连接池(asyncpg)供FastAPI使用
|
||||
"""
|
||||
@ -11,23 +12,17 @@ import psycopg2
|
||||
import psycopg2.pool
|
||||
from contextlib import contextmanager
|
||||
|
||||
# PG连接参数(本地)
|
||||
PG_HOST = os.getenv("PG_HOST", "127.0.0.1")
|
||||
# PG连接参数(统一连接 Cloud SQL)
|
||||
PG_HOST = os.getenv("PG_HOST", "10.106.0.3")
|
||||
PG_PORT = int(os.getenv("PG_PORT", 5432))
|
||||
PG_DB = os.getenv("PG_DB", "arb_engine")
|
||||
PG_USER = os.getenv("PG_USER", "arb")
|
||||
PG_PASS = os.getenv("PG_PASS", "arb_engine_2026")
|
||||
PG_PASS = os.getenv("PG_PASS")
|
||||
if not PG_PASS:
|
||||
raise RuntimeError("PG_PASS 未设置,请在 .env 或环境变量中注入数据库密码")
|
||||
|
||||
PG_DSN = f"postgresql://{PG_USER}:{PG_PASS}@{PG_HOST}:{PG_PORT}/{PG_DB}"
|
||||
|
||||
# Cloud SQL连接参数(双写目标)
|
||||
CLOUD_PG_HOST = os.getenv("CLOUD_PG_HOST", "10.106.0.3")
|
||||
CLOUD_PG_PORT = int(os.getenv("CLOUD_PG_PORT", 5432))
|
||||
CLOUD_PG_DB = os.getenv("CLOUD_PG_DB", "arb_engine")
|
||||
CLOUD_PG_USER = os.getenv("CLOUD_PG_USER", "arb")
|
||||
CLOUD_PG_PASS = os.getenv("CLOUD_PG_PASS", "arb_engine_2026")
|
||||
CLOUD_PG_ENABLED = os.getenv("CLOUD_PG_ENABLED", "true").lower() == "true"
|
||||
|
||||
# ─── 同步连接池(psycopg2)─────────────────────────────────────
|
||||
|
||||
_sync_pool = None
|
||||
@ -36,7 +31,7 @@ def get_sync_pool() -> psycopg2.pool.ThreadedConnectionPool:
|
||||
global _sync_pool
|
||||
if _sync_pool is None:
|
||||
_sync_pool = psycopg2.pool.ThreadedConnectionPool(
|
||||
minconn=1, maxconn=5,
|
||||
minconn=2, maxconn=20,
|
||||
host=PG_HOST, port=PG_PORT,
|
||||
dbname=PG_DB, user=PG_USER, password=PG_PASS,
|
||||
)
|
||||
@ -73,51 +68,6 @@ def sync_executemany(sql: str, params_list: list):
|
||||
conn.commit()
|
||||
|
||||
|
||||
# ─── Cloud SQL 同步连接池(双写用)───────────────────────────────
|
||||
|
||||
_cloud_sync_pool = None
|
||||
|
||||
def get_cloud_sync_pool():
|
||||
global _cloud_sync_pool
|
||||
if not CLOUD_PG_ENABLED:
|
||||
return None
|
||||
if _cloud_sync_pool is None:
|
||||
try:
|
||||
_cloud_sync_pool = psycopg2.pool.ThreadedConnectionPool(
|
||||
minconn=1, maxconn=5,
|
||||
host=CLOUD_PG_HOST, port=CLOUD_PG_PORT,
|
||||
dbname=CLOUD_PG_DB, user=CLOUD_PG_USER, password=CLOUD_PG_PASS,
|
||||
)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger("db").error(f"Cloud SQL pool init failed: {e}")
|
||||
return None
|
||||
return _cloud_sync_pool
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_cloud_sync_conn():
|
||||
"""获取Cloud SQL同步连接(失败返回None,不影响主流程)"""
|
||||
pool = get_cloud_sync_pool()
|
||||
if pool is None:
|
||||
yield None
|
||||
return
|
||||
conn = None
|
||||
try:
|
||||
conn = pool.getconn()
|
||||
yield conn
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger("db").error(f"Cloud SQL conn error: {e}")
|
||||
yield None
|
||||
finally:
|
||||
if conn and pool:
|
||||
try:
|
||||
pool.putconn(conn)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ─── 异步连接池(asyncpg)─────────────────────────────────────
|
||||
|
||||
_async_pool: asyncpg.Pool | None = None
|
||||
@ -206,6 +156,7 @@ CREATE TABLE IF NOT EXISTS signal_indicators (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
ts BIGINT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
strategy TEXT,
|
||||
cvd_fast DOUBLE PRECISION,
|
||||
cvd_mid DOUBLE PRECISION,
|
||||
cvd_day DOUBLE PRECISION,
|
||||
@ -219,10 +170,12 @@ CREATE TABLE IF NOT EXISTS signal_indicators (
|
||||
buy_vol_1m DOUBLE PRECISION,
|
||||
sell_vol_1m DOUBLE PRECISION,
|
||||
score INTEGER,
|
||||
signal TEXT
|
||||
signal TEXT,
|
||||
factors JSONB
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_si_ts ON signal_indicators(ts);
|
||||
CREATE INDEX IF NOT EXISTS idx_si_sym_ts ON signal_indicators(symbol, ts);
|
||||
CREATE INDEX IF NOT EXISTS idx_si_strategy ON signal_indicators(strategy, ts);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS signal_indicators_1m (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
@ -256,7 +209,7 @@ CREATE TABLE IF NOT EXISTS signal_trades (
|
||||
status TEXT DEFAULT 'open'
|
||||
);
|
||||
|
||||
-- 信号日志(旧表兼容)
|
||||
-- 信号日志(旧表兼容保留,不再写入新数据)
|
||||
CREATE TABLE IF NOT EXISTS signal_logs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
symbol TEXT,
|
||||
@ -266,7 +219,32 @@ CREATE TABLE IF NOT EXISTS signal_logs (
|
||||
message TEXT
|
||||
);
|
||||
|
||||
-- 用户表(auth)
|
||||
-- 市场指标(由 market_data_collector 写入)
|
||||
CREATE TABLE IF NOT EXISTS market_indicators (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
ts BIGINT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
indicator_type TEXT NOT NULL,
|
||||
value DOUBLE PRECISION,
|
||||
extra JSONB,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_mi_sym_type_ts ON market_indicators(symbol, indicator_type, ts DESC);
|
||||
|
||||
-- 清算数据(由 liquidation_collector 写入)
|
||||
CREATE TABLE IF NOT EXISTS liquidations (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
ts BIGINT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
side TEXT NOT NULL,
|
||||
qty DOUBLE PRECISION,
|
||||
price DOUBLE PRECISION,
|
||||
usd_value DOUBLE PRECISION,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_liq_sym_ts ON liquidations(symbol, ts DESC);
|
||||
|
||||
-- 用户表(由 auth.py 负责完整定义,此处仅兜底)
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
@ -282,6 +260,13 @@ CREATE TABLE IF NOT EXISTS invite_codes (
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS invite_usage (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
code TEXT NOT NULL,
|
||||
used_by BIGINT REFERENCES users(id),
|
||||
used_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 模拟盘交易表
|
||||
CREATE TABLE IF NOT EXISTS paper_trades (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
@ -300,9 +285,115 @@ CREATE TABLE IF NOT EXISTS paper_trades (
|
||||
status TEXT DEFAULT 'active',
|
||||
pnl_r DOUBLE PRECISION DEFAULT 0,
|
||||
atr_at_entry DOUBLE PRECISION DEFAULT 0,
|
||||
risk_distance DOUBLE PRECISION,
|
||||
score_factors JSONB,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- V5.3 Feature Events(每次信号评估快照,无论是否开仓)
|
||||
CREATE TABLE IF NOT EXISTS signal_feature_events (
|
||||
event_id BIGSERIAL PRIMARY KEY,
|
||||
ts BIGINT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
track TEXT NOT NULL DEFAULT 'ALT',
|
||||
side TEXT,
|
||||
strategy TEXT NOT NULL,
|
||||
strategy_version TEXT,
|
||||
config_hash TEXT,
|
||||
-- 原始特征
|
||||
cvd_fast_raw DOUBLE PRECISION,
|
||||
cvd_mid_raw DOUBLE PRECISION,
|
||||
cvd_day_raw DOUBLE PRECISION,
|
||||
cvd_fast_slope_raw DOUBLE PRECISION,
|
||||
p95_qty_raw DOUBLE PRECISION,
|
||||
p99_qty_raw DOUBLE PRECISION,
|
||||
atr_value DOUBLE PRECISION,
|
||||
atr_percentile DOUBLE PRECISION,
|
||||
oi_delta_raw DOUBLE PRECISION,
|
||||
ls_ratio_raw DOUBLE PRECISION,
|
||||
top_pos_raw DOUBLE PRECISION,
|
||||
coinbase_premium_raw DOUBLE PRECISION,
|
||||
obi_raw DOUBLE PRECISION,
|
||||
tiered_cvd_whale_raw DOUBLE PRECISION,
|
||||
-- 分层评分
|
||||
score_direction DOUBLE PRECISION,
|
||||
score_crowding DOUBLE PRECISION,
|
||||
score_environment DOUBLE PRECISION,
|
||||
score_aux DOUBLE PRECISION,
|
||||
score_total DOUBLE PRECISION,
|
||||
-- 决策结果
|
||||
gate_passed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
block_reason TEXT,
|
||||
price DOUBLE PRECISION,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sfe_sym_ts ON signal_feature_events(symbol, ts DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_sfe_strategy ON signal_feature_events(strategy, ts DESC);
|
||||
|
||||
-- V5.3 Label Events(延迟回填标签,评估信号预测能力)
|
||||
CREATE TABLE IF NOT EXISTS signal_label_events (
|
||||
event_id BIGINT PRIMARY KEY REFERENCES signal_feature_events(event_id) ON DELETE CASCADE,
|
||||
y_binary_15m SMALLINT,
|
||||
y_binary_30m SMALLINT,
|
||||
y_binary_60m SMALLINT,
|
||||
y_return_15m DOUBLE PRECISION,
|
||||
y_return_30m DOUBLE PRECISION,
|
||||
y_return_60m DOUBLE PRECISION,
|
||||
mfe_r_60m DOUBLE PRECISION,
|
||||
mae_r_60m DOUBLE PRECISION,
|
||||
label_ts BIGINT,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS live_config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
label TEXT,
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS live_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
ts BIGINT DEFAULT (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT,
|
||||
level TEXT,
|
||||
category TEXT,
|
||||
symbol TEXT,
|
||||
message TEXT,
|
||||
detail JSONB
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS live_trades (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
symbol TEXT NOT NULL,
|
||||
strategy TEXT NOT NULL,
|
||||
direction TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
entry_price DOUBLE PRECISION,
|
||||
exit_price DOUBLE PRECISION,
|
||||
entry_ts BIGINT,
|
||||
exit_ts BIGINT,
|
||||
sl_price DOUBLE PRECISION,
|
||||
tp1_price DOUBLE PRECISION,
|
||||
tp2_price DOUBLE PRECISION,
|
||||
tp1_hit BOOLEAN DEFAULT FALSE,
|
||||
score DOUBLE PRECISION,
|
||||
tier TEXT,
|
||||
pnl_r DOUBLE PRECISION,
|
||||
fee_usdt DOUBLE PRECISION DEFAULT 0,
|
||||
funding_fee_usdt DOUBLE PRECISION DEFAULT 0,
|
||||
risk_distance DOUBLE PRECISION,
|
||||
atr_at_entry DOUBLE PRECISION,
|
||||
score_factors JSONB,
|
||||
signal_id BIGINT,
|
||||
binance_order_id TEXT,
|
||||
fill_price DOUBLE PRECISION,
|
||||
slippage_bps DOUBLE PRECISION,
|
||||
protection_gap_ms BIGINT,
|
||||
signal_to_order_ms BIGINT,
|
||||
order_to_fill_ms BIGINT,
|
||||
qty DOUBLE PRECISION,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
@ -312,20 +403,21 @@ def ensure_partitions():
|
||||
now = datetime.datetime.utcnow()
|
||||
months = []
|
||||
for delta in range(0, 3): # 当月+下2个月
|
||||
d = now + datetime.timedelta(days=delta * 30)
|
||||
months.append(d.strftime("%Y%m"))
|
||||
m = now.month + delta
|
||||
y = now.year + (m - 1) // 12
|
||||
m = ((m - 1) % 12) + 1
|
||||
months.append(f"{y}{m:02d}")
|
||||
|
||||
with get_sync_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
for m in set(months):
|
||||
year = int(m[:4])
|
||||
month = int(m[4:])
|
||||
# 计算分区范围(毫秒时间戳)
|
||||
start = datetime.datetime(year, month, 1)
|
||||
start = datetime.datetime(year, month, 1, tzinfo=datetime.timezone.utc)
|
||||
if month == 12:
|
||||
end = datetime.datetime(year + 1, 1, 1)
|
||||
end = datetime.datetime(year + 1, 1, 1, tzinfo=datetime.timezone.utc)
|
||||
else:
|
||||
end = datetime.datetime(year, month + 1, 1)
|
||||
end = datetime.datetime(year, month + 1, 1, tzinfo=datetime.timezone.utc)
|
||||
start_ms = int(start.timestamp() * 1000)
|
||||
end_ms = int(end.timestamp() * 1000)
|
||||
part_name = f"agg_trades_{m}"
|
||||
@ -349,9 +441,23 @@ def init_schema():
|
||||
if stmt:
|
||||
try:
|
||||
cur.execute(stmt)
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
# 忽略已存在错误
|
||||
continue
|
||||
# 补全字段(向前兼容旧部署)
|
||||
migrations = [
|
||||
"ALTER TABLE paper_trades ADD COLUMN IF NOT EXISTS strategy VARCHAR(32) DEFAULT 'v51_baseline'",
|
||||
"ALTER TABLE paper_trades ADD COLUMN IF NOT EXISTS risk_distance DOUBLE PRECISION",
|
||||
"ALTER TABLE signal_indicators ADD COLUMN IF NOT EXISTS strategy TEXT",
|
||||
"ALTER TABLE signal_indicators ADD COLUMN IF NOT EXISTS factors JSONB",
|
||||
"ALTER TABLE signal_indicators ADD COLUMN IF NOT EXISTS atr_value DOUBLE PRECISION",
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS discord_id TEXT",
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS banned BOOLEAN DEFAULT FALSE",
|
||||
]
|
||||
for m in migrations:
|
||||
try:
|
||||
cur.execute(m)
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
conn.commit()
|
||||
ensure_partitions()
|
||||
|
||||
13
backend/ecosystem.dev.config.js
Normal file
13
backend/ecosystem.dev.config.js
Normal file
@ -0,0 +1,13 @@
|
||||
module.exports = {
|
||||
apps: [{
|
||||
name: "arb-dev-signal",
|
||||
script: "signal_engine.py",
|
||||
interpreter: "python3",
|
||||
cwd: "/root/Projects/arbitrage-engine/backend",
|
||||
env: {
|
||||
PG_HOST: "34.85.117.248",
|
||||
CLOUD_PG_HOST: "34.85.117.248",
|
||||
CLOUD_PG_ENABLED: "true"
|
||||
}
|
||||
}]
|
||||
};
|
||||
103
backend/fix_historical_pnl.py
Normal file
103
backend/fix_historical_pnl.py
Normal file
@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
fix_historical_pnl.py — 修正历史paper_trades中虚高的pnl_r
|
||||
问题:TP/SL_BE场景用了硬编码倍数(1.5R/2.25R),实际应为0.75R/1.125R
|
||||
修复:用(exit_price - entry_price) / risk_distance 重算
|
||||
"""
|
||||
import os, sys
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from db import get_sync_conn
|
||||
|
||||
FEE_RATE = 0.0005 # Taker 0.05%
|
||||
|
||||
def fix():
|
||||
with get_sync_conn() as conn:
|
||||
cur = conn.cursor()
|
||||
|
||||
# 读取所有已平仓记录
|
||||
cur.execute("""
|
||||
SELECT id, direction, entry_price, exit_price, tp1_price, tp2_price,
|
||||
sl_price, tp1_hit, status, pnl_r, atr_at_entry
|
||||
FROM paper_trades
|
||||
WHERE status NOT IN ('active', 'tp1_hit')
|
||||
ORDER BY id
|
||||
""")
|
||||
rows = cur.fetchall()
|
||||
|
||||
fixed = 0
|
||||
for row in rows:
|
||||
pid, direction, entry, exit_p, tp1, tp2, sl, tp1_hit, status, old_pnl, atr_entry = row
|
||||
|
||||
if entry is None or atr_entry is None or atr_entry <= 0:
|
||||
continue
|
||||
|
||||
risk_distance = 2.0 * 0.7 * atr_entry
|
||||
if risk_distance <= 0:
|
||||
continue
|
||||
|
||||
# 实盘模拟:TP/SL以限价单价格成交
|
||||
new_exit = exit_p # 默认不变
|
||||
|
||||
if status == "tp":
|
||||
new_exit = tp2 # TP以TP2价成交
|
||||
if direction == "LONG":
|
||||
tp1_r = (tp1 - entry) / risk_distance
|
||||
tp2_r = (tp2 - entry) / risk_distance
|
||||
else:
|
||||
tp1_r = (entry - tp1) / risk_distance
|
||||
tp2_r = (entry - tp2) / risk_distance
|
||||
new_pnl = 0.5 * tp1_r + 0.5 * tp2_r
|
||||
elif status == "sl_be":
|
||||
new_exit = sl # SL_BE以SL价成交(成本价附近)
|
||||
if direction == "LONG":
|
||||
tp1_r = (tp1 - entry) / risk_distance
|
||||
else:
|
||||
tp1_r = (entry - tp1) / risk_distance
|
||||
new_pnl = 0.5 * tp1_r
|
||||
elif status == "sl":
|
||||
new_exit = sl # SL以SL价成交
|
||||
if direction == "LONG":
|
||||
new_pnl = (sl - entry) / risk_distance
|
||||
else:
|
||||
new_pnl = (entry - sl) / risk_distance
|
||||
elif status == "timeout":
|
||||
new_exit = exit_p # 超时市价平仓,保持原exit
|
||||
if direction == "LONG":
|
||||
move = exit_p - entry
|
||||
else:
|
||||
move = entry - exit_p
|
||||
new_pnl = move / risk_distance
|
||||
if tp1_hit:
|
||||
tp1_r = abs(tp1 - entry) / risk_distance
|
||||
new_pnl = max(new_pnl, 0.5 * tp1_r)
|
||||
elif status == "signal_flip":
|
||||
new_exit = exit_p # 信号翻转市价平仓
|
||||
if direction == "LONG":
|
||||
new_pnl = (exit_p - entry) / risk_distance
|
||||
else:
|
||||
new_pnl = (entry - exit_p) / risk_distance
|
||||
else:
|
||||
continue
|
||||
|
||||
# 扣手续费
|
||||
fee_r = (2 * FEE_RATE * entry) / risk_distance if risk_distance > 0 else 0
|
||||
new_pnl -= fee_r
|
||||
new_pnl = round(new_pnl, 4)
|
||||
|
||||
need_update = abs(new_pnl - old_pnl) > 0.001 or (new_exit and exit_p and abs(new_exit - exit_p) > 0.0001)
|
||||
if need_update:
|
||||
print(f" #{pid} {status:10s} {direction:5s}: pnl {old_pnl:+.4f}R → {new_pnl:+.4f}R | exit {exit_p} → {new_exit}")
|
||||
cur.execute("UPDATE paper_trades SET pnl_r = %s, exit_price = %s WHERE id = %s", (new_pnl, new_exit, pid))
|
||||
fixed += 1
|
||||
|
||||
conn.commit()
|
||||
|
||||
# 汇总
|
||||
cur.execute("SELECT COUNT(*), SUM(pnl_r) FROM paper_trades WHERE status NOT IN ('active','tp1_hit')")
|
||||
total, total_pnl = cur.fetchone()
|
||||
print(f"\n修正了 {fixed}/{len(rows)} 笔交易")
|
||||
print(f"修正后总计: {total}笔, 总pnl={total_pnl:+.2f}R")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
fix()
|
||||
709
backend/live_executor.py
Normal file
709
backend/live_executor.py
Normal file
@ -0,0 +1,709 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Live Executor - 实盘交易执行模块
|
||||
监听PG NOTIFY接收新信号,调币安API执行交易
|
||||
|
||||
架构:
|
||||
signal_engine.py → NOTIFY new_signal → live_executor.py → Binance API
|
||||
|
||||
不影响模拟盘任何进程。
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
try:
|
||||
from dotenv import load_dotenv; load_dotenv(Path(__file__).parent / ".env")
|
||||
except ImportError:
|
||||
pass
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import psycopg2
|
||||
import psycopg2.extensions
|
||||
import aiohttp
|
||||
|
||||
# ============ 配置 ============
|
||||
|
||||
# 环境:testnet / production
|
||||
TRADE_ENV = os.getenv("TRADE_ENV", "testnet")
|
||||
|
||||
# 币安API端点
|
||||
BINANCE_ENDPOINTS = {
|
||||
"testnet": "https://testnet.binancefuture.com",
|
||||
"production": "https://fapi.binance.com",
|
||||
}
|
||||
BASE_URL = BINANCE_ENDPOINTS[TRADE_ENV]
|
||||
|
||||
# 数据库
|
||||
_DB_PASSWORD = os.getenv("DB_PASSWORD") or os.getenv("PG_PASS")
|
||||
if not _DB_PASSWORD:
|
||||
raise RuntimeError("DB_PASSWORD / PG_PASS 未设置,请在 .env 或环境变量中注入数据库密码")
|
||||
if not _DB_PASSWORD:
|
||||
print("FATAL: DB_PASSWORD 未设置(生产环境必须配置)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
DB_CONFIG = {
|
||||
"host": os.getenv("DB_HOST", "10.106.0.3"),
|
||||
"port": int(os.getenv("DB_PORT", "5432")),
|
||||
"dbname": os.getenv("DB_NAME", "arb_engine"),
|
||||
"user": os.getenv("DB_USER", "arb"),
|
||||
"password": _DB_PASSWORD,
|
||||
}
|
||||
|
||||
# 策略
|
||||
ENABLED_STRATEGIES = json.loads(os.getenv("LIVE_STRATEGIES", '["v52_8signals"]'))
|
||||
|
||||
# 风险参数(默认值,会被DB live_config覆盖)
|
||||
RISK_PER_TRADE_USD = float(os.getenv("RISK_PER_TRADE_USD", "2"))
|
||||
MAX_POSITIONS = int(os.getenv("MAX_POSITIONS", "4"))
|
||||
FEE_RATE = 0.0005 # Taker 0.05%
|
||||
_config_cache = {}
|
||||
_config_cache_ts = 0
|
||||
|
||||
def reload_live_config(conn):
|
||||
"""从live_config表读取配置,每60秒刷新一次"""
|
||||
global RISK_PER_TRADE_USD, MAX_POSITIONS, _config_cache, _config_cache_ts
|
||||
now = time.time()
|
||||
if now - _config_cache_ts < 60:
|
||||
return
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT key, value FROM live_config")
|
||||
for row in cur.fetchall():
|
||||
_config_cache[row[0]] = row[1]
|
||||
RISK_PER_TRADE_USD = float(_config_cache.get("risk_per_trade_usd", "2"))
|
||||
MAX_POSITIONS = int(_config_cache.get("max_positions", "4"))
|
||||
_config_cache_ts = now
|
||||
logger.info(f"📋 配置刷新: 1R=${RISK_PER_TRADE_USD} | 最大持仓={MAX_POSITIONS}")
|
||||
except Exception as e:
|
||||
logger.warning(f"读取live_config失败: {e}")
|
||||
|
||||
# 币种精度(从共用配置导入)
|
||||
from trade_config import SYMBOL_PRECISION
|
||||
|
||||
# 日志
|
||||
from logging.handlers import RotatingFileHandler
|
||||
_log_fmt = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
||||
logging.basicConfig(level=logging.INFO, format=_log_fmt)
|
||||
logger = logging.getLogger("live-executor")
|
||||
_fh = RotatingFileHandler("logs/live_executor.log", maxBytes=10*1024*1024, backupCount=5)
|
||||
_fh.setFormatter(logging.Formatter(_log_fmt))
|
||||
logger.addHandler(_fh)
|
||||
|
||||
# ============ API Key管理 ============
|
||||
|
||||
_api_key = None
|
||||
_secret_key = None
|
||||
|
||||
|
||||
def load_api_keys():
|
||||
"""从GCP Secret Manager或环境变量加载API Key"""
|
||||
global _api_key, _secret_key
|
||||
|
||||
# 优先环境变量(本地开发/测试用)
|
||||
_api_key = os.getenv("BINANCE_API_KEY")
|
||||
_secret_key = os.getenv("BINANCE_SECRET_KEY")
|
||||
|
||||
if _api_key and _secret_key:
|
||||
logger.info(f"API Key loaded from env (first 10: {_api_key[:10]}...)")
|
||||
return
|
||||
|
||||
# 从GCP Secret Manager读取
|
||||
try:
|
||||
from google.cloud import secretmanager
|
||||
client = secretmanager.SecretManagerServiceClient()
|
||||
project = os.getenv("GCP_PROJECT", "gen-lang-client-0835616737")
|
||||
|
||||
prefix = "binance-testnet" if TRADE_ENV == "testnet" else "binance-live"
|
||||
api_key_name = f"projects/{project}/secrets/{prefix}-api-key/versions/latest"
|
||||
secret_key_name = f"projects/{project}/secrets/{prefix}-secret-key/versions/latest"
|
||||
|
||||
_api_key = client.access_secret_version(name=api_key_name).payload.data.decode()
|
||||
_secret_key = client.access_secret_version(name=secret_key_name).payload.data.decode()
|
||||
logger.info(f"API Key loaded from GCP Secret Manager ({prefix}, first 10: {_api_key[:10]}...)")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load API keys: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# ============ 币安API工具 ============
|
||||
|
||||
def sign_params(params: dict) -> dict:
|
||||
"""HMAC SHA256签名"""
|
||||
params["timestamp"] = int(time.time() * 1000)
|
||||
query_string = urlencode(params)
|
||||
signature = hmac.new(
|
||||
_secret_key.encode(), query_string.encode(), hashlib.sha256
|
||||
).hexdigest()
|
||||
params["signature"] = signature
|
||||
return params
|
||||
|
||||
|
||||
async def binance_request(session: aiohttp.ClientSession, method: str, path: str, params: dict = None, signed: bool = True):
|
||||
"""发送币安API请求"""
|
||||
url = f"{BASE_URL}{path}"
|
||||
headers = {"X-MBX-APIKEY": _api_key}
|
||||
|
||||
if params is None:
|
||||
params = {}
|
||||
if signed:
|
||||
params = sign_params(params)
|
||||
|
||||
try:
|
||||
if method == "GET":
|
||||
async with session.get(url, params=params, headers=headers) as resp:
|
||||
data = await resp.json()
|
||||
if resp.status != 200:
|
||||
logger.error(f"Binance API error: {resp.status} {data}")
|
||||
return data, resp.status
|
||||
elif method == "POST":
|
||||
async with session.post(url, params=params, headers=headers) as resp:
|
||||
data = await resp.json()
|
||||
if resp.status != 200:
|
||||
logger.error(f"Binance API error: {resp.status} {data}")
|
||||
return data, resp.status
|
||||
elif method == "DELETE":
|
||||
async with session.delete(url, params=params, headers=headers) as resp:
|
||||
data = await resp.json()
|
||||
if resp.status != 200:
|
||||
logger.error(f"Binance API error: {resp.status} {data}")
|
||||
return data, resp.status
|
||||
except Exception as e:
|
||||
logger.error(f"Binance request failed: {e}")
|
||||
return {"error": str(e)}, 500
|
||||
|
||||
|
||||
# ============ 交易执行 ============
|
||||
|
||||
async def set_leverage_and_margin(session: aiohttp.ClientSession, symbol: str, leverage: int = 20):
|
||||
"""设置杠杆和逐仓模式"""
|
||||
# 设置逐仓
|
||||
await binance_request(session, "POST", "/fapi/v1/marginType", {
|
||||
"symbol": symbol, "marginType": "ISOLATED"
|
||||
})
|
||||
# 设置杠杆
|
||||
await binance_request(session, "POST", "/fapi/v1/leverage", {
|
||||
"symbol": symbol, "leverage": leverage
|
||||
})
|
||||
logger.info(f"[{symbol}] 设置逐仓 + {leverage}x杠杆")
|
||||
|
||||
|
||||
async def place_market_order(session: aiohttp.ClientSession, symbol: str, side: str, quantity: float):
|
||||
"""市价开仓"""
|
||||
prec = SYMBOL_PRECISION.get(symbol, {"qty": 3})
|
||||
qty_str = f"{quantity:.{prec['qty']}f}"
|
||||
|
||||
params = {
|
||||
"symbol": symbol,
|
||||
"side": side,
|
||||
"type": "MARKET",
|
||||
"quantity": qty_str,
|
||||
}
|
||||
|
||||
t_submit = time.time() * 1000
|
||||
data, status = await binance_request(session, "POST", "/fapi/v1/order", params)
|
||||
t_ack = time.time() * 1000
|
||||
|
||||
if status == 200:
|
||||
logger.info(f"[{symbol}] ✅ 市价{side} {qty_str}个 | orderId={data.get('orderId')} | 延迟={t_ack-t_submit:.0f}ms")
|
||||
return data, status, t_submit, t_ack
|
||||
|
||||
|
||||
async def place_stop_order(session: aiohttp.ClientSession, symbol: str, side: str, stop_price: float, quantity: float, order_type: str = "STOP_MARKET"):
|
||||
"""挂止损/止盈单"""
|
||||
prec = SYMBOL_PRECISION.get(symbol, {"qty": 3, "price": 2})
|
||||
qty_str = f"{quantity:.{prec['qty']}f}"
|
||||
price_str = f"{stop_price:.{prec['price']}f}"
|
||||
|
||||
params = {
|
||||
"symbol": symbol,
|
||||
"side": side,
|
||||
"type": order_type,
|
||||
"stopPrice": price_str,
|
||||
"quantity": qty_str,
|
||||
"reduceOnly": "true",
|
||||
}
|
||||
|
||||
# 先尝试传统endpoint
|
||||
data, status = await binance_request(session, "POST", "/fapi/v1/order", params)
|
||||
|
||||
# 如果不支持(-4120),降级到Algo Order API
|
||||
if status == 400 and isinstance(data, dict) and data.get("code") == -4120:
|
||||
logger.info(f"[{symbol}] 传统endpoint不支持{order_type},切换Algo Order API")
|
||||
algo_params = {
|
||||
"symbol": symbol,
|
||||
"side": side,
|
||||
"type": order_type,
|
||||
"stopPrice": price_str,
|
||||
"quantity": qty_str,
|
||||
"reduceOnly": "true",
|
||||
}
|
||||
data, status = await binance_request(session, "POST", "/fapi/v1/algo/order", algo_params)
|
||||
if status == 200:
|
||||
logger.info(f"[{symbol}] 📌 挂{order_type} {side} @ {price_str} qty={qty_str} | orderId={data.get('orderId')}")
|
||||
return data, status
|
||||
|
||||
|
||||
async def cancel_all_orders(session: aiohttp.ClientSession, symbol: str):
|
||||
"""取消某币种所有挂单"""
|
||||
data, status = await binance_request(session, "DELETE", "/fapi/v1/allOpenOrders", {"symbol": symbol})
|
||||
if status == 200:
|
||||
logger.info(f"[{symbol}] 🗑 已取消所有挂单")
|
||||
return data, status
|
||||
|
||||
|
||||
async def get_position(session: aiohttp.ClientSession, symbol: str):
|
||||
"""查询当前持仓"""
|
||||
data, status = await binance_request(session, "GET", "/fapi/v2/positionRisk", {"symbol": symbol})
|
||||
if status == 200 and isinstance(data, list):
|
||||
for pos in data:
|
||||
if pos.get("symbol") == symbol and float(pos.get("positionAmt", 0)) != 0:
|
||||
return pos
|
||||
return None
|
||||
|
||||
|
||||
async def get_account_balance(session: aiohttp.ClientSession):
|
||||
"""查询账户余额"""
|
||||
data, status = await binance_request(session, "GET", "/fapi/v2/balance")
|
||||
if status == 200 and isinstance(data, list):
|
||||
for asset in data:
|
||||
if asset.get("asset") == "USDT":
|
||||
return float(asset.get("availableBalance", 0))
|
||||
return 0
|
||||
|
||||
|
||||
# ============ 核心:开仓流程 ============
|
||||
|
||||
async def execute_entry(session: aiohttp.ClientSession, signal: dict, db_conn):
|
||||
"""
|
||||
完整开仓流程:
|
||||
1. 检查余额和持仓数
|
||||
2. 计算仓位大小
|
||||
3. 市价开仓
|
||||
4. 挂SL + TP1 + TP2保护单
|
||||
5. 写live_trades表
|
||||
6. 记录延迟指标
|
||||
"""
|
||||
symbol = signal["symbol"]
|
||||
direction = signal["direction"]
|
||||
score = signal["score"]
|
||||
tier = signal.get("tier", "standard")
|
||||
strategy = signal["strategy"]
|
||||
risk_distance = signal["risk_distance"]
|
||||
sl_price = signal["sl_price"]
|
||||
tp1_price = signal["tp1_price"]
|
||||
tp2_price = signal["tp2_price"]
|
||||
atr = signal.get("atr", 0)
|
||||
signal_ts = signal["signal_ts"]
|
||||
signal_id = signal.get("signal_id")
|
||||
factors = signal.get("factors")
|
||||
|
||||
t_signal = signal_ts # 信号时间戳(ms)
|
||||
|
||||
# 刷新配置(每60秒从DB读一次)
|
||||
reload_live_config(db_conn)
|
||||
|
||||
# 0. 信号新鲜度检查(超过2秒弃仓)
|
||||
SIGNAL_MAX_AGE_MS = 2000
|
||||
signal_age_ms = time.time() * 1000 - t_signal
|
||||
if signal_age_ms > SIGNAL_MAX_AGE_MS:
|
||||
logger.warning(f"[{symbol}] ❌ 信号过期: {signal_age_ms:.0f}ms > {SIGNAL_MAX_AGE_MS}ms,弃仓")
|
||||
_log_event(db_conn, "warn", "trade",
|
||||
f"信号过期弃仓 {direction} {symbol} | age={signal_age_ms:.0f}ms | score={score}",
|
||||
symbol, {"signal_age_ms": round(signal_age_ms), "score": score})
|
||||
return None
|
||||
|
||||
# 0.5 检查风控状态(Fail-Closed: 状态异常时拒绝开仓)
|
||||
state_path = "/tmp/risk_guard_state.json"
|
||||
try:
|
||||
st = os.stat(state_path)
|
||||
if time.time() - st.st_mtime > 15:
|
||||
logger.error(f"[{symbol}] ❌ 风控状态文件超过15秒未更新,risk_guard可能失联,拒绝开仓")
|
||||
_log_event(db_conn, "critical", "risk", "风控失联(状态文件过期),拒绝开仓", symbol)
|
||||
return None
|
||||
with open(state_path) as f:
|
||||
risk_state = json.load(f)
|
||||
if risk_state.get("block_new_entries"):
|
||||
logger.warning(f"[{symbol}] ❌ 风控禁止新开仓: {risk_state.get('circuit_break_reason', '未知原因')}")
|
||||
return None
|
||||
if risk_state.get("reduce_only"):
|
||||
logger.warning(f"[{symbol}] ❌ 只减仓模式,拒绝开仓")
|
||||
return None
|
||||
except FileNotFoundError:
|
||||
logger.error(f"[{symbol}] ❌ 风控状态文件不存在,risk_guard未运行,拒绝开仓")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"[{symbol}] ❌ 读取风控状态失败: {e},拒绝开仓")
|
||||
return None
|
||||
|
||||
# 检查前端紧急操作指令
|
||||
try:
|
||||
with open("/tmp/risk_guard_emergency.json") as f:
|
||||
emergency = json.load(f)
|
||||
action = emergency.get("action")
|
||||
if action in ("close_all", "block_new"):
|
||||
logger.warning(f"[{symbol}] ❌ 紧急指令生效: {action},拒绝开仓")
|
||||
return None
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 1. 检查余额
|
||||
balance = await get_account_balance(session)
|
||||
if balance < RISK_PER_TRADE_USD * 2:
|
||||
logger.warning(f"[{symbol}] ❌ 余额不足: ${balance:.2f} < ${RISK_PER_TRADE_USD * 2}")
|
||||
return None
|
||||
|
||||
# 2. 检查持仓数
|
||||
cur = db_conn.cursor()
|
||||
cur.execute("SELECT count(*) FROM live_trades WHERE strategy=%s AND status='active'", (strategy,))
|
||||
active_count = cur.fetchone()[0]
|
||||
if active_count >= MAX_POSITIONS:
|
||||
logger.warning(f"[{symbol}] ❌ 已达最大持仓数 {MAX_POSITIONS}")
|
||||
return None
|
||||
|
||||
# 3. 检查是否已有同币种持仓(不区分策略,币安单向模式下同币共享净仓位)
|
||||
cur.execute("SELECT id, strategy FROM live_trades WHERE symbol=%s AND status IN ('active', 'tp1_hit')", (symbol,))
|
||||
existing = cur.fetchone()
|
||||
if existing:
|
||||
logger.info(f"[{symbol}] ⏭ 已有活跃持仓(id={existing[0]}, strategy={existing[1]}),跳过")
|
||||
return None
|
||||
|
||||
# 4. 设置杠杆和逐仓
|
||||
await set_leverage_and_margin(session, symbol)
|
||||
|
||||
# 5. 计算仓位
|
||||
# position_size(个) = risk_usd / risk_distance
|
||||
qty = RISK_PER_TRADE_USD / risk_distance
|
||||
prec = SYMBOL_PRECISION.get(symbol, {"qty": 3})
|
||||
qty = round(qty, prec["qty"])
|
||||
|
||||
# 检查最小名义值
|
||||
# 需要当前价来估算
|
||||
entry_side = "BUY" if direction == "LONG" else "SELL"
|
||||
|
||||
# 6. 市价开仓
|
||||
t_before_order = time.time() * 1000
|
||||
order_data, order_status, t_submit, t_ack = await place_market_order(session, symbol, entry_side, qty)
|
||||
|
||||
if order_status != 200:
|
||||
logger.error(f"[{symbol}] ❌ 开仓失败: {order_data}")
|
||||
return None
|
||||
|
||||
# 解析成交信息
|
||||
order_id = str(order_data.get("orderId", ""))
|
||||
fill_price = float(order_data.get("avgPrice", 0))
|
||||
if fill_price == 0:
|
||||
fill_price = float(order_data.get("price", 0))
|
||||
t_fill = time.time() * 1000
|
||||
|
||||
signal_to_order_ms = int(t_submit - t_signal)
|
||||
order_to_fill_ms = int(t_fill - t_submit)
|
||||
|
||||
# 计算滑点
|
||||
signal_price = signal.get("signal_price", fill_price)
|
||||
if signal_price > 0:
|
||||
if direction == "LONG":
|
||||
slippage_bps = (fill_price - signal_price) / signal_price * 10000
|
||||
else:
|
||||
slippage_bps = (signal_price - fill_price) / signal_price * 10000
|
||||
else:
|
||||
slippage_bps = 0
|
||||
|
||||
logger.info(f"[{symbol}] 📊 成交价={fill_price} | 信号价={signal_price} | 滑点={slippage_bps:.1f}bps | 信号→下单={signal_to_order_ms}ms | 下单→成交={order_to_fill_ms}ms")
|
||||
|
||||
# 7. 挂保护单(SL + TP1半仓 + TP2半仓)
|
||||
close_side = "SELL" if direction == "LONG" else "BUY"
|
||||
half_qty = round(qty / 2, prec["qty"])
|
||||
other_half = round(qty - half_qty, prec["qty"])
|
||||
|
||||
# SL - 全仓(失败重试2次,3次都失败则紧急平仓)
|
||||
t_before_sl = time.time() * 1000
|
||||
sl_data, sl_status = await place_stop_order(session, symbol, close_side, sl_price, qty, "STOP_MARKET")
|
||||
if sl_status != 200:
|
||||
for retry in range(2):
|
||||
logger.warning(f"[{symbol}] SL挂单重试 {retry+1}/2...")
|
||||
await asyncio.sleep(0.3)
|
||||
sl_data, sl_status = await place_stop_order(session, symbol, close_side, sl_price, qty, "STOP_MARKET")
|
||||
if sl_status == 200:
|
||||
break
|
||||
if sl_status != 200:
|
||||
logger.error(f"[{symbol}] ❌ SL 3次全部失败,紧急reduceOnly平仓! data={sl_data}")
|
||||
# 查真实持仓量,用reduceOnly市价平仓(避免反向开仓)
|
||||
emergency_pos = await get_position(session, symbol)
|
||||
if emergency_pos:
|
||||
emergency_amt = abs(float(emergency_pos.get("positionAmt", 0)))
|
||||
emergency_prec = SYMBOL_PRECISION.get(symbol, {"qty": 3})
|
||||
emergency_qty_str = f"{emergency_amt:.{emergency_prec['qty']}f}"
|
||||
close_data, close_status = await binance_request(session, "POST", "/fapi/v1/order", {
|
||||
"symbol": symbol, "side": close_side, "type": "MARKET",
|
||||
"quantity": emergency_qty_str, "reduceOnly": "true",
|
||||
})
|
||||
if close_status != 200:
|
||||
logger.error(f"[{symbol}] ❌ 紧急平仓也失败! close_data={close_data}")
|
||||
_log_event(db_conn, "critical", "trade",
|
||||
f"SL失败后紧急平仓也失败,需人工介入", symbol,
|
||||
{"sl_data": str(sl_data), "close_data": str(close_data)})
|
||||
else:
|
||||
logger.info(f"[{symbol}] ✅ 紧急reduceOnly平仓成功 qty={emergency_qty_str}")
|
||||
_log_event(db_conn, "critical", "trade", f"SL挂单3次失败,已紧急reduceOnly平仓", symbol, {"sl_data": str(sl_data)})
|
||||
else:
|
||||
logger.warning(f"[{symbol}] SL失败但币安已无持仓,无需平仓")
|
||||
_log_event(db_conn, "critical", "trade", f"SL挂单3次失败,但币安无持仓", symbol, {"sl_data": str(sl_data)})
|
||||
return None
|
||||
t_after_sl = time.time() * 1000
|
||||
protection_gap_ms = int(t_after_sl - t_fill)
|
||||
|
||||
# TP1 - 半仓
|
||||
tp1_type = "TAKE_PROFIT_MARKET"
|
||||
await place_stop_order(session, symbol, close_side, tp1_price, half_qty, tp1_type)
|
||||
|
||||
# TP2 - 半仓
|
||||
await place_stop_order(session, symbol, close_side, tp2_price, other_half, tp1_type)
|
||||
|
||||
logger.info(f"[{symbol}] 🛡 保护单已挂 | SL={sl_price} TP1={tp1_price}(半仓) TP2={tp2_price}(半仓) | 裸奔={protection_gap_ms}ms")
|
||||
|
||||
# 8. 写DB
|
||||
cur.execute("""
|
||||
INSERT INTO live_trades (
|
||||
symbol, strategy, direction, entry_price, entry_ts,
|
||||
sl_price, tp1_price, tp2_price, score, tier, status,
|
||||
risk_distance, atr_at_entry, score_factors, signal_id,
|
||||
binance_order_id, fill_price, slippage_bps,
|
||||
protection_gap_ms, signal_to_order_ms, order_to_fill_ms,
|
||||
qty
|
||||
) VALUES (
|
||||
%s, %s, %s, %s, %s,
|
||||
%s, %s, %s, %s, %s, 'active',
|
||||
%s, %s, %s, %s,
|
||||
%s, %s, %s,
|
||||
%s, %s, %s,
|
||||
%s
|
||||
) RETURNING id
|
||||
""", (
|
||||
symbol, strategy, direction, fill_price, int(t_fill),
|
||||
sl_price, tp1_price, tp2_price, score, tier,
|
||||
risk_distance, atr, json.dumps(factors) if factors else None, signal_id,
|
||||
order_id, fill_price, round(slippage_bps, 2),
|
||||
protection_gap_ms, signal_to_order_ms, order_to_fill_ms,
|
||||
qty,
|
||||
))
|
||||
trade_id = cur.fetchone()[0]
|
||||
db_conn.commit()
|
||||
|
||||
logger.info(f"[{symbol}] ✅ 实盘开仓完成 | trade_id={trade_id} | {direction} @ {fill_price} | score={score} | 策略={strategy}")
|
||||
|
||||
# 写event
|
||||
_log_event(db_conn, "info", "trade", f"{direction} {symbol} @ {fill_price} | score={score} | 滑点={slippage_bps:.1f}bps", symbol,
|
||||
{"trade_id": trade_id, "score": score, "fill_price": fill_price, "slippage_bps": round(slippage_bps, 2)})
|
||||
|
||||
return trade_id
|
||||
|
||||
|
||||
# ============ 事件日志 ============
|
||||
|
||||
def _log_event(conn, level, category, message, symbol=None, detail=None):
|
||||
"""写live_events表"""
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"INSERT INTO live_events (level, category, symbol, message, detail) VALUES (%s, %s, %s, %s, %s)",
|
||||
(level, category, symbol, message, json.dumps(detail) if detail else None)
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ============ 信号监听 ============
|
||||
|
||||
def get_db_connection():
|
||||
"""获取DB连接"""
|
||||
conn = psycopg2.connect(**DB_CONFIG)
|
||||
conn.set_isolation_level(psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT)
|
||||
return conn
|
||||
|
||||
|
||||
def fetch_pending_signals(conn):
|
||||
"""查询未处理的新信号"""
|
||||
cur = conn.cursor()
|
||||
# 查最近60秒内的新信号(score>=阈值、策略匹配、未被live_executor处理过)
|
||||
cur.execute("""
|
||||
SELECT si.id, si.symbol, si.signal, si.score, si.ts, si.factors, si.strategy,
|
||||
si.price
|
||||
FROM signal_indicators si
|
||||
WHERE si.signal IS NOT NULL
|
||||
AND si.signal != ''
|
||||
AND si.strategy = ANY(%s)
|
||||
AND si.ts > extract(epoch from now()) * 1000 - 60000
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM live_trades lt
|
||||
WHERE lt.signal_id = si.id AND lt.strategy = si.strategy
|
||||
)
|
||||
ORDER BY si.ts DESC
|
||||
""", (ENABLED_STRATEGIES,))
|
||||
rows = cur.fetchall()
|
||||
signals = []
|
||||
for row in rows:
|
||||
factors = row[5]
|
||||
if isinstance(factors, str):
|
||||
try:
|
||||
factors = json.loads(factors)
|
||||
except:
|
||||
factors = None
|
||||
|
||||
signals.append({
|
||||
"signal_id": row[0],
|
||||
"symbol": row[1],
|
||||
"direction": row[2],
|
||||
"score": row[3],
|
||||
"signal_ts": row[4],
|
||||
"factors": factors,
|
||||
"strategy": row[6],
|
||||
"signal_price": float(row[7]) if row[7] else 0,
|
||||
})
|
||||
return signals
|
||||
|
||||
|
||||
def enrich_signal_with_trade_params(signal: dict, conn):
|
||||
"""从策略配置计算TP/SL参数"""
|
||||
symbol = signal["symbol"]
|
||||
strategy = signal["strategy"]
|
||||
direction = signal["direction"]
|
||||
|
||||
# 读策略配置JSON
|
||||
config_map = {
|
||||
"v51_baseline": {"sl_multiplier": 1.4, "tp1_multiplier": 1.05, "tp2_multiplier": 2.1},
|
||||
"v52_8signals": {"sl_multiplier": 2.1, "tp1_multiplier": 1.4, "tp2_multiplier": 3.15},
|
||||
}
|
||||
cfg = config_map.get(strategy)
|
||||
if not cfg:
|
||||
logger.warning(f"[{symbol}] 未知策略: {strategy}")
|
||||
return False
|
||||
|
||||
# 从signal_indicators读ATR和价格
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT atr_5m, price FROM signal_indicators
|
||||
WHERE id = %s
|
||||
""", (signal["signal_id"],))
|
||||
row = cur.fetchone()
|
||||
if not row or not row[0]:
|
||||
logger.warning(f"[{symbol}] signal_id={signal['signal_id']} 无ATR数据")
|
||||
return False
|
||||
|
||||
atr = float(row[0])
|
||||
price = float(row[1])
|
||||
signal["atr"] = atr
|
||||
signal["signal_price"] = price
|
||||
|
||||
# 计算SL/TP(直接用ATR,不乘0.7)
|
||||
risk_distance = cfg["sl_multiplier"] * atr
|
||||
signal["risk_distance"] = risk_distance
|
||||
|
||||
if direction == "LONG":
|
||||
signal["sl_price"] = price - cfg["sl_multiplier"] * atr
|
||||
signal["tp1_price"] = price + cfg["tp1_multiplier"] * atr
|
||||
signal["tp2_price"] = price + cfg["tp2_multiplier"] * atr
|
||||
else: # SHORT
|
||||
signal["sl_price"] = price + cfg["sl_multiplier"] * atr
|
||||
signal["tp1_price"] = price - cfg["tp1_multiplier"] * atr
|
||||
signal["tp2_price"] = price - cfg["tp2_multiplier"] * atr
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# ============ 主循环 ============
|
||||
|
||||
async def main():
|
||||
"""主循环:监听PG NOTIFY + 定时轮询fallback"""
|
||||
logger.info("=" * 60)
|
||||
logger.info(f"🚀 Live Executor 启动 | 环境={TRADE_ENV} | 策略={ENABLED_STRATEGIES}")
|
||||
logger.info(f" 风险/笔=${RISK_PER_TRADE_USD} | 最大持仓={MAX_POSITIONS}")
|
||||
logger.info(f" API端点={BASE_URL}")
|
||||
logger.info("=" * 60)
|
||||
|
||||
load_api_keys()
|
||||
|
||||
# 测试API连通性
|
||||
async with aiohttp.ClientSession() as http_session:
|
||||
balance = await get_account_balance(http_session)
|
||||
logger.info(f"💰 账户余额: ${balance:.2f} USDT")
|
||||
|
||||
if balance <= 0:
|
||||
logger.error("❌ 无法获取余额或余额为0,请检查API Key")
|
||||
return
|
||||
|
||||
# DB连接(用于LISTEN)
|
||||
listen_conn = get_db_connection()
|
||||
cur = listen_conn.cursor()
|
||||
cur.execute("LISTEN new_signal;")
|
||||
logger.info("👂 已注册PG LISTEN new_signal")
|
||||
|
||||
# 工作DB连接
|
||||
work_conn = psycopg2.connect(**DB_CONFIG)
|
||||
|
||||
def ensure_db_conn(conn):
|
||||
try:
|
||||
conn.cursor().execute("SELECT 1")
|
||||
return conn
|
||||
except Exception:
|
||||
logger.warning("⚠️ DB连接断开,重连中...")
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
return psycopg2.connect(**DB_CONFIG)
|
||||
|
||||
async with aiohttp.ClientSession() as http_session:
|
||||
while True:
|
||||
try:
|
||||
# 检查PG NOTIFY(非阻塞)
|
||||
got_notify = False
|
||||
if listen_conn.poll() == psycopg2.extensions.POLL_OK:
|
||||
while listen_conn.notifies:
|
||||
notify = listen_conn.notifies.pop(0)
|
||||
logger.info(f"📡 收到NOTIFY: {notify.payload}")
|
||||
got_notify = True
|
||||
|
||||
work_conn = ensure_db_conn(work_conn)
|
||||
|
||||
# 获取待处理信号(NOTIFY + 轮询双保险)
|
||||
signals = await asyncio.to_thread(fetch_pending_signals, work_conn)
|
||||
|
||||
for sig in signals:
|
||||
# 补充TP/SL参数
|
||||
if not enrich_signal_with_trade_params(sig, work_conn):
|
||||
continue
|
||||
|
||||
logger.info(f"[{sig['symbol']}] 🎯 新信号: {sig['direction']} score={sig['score']} strategy={sig['strategy']}")
|
||||
|
||||
# 执行开仓
|
||||
trade_id = await execute_entry(http_session, sig, work_conn)
|
||||
if trade_id:
|
||||
logger.info(f"[{sig['symbol']}] ✅ trade_id={trade_id} 开仓成功")
|
||||
|
||||
if not got_notify:
|
||||
await asyncio.sleep(1) # 无NOTIFY时才sleep,有NOTIFY立即处理下一轮
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("🛑 收到退出信号")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 主循环异常: {e}", exc_info=True)
|
||||
await asyncio.sleep(5)
|
||||
|
||||
listen_conn.close()
|
||||
work_conn.close()
|
||||
logger.info("Live Executor 已停止")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
0
backend/logs/.gitkeep
Normal file
0
backend/logs/.gitkeep
Normal file
1306
backend/main.py
1306
backend/main.py
File diff suppressed because it is too large
Load Diff
@ -12,11 +12,13 @@ from psycopg2.extras import Json
|
||||
SYMBOLS = ["BTCUSDT", "ETHUSDT", "XRPUSDT", "SOLUSDT"]
|
||||
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_DB = os.getenv("PG_DB", "arb_engine")
|
||||
PG_USER = os.getenv("PG_USER", "arb")
|
||||
PG_PASS = os.getenv("PG_PASS", "arb_engine_2026")
|
||||
PG_PASS = os.getenv("PG_PASS")
|
||||
if not PG_PASS:
|
||||
raise RuntimeError("PG_PASS 未设置,请在 .env 或环境变量中注入数据库密码")
|
||||
|
||||
TABLE_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS market_indicators (
|
||||
@ -113,7 +115,9 @@ class MarketDataCollector:
|
||||
"BTCUSDT": "BTC-USD",
|
||||
"ETHUSDT": "ETH-USD",
|
||||
}
|
||||
coinbase_pair = pair_map[symbol]
|
||||
coinbase_pair = pair_map.get(symbol)
|
||||
if not coinbase_pair:
|
||||
return # XRP/SOL无Coinbase数据,跳过
|
||||
|
||||
binance_url = "https://api.binance.com/api/v3/ticker/price"
|
||||
coinbase_url = f"https://api.coinbase.com/v2/prices/{coinbase_pair}/spot"
|
||||
@ -136,13 +140,140 @@ class MarketDataCollector:
|
||||
self.save_indicator(symbol, "coinbase_premium", ts, payload)
|
||||
|
||||
async def collect_funding_rate(self, session: aiohttp.ClientSession, symbol: str) -> None:
|
||||
endpoint = "https://fapi.binance.com/fapi/v1/fundingRate"
|
||||
data = await self.fetch_json(session, endpoint, {"symbol": symbol, "limit": 1})
|
||||
endpoint = "https://fapi.binance.com/fapi/v1/premiumIndex"
|
||||
data = await self.fetch_json(session, endpoint, {"symbol": symbol})
|
||||
if not data:
|
||||
raise RuntimeError("empty response")
|
||||
item = data[0]
|
||||
ts = int(item.get("fundingTime") or int(time.time() * 1000))
|
||||
self.save_indicator(symbol, "funding_rate", ts, item)
|
||||
# premiumIndex returns a single object (not array)
|
||||
item = data if isinstance(data, dict) else data[0]
|
||||
# Use current time as timestamp so every 5-min poll stores a new row
|
||||
ts = int(time.time() * 1000)
|
||||
payload = {
|
||||
"symbol": item.get("symbol"),
|
||||
"markPrice": item.get("markPrice"),
|
||||
"indexPrice": item.get("indexPrice"),
|
||||
"lastFundingRate": item.get("lastFundingRate"),
|
||||
"nextFundingTime": item.get("nextFundingTime"),
|
||||
"interestRate": item.get("interestRate"),
|
||||
"fundingRate": item.get("lastFundingRate"), # compat: signal_engine reads 'fundingRate'
|
||||
"time": ts,
|
||||
}
|
||||
self.save_indicator(symbol, "funding_rate", ts, payload)
|
||||
|
||||
async def collect_obi_depth(self, session: aiohttp.ClientSession, symbol: str) -> None:
|
||||
"""
|
||||
OBI(订单簿失衡)采集 — Phase 2 BTC gate-control 核心特征
|
||||
计算:(bid_vol - ask_vol) / (bid_vol + ask_vol),范围[-1,1]
|
||||
正值=买压大,负值=卖压大
|
||||
"""
|
||||
endpoint = "https://fapi.binance.com/fapi/v1/depth"
|
||||
data = await self.fetch_json(session, endpoint, {"symbol": symbol, "limit": 10})
|
||||
ts = int(time.time() * 1000)
|
||||
|
||||
bids = data.get("bids", [])
|
||||
asks = data.get("asks", [])
|
||||
bid_vol = sum(float(b[1]) for b in bids)
|
||||
ask_vol = sum(float(a[1]) for a in asks)
|
||||
total_vol = bid_vol + ask_vol
|
||||
obi = (bid_vol - ask_vol) / total_vol if total_vol > 0 else 0.0
|
||||
|
||||
best_bid = float(bids[0][0]) if bids else 0.0
|
||||
best_ask = float(asks[0][0]) if asks else 0.0
|
||||
spread_bps = ((best_ask - best_bid) / best_bid * 10000) if best_bid > 0 else 0.0
|
||||
|
||||
payload = {
|
||||
"symbol": symbol,
|
||||
"obi": round(obi, 6),
|
||||
"bid_vol_10": round(bid_vol, 4),
|
||||
"ask_vol_10": round(ask_vol, 4),
|
||||
"best_bid": best_bid,
|
||||
"best_ask": best_ask,
|
||||
"spread_bps": round(spread_bps, 3),
|
||||
}
|
||||
self.save_indicator(symbol, "obi_depth_10", ts, payload)
|
||||
|
||||
async def collect_spot_perp_divergence(self, session: aiohttp.ClientSession, symbol: str) -> None:
|
||||
"""
|
||||
期现背离采集 — Phase 2 BTC gate-control 核心特征
|
||||
divergence = (spot - mark) / mark,正值=现货溢价,负值=现货折价
|
||||
"""
|
||||
spot_url = "https://api.binance.com/api/v3/ticker/price"
|
||||
perp_url = "https://fapi.binance.com/fapi/v1/premiumIndex"
|
||||
|
||||
spot_data, perp_data = await asyncio.gather(
|
||||
self.fetch_json(session, spot_url, {"symbol": symbol}),
|
||||
self.fetch_json(session, perp_url, {"symbol": symbol}),
|
||||
)
|
||||
ts = int(time.time() * 1000)
|
||||
|
||||
spot_price = float(spot_data["price"])
|
||||
mark_price = float(perp_data["markPrice"])
|
||||
index_price = float(perp_data.get("indexPrice", mark_price))
|
||||
divergence = (spot_price - mark_price) / mark_price if mark_price > 0 else 0.0
|
||||
|
||||
payload = {
|
||||
"symbol": symbol,
|
||||
"spot_price": spot_price,
|
||||
"mark_price": mark_price,
|
||||
"index_price": index_price,
|
||||
"divergence": round(divergence, 8),
|
||||
"divergence_bps": round(divergence * 10000, 3),
|
||||
}
|
||||
self.save_indicator(symbol, "spot_perp_divergence", ts, payload)
|
||||
|
||||
async def collect_tiered_cvd_whale(self, session: aiohttp.ClientSession, symbol: str) -> None:
|
||||
"""
|
||||
巨鲸CVD分层采集 — Phase 2 BTC gate-control 核心特征
|
||||
分层:small(<$10k), medium($10k-$100k), whale(>$100k)
|
||||
net_cvd = buy_usd - sell_usd(正=净买入)
|
||||
"""
|
||||
endpoint = "https://fapi.binance.com/fapi/v1/aggTrades"
|
||||
data = await self.fetch_json(session, endpoint, {"symbol": symbol, "limit": 500})
|
||||
ts = int(time.time() * 1000)
|
||||
|
||||
tiers = {
|
||||
"small": {"buy": 0.0, "sell": 0.0},
|
||||
"medium": {"buy": 0.0, "sell": 0.0},
|
||||
"whale": {"buy": 0.0, "sell": 0.0},
|
||||
}
|
||||
|
||||
for trade in data:
|
||||
price = float(trade["p"])
|
||||
qty = float(trade["q"])
|
||||
usd_val = price * qty
|
||||
is_sell = trade["m"] # m=True 表示卖单(taker卖)
|
||||
|
||||
if usd_val < 10_000:
|
||||
tier = "small"
|
||||
elif usd_val < 100_000:
|
||||
tier = "medium"
|
||||
else:
|
||||
tier = "whale"
|
||||
|
||||
if is_sell:
|
||||
tiers[tier]["sell"] += usd_val
|
||||
else:
|
||||
tiers[tier]["buy"] += usd_val
|
||||
|
||||
result = {}
|
||||
for name, t in tiers.items():
|
||||
buy, sell = t["buy"], t["sell"]
|
||||
net = buy - sell
|
||||
total = buy + sell
|
||||
result[name] = {
|
||||
"buy_usd": round(buy, 2),
|
||||
"sell_usd": round(sell, 2),
|
||||
"net_cvd": round(net, 2),
|
||||
"cvd_ratio": round(net / total, 4) if total > 0 else 0.0,
|
||||
}
|
||||
|
||||
payload = {
|
||||
"symbol": symbol,
|
||||
"tiers": result,
|
||||
"whale_net_cvd": result["whale"]["net_cvd"],
|
||||
"whale_cvd_ratio": result["whale"]["cvd_ratio"],
|
||||
}
|
||||
self.save_indicator(symbol, "tiered_cvd_whale", ts, payload)
|
||||
|
||||
async def collect_symbol(self, session: aiohttp.ClientSession, symbol: str) -> None:
|
||||
tasks = [
|
||||
@ -151,6 +282,9 @@ class MarketDataCollector:
|
||||
("open_interest_hist", self.collect_open_interest_hist(session, symbol)),
|
||||
("coinbase_premium", self.collect_coinbase_premium(session, symbol)),
|
||||
("funding_rate", self.collect_funding_rate(session, symbol)),
|
||||
("obi_depth_10", self.collect_obi_depth(session, symbol)),
|
||||
("spot_perp_divergence", self.collect_spot_perp_divergence(session, symbol)),
|
||||
("tiered_cvd_whale", self.collect_tiered_cvd_whale(session, symbol)),
|
||||
]
|
||||
|
||||
results = await asyncio.gather(*(t[1] for t in tasks), return_exceptions=True)
|
||||
|
||||
16
backend/paper_config.json
Normal file
16
backend/paper_config.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"enabled_strategies": [
|
||||
"v53",
|
||||
"v53_fast",
|
||||
"v53_middle"
|
||||
],
|
||||
"initial_balance": 10000,
|
||||
"risk_per_trade": 0.02,
|
||||
"max_positions": 4,
|
||||
"tier_multiplier": {
|
||||
"light": 0.5,
|
||||
"standard": 1.0,
|
||||
"heavy": 1.5
|
||||
}
|
||||
}
|
||||
@ -49,80 +49,94 @@ def check_and_close(symbol_upper: str, price: float):
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT id, direction, entry_price, tp1_price, tp2_price, sl_price, "
|
||||
"tp1_hit, entry_ts, atr_at_entry "
|
||||
"tp1_hit, entry_ts, atr_at_entry, risk_distance "
|
||||
"FROM paper_trades WHERE symbol=%s AND status IN ('active','tp1_hit')",
|
||||
(symbol_upper,)
|
||||
)
|
||||
positions = cur.fetchall()
|
||||
|
||||
for pos in positions:
|
||||
pid, direction, entry_price, tp1, tp2, sl, tp1_hit, entry_ts, atr_entry = pos
|
||||
pid, direction, entry_price, tp1, tp2, sl, tp1_hit, entry_ts, atr_entry, rd_db = pos
|
||||
closed = False
|
||||
new_status = None
|
||||
pnl_r = 0.0
|
||||
|
||||
# 从DB读risk_distance,fallback用|entry-sl|
|
||||
risk_distance = rd_db if rd_db and rd_db > 0 else abs(entry_price - sl)
|
||||
|
||||
# === 实盘模拟:TP/SL视为限价单,以挂单价成交(非市价) ===
|
||||
if direction == "LONG":
|
||||
if price <= sl:
|
||||
closed = True
|
||||
exit_price = sl # 限价止损单以SL价成交
|
||||
if tp1_hit:
|
||||
new_status = "sl_be"
|
||||
pnl_r = 0.5 * 1.5
|
||||
tp1_r = (tp1 - entry_price) / risk_distance if risk_distance > 0 else 0
|
||||
pnl_r = 0.5 * tp1_r # TP1半仓已锁定
|
||||
else:
|
||||
new_status = "sl"
|
||||
pnl_r = -1.0
|
||||
pnl_r = (exit_price - entry_price) / risk_distance if risk_distance > 0 else -1.0
|
||||
elif not tp1_hit and price >= tp1:
|
||||
new_sl = entry_price * 1.0005
|
||||
cur.execute("UPDATE paper_trades SET tp1_hit=TRUE, sl_price=%s, status='tp1_hit' WHERE id=%s",
|
||||
(new_sl, pid))
|
||||
logger.info(f"[{symbol_upper}] ✅ TP1触发 LONG @ {price:.4f}, SL→{new_sl:.4f}")
|
||||
logger.info(f"[{symbol_upper}] ✅ TP1触发 LONG @ {tp1:.4f}, SL→{new_sl:.4f}")
|
||||
elif tp1_hit and price >= tp2:
|
||||
closed = True
|
||||
exit_price = tp2 # 限价止盈单以TP2价成交
|
||||
new_status = "tp"
|
||||
pnl_r = 2.25
|
||||
tp1_r = (tp1 - entry_price) / risk_distance if risk_distance > 0 else 0
|
||||
tp2_r = (tp2 - entry_price) / risk_distance if risk_distance > 0 else 0
|
||||
pnl_r = 0.5 * tp1_r + 0.5 * tp2_r
|
||||
else: # SHORT
|
||||
if price >= sl:
|
||||
closed = True
|
||||
exit_price = sl # 限价止损单以SL价成交
|
||||
if tp1_hit:
|
||||
new_status = "sl_be"
|
||||
pnl_r = 0.5 * 1.5
|
||||
tp1_r = (entry_price - tp1) / risk_distance if risk_distance > 0 else 0
|
||||
pnl_r = 0.5 * tp1_r
|
||||
else:
|
||||
new_status = "sl"
|
||||
pnl_r = -1.0
|
||||
pnl_r = (entry_price - exit_price) / risk_distance if risk_distance > 0 else -1.0
|
||||
elif not tp1_hit and price <= tp1:
|
||||
new_sl = entry_price * 0.9995
|
||||
cur.execute("UPDATE paper_trades SET tp1_hit=TRUE, sl_price=%s, status='tp1_hit' WHERE id=%s",
|
||||
(new_sl, pid))
|
||||
logger.info(f"[{symbol_upper}] ✅ TP1触发 SHORT @ {price:.4f}, SL→{new_sl:.4f}")
|
||||
logger.info(f"[{symbol_upper}] ✅ TP1触发 SHORT @ {tp1:.4f}, SL→{new_sl:.4f}")
|
||||
elif tp1_hit and price <= tp2:
|
||||
closed = True
|
||||
exit_price = tp2 # 限价止盈单以TP2价成交
|
||||
new_status = "tp"
|
||||
pnl_r = 2.25
|
||||
tp1_r = (entry_price - tp1) / 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
|
||||
|
||||
# 时间止损:60分钟
|
||||
# 时间止损:60分钟(市价平仓,用当前价)
|
||||
if not closed and (now_ms - entry_ts > 60 * 60 * 1000):
|
||||
closed = True
|
||||
exit_price = price # 超时是市价平仓
|
||||
new_status = "timeout"
|
||||
risk_distance = 2.0 * 0.7 * atr_entry if atr_entry > 0 else 1
|
||||
if direction == "LONG":
|
||||
move = price - entry_price
|
||||
else:
|
||||
move = entry_price - price
|
||||
pnl_r = move / risk_distance if risk_distance > 0 else 0
|
||||
if tp1_hit:
|
||||
pnl_r = max(pnl_r, 0.5 * 1.5)
|
||||
tp1_r = abs(tp1 - entry_price) / risk_distance if risk_distance > 0 else 0
|
||||
pnl_r = max(pnl_r, 0.5 * tp1_r)
|
||||
|
||||
if closed:
|
||||
# 扣手续费
|
||||
risk_distance = 2.0 * 0.7 * atr_entry if atr_entry > 0 else 1
|
||||
fee_r = (2 * FEE_RATE * entry_price) / risk_distance if risk_distance > 0 else 0
|
||||
pnl_r -= fee_r
|
||||
|
||||
cur.execute(
|
||||
"UPDATE paper_trades SET status=%s, exit_price=%s, exit_ts=%s, pnl_r=%s WHERE id=%s",
|
||||
(new_status, price, now_ms, round(pnl_r, 4), pid)
|
||||
(new_status, exit_price, now_ms, round(pnl_r, 4), pid)
|
||||
)
|
||||
logger.info(
|
||||
f"[{symbol_upper}] 📝 平仓: {direction} @ {price:.4f} "
|
||||
f"[{symbol_upper}] 📝 平仓: {direction} @ {exit_price:.4f} "
|
||||
f"status={new_status} pnl={pnl_r:+.2f}R (fee={fee_r:.3f}R)"
|
||||
)
|
||||
|
||||
|
||||
686
backend/position_sync.py
Normal file
686
backend/position_sync.py
Normal file
@ -0,0 +1,686 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Position Sync - 仓位同步与对账模块
|
||||
每30秒对账本地DB vs 币安实际持仓/挂单,SL缺失自动补挂
|
||||
|
||||
架构:
|
||||
live_executor.py 开仓 → position_sync.py 持续对账 + 监控TP/SL状态
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
from pathlib import Path
|
||||
try:
|
||||
from dotenv import load_dotenv; load_dotenv(Path(__file__).parent / ".env")
|
||||
except ImportError:
|
||||
pass
|
||||
import time
|
||||
import logging
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import psycopg2
|
||||
import aiohttp
|
||||
|
||||
# ============ 配置 ============
|
||||
|
||||
TRADE_ENV = os.getenv("TRADE_ENV", "testnet")
|
||||
BINANCE_ENDPOINTS = {
|
||||
"testnet": "https://testnet.binancefuture.com",
|
||||
"production": "https://fapi.binance.com",
|
||||
}
|
||||
BASE_URL = BINANCE_ENDPOINTS[TRADE_ENV]
|
||||
|
||||
_DB_PASSWORD = os.getenv("DB_PASSWORD", "arb_engine_2026" if TRADE_ENV == "testnet" else "")
|
||||
if not _DB_PASSWORD:
|
||||
print("FATAL: DB_PASSWORD 未设置(生产环境必须配置)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
DB_CONFIG = {
|
||||
"host": os.getenv("DB_HOST", "10.106.0.3"),
|
||||
"port": int(os.getenv("DB_PORT", "5432")),
|
||||
"dbname": os.getenv("DB_NAME", "arb_engine"),
|
||||
"user": os.getenv("DB_USER", "arb"),
|
||||
"password": _DB_PASSWORD,
|
||||
}
|
||||
|
||||
CHECK_INTERVAL = 30 # 对账间隔(秒)
|
||||
SL_REHANG_DELAYS = [0, 3] # SL补挂重试延迟(秒)
|
||||
MAX_REHANG_RETRIES = 2
|
||||
MISMATCH_ESCALATION_SEC = 60 # 差异持续超过此秒数升级告警
|
||||
RISK_PER_TRADE_USD = float(os.getenv("RISK_PER_TRADE_USD", "2")) # $2=1R
|
||||
|
||||
from trade_config import SYMBOLS, SYMBOL_PRECISION
|
||||
|
||||
from logging.handlers import RotatingFileHandler
|
||||
_log_fmt = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
||||
logging.basicConfig(level=logging.INFO, format=_log_fmt)
|
||||
logger = logging.getLogger("position-sync")
|
||||
_fh = RotatingFileHandler("logs/position_sync.log", maxBytes=10*1024*1024, backupCount=5)
|
||||
_fh.setFormatter(logging.Formatter(_log_fmt))
|
||||
logger.addHandler(_fh)
|
||||
|
||||
# ============ API Key ============
|
||||
_api_key = None
|
||||
_secret_key = None
|
||||
|
||||
|
||||
def load_api_keys():
|
||||
global _api_key, _secret_key
|
||||
_api_key = os.getenv("BINANCE_API_KEY")
|
||||
_secret_key = os.getenv("BINANCE_SECRET_KEY")
|
||||
if _api_key and _secret_key:
|
||||
logger.info(f"API Key loaded from env")
|
||||
return
|
||||
try:
|
||||
from google.cloud import secretmanager
|
||||
client = secretmanager.SecretManagerServiceClient()
|
||||
project = os.getenv("GCP_PROJECT", "gen-lang-client-0835616737")
|
||||
prefix = "binance-testnet" if TRADE_ENV == "testnet" else "binance-live"
|
||||
_api_key = client.access_secret_version(
|
||||
name=f"projects/{project}/secrets/{prefix}-api-key/versions/latest"
|
||||
).payload.data.decode()
|
||||
_secret_key = client.access_secret_version(
|
||||
name=f"projects/{project}/secrets/{prefix}-secret-key/versions/latest"
|
||||
).payload.data.decode()
|
||||
logger.info(f"API Key loaded from GCP Secret Manager")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load API keys: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def sign_params(params):
|
||||
params["timestamp"] = int(time.time() * 1000)
|
||||
query_string = urlencode(params)
|
||||
signature = hmac.new(_secret_key.encode(), query_string.encode(), hashlib.sha256).hexdigest()
|
||||
params["signature"] = signature
|
||||
return params
|
||||
|
||||
|
||||
async def binance_request(session, method, path, params=None):
|
||||
url = f"{BASE_URL}{path}"
|
||||
headers = {"X-MBX-APIKEY": _api_key}
|
||||
if params is None:
|
||||
params = {}
|
||||
params = sign_params(params)
|
||||
try:
|
||||
if method == "GET":
|
||||
async with session.get(url, params=params, headers=headers) as resp:
|
||||
return await resp.json(), resp.status
|
||||
elif method == "POST":
|
||||
async with session.post(url, params=params, headers=headers) as resp:
|
||||
return await resp.json(), resp.status
|
||||
elif method == "DELETE":
|
||||
async with session.delete(url, params=params, headers=headers) as resp:
|
||||
return await resp.json(), resp.status
|
||||
except Exception as e:
|
||||
logger.error(f"Request failed: {e}")
|
||||
return {"error": str(e)}, 500
|
||||
|
||||
|
||||
# ============ 对账核心 ============
|
||||
|
||||
async def get_binance_positions(session):
|
||||
"""获取币安所有持仓"""
|
||||
data, status = await binance_request(session, "GET", "/fapi/v2/positionRisk")
|
||||
positions = {}
|
||||
if status == 200 and isinstance(data, list):
|
||||
for pos in data:
|
||||
amt = float(pos.get("positionAmt", 0))
|
||||
if amt != 0:
|
||||
positions[pos["symbol"]] = {
|
||||
"amount": amt,
|
||||
"direction": "LONG" if amt > 0 else "SHORT",
|
||||
"entry_price": float(pos.get("entryPrice", 0)),
|
||||
"unrealized_pnl": float(pos.get("unRealizedProfit", 0)),
|
||||
"liquidation_price": float(pos.get("liquidationPrice", 0)),
|
||||
"mark_price": float(pos.get("markPrice", 0)),
|
||||
}
|
||||
return positions
|
||||
|
||||
|
||||
async def get_binance_open_orders(session, symbol=None):
|
||||
"""获取币安挂单"""
|
||||
params = {}
|
||||
if symbol:
|
||||
params["symbol"] = symbol
|
||||
data, status = await binance_request(session, "GET", "/fapi/v1/openOrders", params)
|
||||
if status == 200 and isinstance(data, list):
|
||||
return data
|
||||
return []
|
||||
|
||||
|
||||
def get_local_positions(conn):
|
||||
"""获取本地DB活跃持仓"""
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT id, symbol, strategy, direction, entry_price, sl_price, tp1_price, tp2_price,
|
||||
tp1_hit, status, risk_distance, binance_order_id, entry_ts, qty
|
||||
FROM live_trades
|
||||
WHERE status IN ('active', 'tp1_hit')
|
||||
ORDER BY entry_ts DESC
|
||||
""")
|
||||
positions = []
|
||||
for row in cur.fetchall():
|
||||
positions.append({
|
||||
"id": row[0], "symbol": row[1], "strategy": row[2], "direction": row[3],
|
||||
"entry_price": row[4], "sl_price": row[5], "tp1_price": row[6], "tp2_price": row[7],
|
||||
"tp1_hit": row[8], "status": row[9], "risk_distance": row[10],
|
||||
"binance_order_id": row[11], "entry_ts": row[12],
|
||||
"qty": row[13],
|
||||
})
|
||||
return positions
|
||||
|
||||
|
||||
async def check_sl_exists(session, symbol, direction):
|
||||
"""检查是否有SL挂单"""
|
||||
orders = await get_binance_open_orders(session, symbol)
|
||||
close_side = "SELL" if direction == "LONG" else "BUY"
|
||||
for order in orders:
|
||||
if order.get("type") == "STOP_MARKET" and order.get("side") == close_side:
|
||||
return True, order
|
||||
return False, None
|
||||
|
||||
|
||||
async def rehang_sl(session, symbol, direction, sl_price, quantity):
|
||||
"""补挂SL保护单"""
|
||||
prec = SYMBOL_PRECISION.get(symbol, {"qty": 3, "price": 2})
|
||||
close_side = "SELL" if direction == "LONG" else "BUY"
|
||||
qty_str = f"{abs(quantity):.{prec['qty']}f}"
|
||||
price_str = f"{sl_price:.{prec['price']}f}"
|
||||
|
||||
params = {
|
||||
"symbol": symbol,
|
||||
"side": close_side,
|
||||
"type": "STOP_MARKET",
|
||||
"stopPrice": price_str,
|
||||
"quantity": qty_str,
|
||||
"reduceOnly": "true",
|
||||
}
|
||||
data, status = await binance_request(session, "POST", "/fapi/v1/order", params)
|
||||
return status == 200, data
|
||||
|
||||
|
||||
async def reconcile(session, conn):
|
||||
"""执行一次完整对账"""
|
||||
local_positions = get_local_positions(conn)
|
||||
binance_positions = await get_binance_positions(session)
|
||||
|
||||
issues = []
|
||||
cur = conn.cursor()
|
||||
|
||||
# 1. 检查本地有仓但币安没有(漏单/已被清算)
|
||||
for lp in local_positions:
|
||||
symbol = lp["symbol"]
|
||||
bp = binance_positions.get(symbol)
|
||||
|
||||
if not bp:
|
||||
issues.append({
|
||||
"type": "local_only",
|
||||
"severity": "critical",
|
||||
"symbol": symbol,
|
||||
"detail": f"本地有{lp['direction']}仓位(id={lp['id']})但币安无持仓",
|
||||
})
|
||||
continue
|
||||
|
||||
# 方向不一致
|
||||
if bp["direction"] != lp["direction"]:
|
||||
issues.append({
|
||||
"type": "direction_mismatch",
|
||||
"severity": "critical",
|
||||
"symbol": symbol,
|
||||
"detail": f"本地={lp['direction']} vs 币安={bp['direction']}",
|
||||
})
|
||||
continue
|
||||
|
||||
# 2. 检查SL保护单是否存在
|
||||
sl_exists, sl_order = await check_sl_exists(session, symbol, lp["direction"])
|
||||
if not sl_exists:
|
||||
issues.append({
|
||||
"type": "sl_missing",
|
||||
"severity": "critical",
|
||||
"symbol": symbol,
|
||||
"detail": f"SL保护单缺失! 仓位裸奔中!",
|
||||
})
|
||||
# 自动补挂SL
|
||||
logger.warning(f"[{symbol}] ⚠️ SL缺失,开始自动补挂...")
|
||||
success = False
|
||||
for attempt, delay in enumerate(SL_REHANG_DELAYS):
|
||||
if delay > 0:
|
||||
await asyncio.sleep(delay)
|
||||
ok, data = await rehang_sl(session, symbol, lp["direction"], lp["sl_price"], bp["amount"])
|
||||
if ok:
|
||||
logger.info(f"[{symbol}] ✅ SL补挂成功 (attempt={attempt+1})")
|
||||
success = True
|
||||
break
|
||||
else:
|
||||
logger.error(f"[{symbol}] ❌ SL补挂失败 (attempt={attempt+1}): {data}")
|
||||
|
||||
if not success:
|
||||
issues.append({
|
||||
"type": "sl_rehang_failed",
|
||||
"severity": "emergency",
|
||||
"symbol": symbol,
|
||||
"detail": f"SL补挂{MAX_REHANG_RETRIES}次全部失败! 建议进入只减仓模式!",
|
||||
})
|
||||
|
||||
# 3. 检查清算距离
|
||||
if bp.get("liquidation_price") and bp.get("mark_price"):
|
||||
liq = bp["liquidation_price"]
|
||||
mark = bp["mark_price"]
|
||||
if liq > 0 and mark > 0:
|
||||
if lp["direction"] == "LONG":
|
||||
dist_pct = (mark - liq) / mark * 100
|
||||
else:
|
||||
dist_pct = (liq - mark) / mark * 100
|
||||
|
||||
if dist_pct < 8:
|
||||
issues.append({
|
||||
"type": "liquidation_near",
|
||||
"severity": "emergency",
|
||||
"symbol": symbol,
|
||||
"detail": f"距清算仅{dist_pct:.1f}%! 建议立即减仓!",
|
||||
})
|
||||
elif dist_pct < 12:
|
||||
issues.append({
|
||||
"type": "liquidation_warning",
|
||||
"severity": "high",
|
||||
"symbol": symbol,
|
||||
"detail": f"距清算{dist_pct:.1f}%",
|
||||
})
|
||||
elif dist_pct < 20:
|
||||
issues.append({
|
||||
"type": "liquidation_caution",
|
||||
"severity": "medium",
|
||||
"symbol": symbol,
|
||||
"detail": f"距清算{dist_pct:.1f}%",
|
||||
})
|
||||
|
||||
# 4. 检查币安有仓但本地没有(幽灵仓位)
|
||||
local_symbols = {lp["symbol"] for lp in local_positions}
|
||||
for symbol, bp in binance_positions.items():
|
||||
if symbol not in local_symbols and symbol in [s for s in SYMBOLS]:
|
||||
issues.append({
|
||||
"type": "exchange_only",
|
||||
"severity": "high",
|
||||
"symbol": symbol,
|
||||
"detail": f"币安有{bp['direction']}仓位但本地无记录",
|
||||
})
|
||||
|
||||
# 记录对账结果
|
||||
now_ms = int(time.time() * 1000)
|
||||
result = {
|
||||
"timestamp_ms": now_ms,
|
||||
"local_count": len(local_positions),
|
||||
"exchange_count": len(binance_positions),
|
||||
"issues_count": len(issues),
|
||||
"issues": issues,
|
||||
"status": "ok" if len(issues) == 0 else "mismatch",
|
||||
}
|
||||
|
||||
# 写对账结果到DB(可选:创建reconciliation_log表)
|
||||
if issues:
|
||||
for issue in issues:
|
||||
level = "🔴" if issue["severity"] in ("critical", "emergency") else "🟡"
|
||||
logger.warning(f"{level} [{issue['symbol']}] {issue['type']}: {issue['detail']}")
|
||||
else:
|
||||
logger.info(f"✅ 对账正常 | 本地={len(local_positions)}仓 | 币安={len(binance_positions)}仓")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ============ TP1触发监控 ============
|
||||
|
||||
async def check_tp1_triggers(session, conn):
|
||||
"""检查TP1是否触发,触发后移SL到保本价"""
|
||||
local_positions = get_local_positions(conn)
|
||||
cur = conn.cursor()
|
||||
|
||||
for lp in local_positions:
|
||||
if lp["tp1_hit"]:
|
||||
continue # 已处理
|
||||
|
||||
symbol = lp["symbol"]
|
||||
# 查币安挂单,看TP1是否已成交(不在挂单列表里了)
|
||||
orders = await get_binance_open_orders(session, symbol)
|
||||
close_side = "SELL" if lp["direction"] == "LONG" else "BUY"
|
||||
|
||||
tp1_found = False
|
||||
for order in orders:
|
||||
if (order.get("type") == "TAKE_PROFIT_MARKET"
|
||||
and order.get("side") == close_side
|
||||
and abs(float(order.get("stopPrice", 0)) - lp["tp1_price"]) < lp["tp1_price"] * 0.001):
|
||||
tp1_found = True
|
||||
break
|
||||
|
||||
if not tp1_found and lp["status"] == "active":
|
||||
# TP1可能已触发,验证仓位是否减半
|
||||
bp = (await get_binance_positions(session)).get(symbol)
|
||||
entry_qty = lp.get("qty") or 0
|
||||
if bp and entry_qty > 0 and abs(bp["amount"]) < entry_qty * 0.75:
|
||||
# 确认TP1触发
|
||||
logger.info(f"[{symbol}] ✅ TP1触发! 移SL到保本价")
|
||||
|
||||
# 取消旧SL
|
||||
await binance_request(session, "DELETE", "/fapi/v1/allOpenOrders", {"symbol": symbol})
|
||||
|
||||
# 计算保本SL
|
||||
if lp["direction"] == "LONG":
|
||||
new_sl = lp["entry_price"] * 1.0005
|
||||
else:
|
||||
new_sl = lp["entry_price"] * 0.9995
|
||||
|
||||
# 挂新SL(半仓)— 失败则不推进状态
|
||||
prec = SYMBOL_PRECISION.get(symbol, {"qty": 3, "price": 2})
|
||||
ok, sl_resp = await rehang_sl(session, symbol, lp["direction"], new_sl, bp["amount"])
|
||||
if not ok:
|
||||
logger.error(f"[{symbol}] ❌ TP1后重挂SL失败: {sl_resp},不推进tp1_hit状态")
|
||||
_log_event(conn, "critical", "trade",
|
||||
"TP1后重挂SL失败,仓位可能裸奔,需人工确认", symbol,
|
||||
{"trade_id": lp["id"], "sl_resp": str(sl_resp)})
|
||||
continue
|
||||
|
||||
# 重新挂TP2(半仓)
|
||||
tp2_price = lp["tp2_price"]
|
||||
qty_str = f"{abs(bp['amount']):.{prec['qty']}f}"
|
||||
price_str = f"{tp2_price:.{prec['price']}f}"
|
||||
tp2_data, tp2_status = await binance_request(session, "POST", "/fapi/v1/order", {
|
||||
"symbol": symbol, "side": close_side, "type": "TAKE_PROFIT_MARKET",
|
||||
"stopPrice": price_str, "quantity": qty_str, "reduceOnly": "true",
|
||||
})
|
||||
if tp2_status != 200:
|
||||
logger.error(f"[{symbol}] ❌ TP2重挂失败: {tp2_data},SL已挂但TP2缺失")
|
||||
|
||||
# SL成功才更新DB
|
||||
cur.execute("""
|
||||
UPDATE live_trades SET tp1_hit=TRUE, sl_price=%s, status='tp1_hit'
|
||||
WHERE id=%s
|
||||
""", (new_sl, lp["id"]))
|
||||
conn.commit()
|
||||
logger.info(f"[{symbol}] 🛡 SL移至保本 {new_sl:.4f}, TP2={tp2_price:.4f}")
|
||||
|
||||
|
||||
# ============ 平仓检测 ============
|
||||
|
||||
async def check_closed_positions(session, conn):
|
||||
"""检测已平仓的交易,更新DB"""
|
||||
local_positions = get_local_positions(conn)
|
||||
binance_positions = await get_binance_positions(session)
|
||||
cur = conn.cursor()
|
||||
|
||||
for lp in local_positions:
|
||||
symbol = lp["symbol"]
|
||||
bp = binance_positions.get(symbol)
|
||||
|
||||
# 币安无持仓但本地还active → 已平仓
|
||||
if not bp:
|
||||
logger.info(f"[{symbol}] 📝 检测到平仓,查询成交记录...")
|
||||
|
||||
# 查最近成交确定平仓价
|
||||
# 简化:用当前标记价做近似
|
||||
now_ms = int(time.time() * 1000)
|
||||
rd = lp["risk_distance"] or 1
|
||||
|
||||
# 判断平仓类型(通过挂单是否还在来推断)
|
||||
# 如果SL/TP都不在了,说明触发了其中一个
|
||||
status = "unknown"
|
||||
exit_price = lp["entry_price"] # fallback
|
||||
|
||||
# 尝试从最近交易记录获取成交价和手续费
|
||||
entry_ts = lp.get("entry_ts", 0)
|
||||
trades_data, trades_status = await binance_request(session, "GET", "/fapi/v1/userTrades", {
|
||||
"symbol": symbol, "startTime": entry_ts, "limit": 100
|
||||
})
|
||||
actual_fee_usdt = 0
|
||||
if trades_status == 200 and isinstance(trades_data, list) and trades_data:
|
||||
# 过滤平仓成交:LONG平仓是SELL(buyer=false), SHORT平仓是BUY(buyer=true)
|
||||
is_close_buyer = lp["direction"] == "SHORT"
|
||||
close_trades = [t for t in trades_data if bool(t.get("buyer")) == is_close_buyer and int(t.get("time", 0)) > entry_ts + 1000]
|
||||
if close_trades:
|
||||
total_qty = sum(float(t["qty"]) for t in close_trades)
|
||||
if total_qty > 0:
|
||||
exit_price = sum(float(t["price"]) * float(t["qty"]) for t in close_trades) / total_qty
|
||||
elif trades_data:
|
||||
# fallback: 未找到明确平仓成交,延后本轮结算
|
||||
logger.warning(f"[{symbol}] 未找到明确平仓成交,延后结算")
|
||||
continue
|
||||
|
||||
# 汇总手续费(开仓后200ms起算,避免含其他策略成交)
|
||||
for t in trades_data:
|
||||
t_time = int(t.get("time", 0))
|
||||
if t_time >= entry_ts + 200: # 开仓后200ms起算,避免纳入开仓前成交
|
||||
actual_fee_usdt += abs(float(t.get("commission", 0)))
|
||||
|
||||
# 计算pnl — gross(不含费)
|
||||
if lp["direction"] == "LONG":
|
||||
gross_pnl_r = (exit_price - lp["entry_price"]) / rd
|
||||
else:
|
||||
gross_pnl_r = (lp["entry_price"] - exit_price) / rd
|
||||
|
||||
if lp["tp1_hit"]:
|
||||
tp1_r = abs(lp["tp1_price"] - lp["entry_price"]) / rd
|
||||
gross_pnl_r = 0.5 * tp1_r + 0.5 * gross_pnl_r
|
||||
|
||||
# 手续费(R) — 用实际成交手续费(动态1R)
|
||||
_risk_usd = load_live_risk_usd(conn)
|
||||
if actual_fee_usdt > 0:
|
||||
fee_r = actual_fee_usdt / (_risk_usd if _risk_usd > 0 else rd)
|
||||
else:
|
||||
# fallback: 按0.1%估算(开+平各0.05%)
|
||||
fee_r = 0.001 * lp["entry_price"] / rd
|
||||
|
||||
# funding费(R)
|
||||
funding_usdt = 0
|
||||
cur.execute("SELECT COALESCE(funding_fee_usdt, 0) FROM live_trades WHERE id = %s", (lp["id"],))
|
||||
fr_row = cur.fetchone()
|
||||
if fr_row:
|
||||
funding_usdt = fr_row[0]
|
||||
# P1-3: 正向funding也计入净PnL(正值增加收益,负值减少收益)
|
||||
funding_r = funding_usdt / (_risk_usd if _risk_usd > 0 else rd)
|
||||
|
||||
# 净PnL = gross - fee + funding (funding正值=收入,负值=支出)
|
||||
pnl_r = gross_pnl_r - fee_r + funding_r
|
||||
|
||||
# 判断状态
|
||||
if pnl_r > 0.5:
|
||||
status = "tp"
|
||||
elif pnl_r < -0.5:
|
||||
status = "sl"
|
||||
elif lp["tp1_hit"] and pnl_r >= -0.1:
|
||||
status = "sl_be"
|
||||
else:
|
||||
status = "closed"
|
||||
|
||||
cur.execute("""
|
||||
UPDATE live_trades SET status=%s, exit_price=%s, exit_ts=%s, pnl_r=%s, fee_usdt=%s
|
||||
WHERE id=%s
|
||||
""", (status, exit_price, now_ms, round(pnl_r, 4), round(actual_fee_usdt, 4), lp["id"]))
|
||||
conn.commit()
|
||||
|
||||
logger.info(
|
||||
f"[{symbol}] 📝 平仓: {status} | exit={exit_price:.4f} | "
|
||||
f"gross={gross_pnl_r:+.3f}R fee={fee_r:.3f}R({actual_fee_usdt:.4f}$) "
|
||||
f"funding={funding_usdt:+.4f}$ | net={pnl_r:+.3f}R"
|
||||
)
|
||||
|
||||
# 写event
|
||||
evt_level = "info" if pnl_r >= 0 else "warn"
|
||||
_log_event(conn, evt_level, "trade",
|
||||
f"平仓 {lp['direction']} {symbol} | {status} | net={pnl_r:+.3f}R (gross={gross_pnl_r:+.3f} fee=-{fee_r:.3f} fr={funding_usdt:+.4f}$)",
|
||||
symbol, {"trade_id": lp["id"], "status": status, "pnl_r": round(pnl_r, 4), "exit_price": exit_price})
|
||||
|
||||
|
||||
# ============ 资金费率结算追踪 ============
|
||||
|
||||
# 币安结算时间:UTC 00:00, 08:00, 16:00
|
||||
FUNDING_SETTLEMENT_HOURS = [0, 8, 16]
|
||||
_last_funding_check_ts = 0 # 上次查funding的时间戳
|
||||
|
||||
|
||||
async def track_funding_fees(session, conn):
|
||||
"""
|
||||
查询币安资金费率收支,更新到live_trades的funding_fee_usdt字段。
|
||||
只在结算时间点附近查询(每8小时一次,±5分钟窗口内查一次)。
|
||||
"""
|
||||
global _last_funding_check_ts
|
||||
import datetime as _dt
|
||||
|
||||
now = _dt.datetime.now(_dt.timezone.utc)
|
||||
now_ts = time.time()
|
||||
|
||||
# 判断是否在结算窗口内(结算时间后0-5分钟)
|
||||
in_settlement_window = False
|
||||
for h in FUNDING_SETTLEMENT_HOURS:
|
||||
settlement_time = now.replace(hour=h, minute=0, second=0, microsecond=0)
|
||||
diff_sec = (now - settlement_time).total_seconds()
|
||||
if 0 <= diff_sec <= 300: # 结算后5分钟内
|
||||
in_settlement_window = True
|
||||
break
|
||||
|
||||
# 不在窗口内,或者5分钟内已经查过了
|
||||
if not in_settlement_window:
|
||||
return
|
||||
if now_ts - _last_funding_check_ts < 300:
|
||||
return
|
||||
|
||||
_last_funding_check_ts = now_ts
|
||||
logger.info("💰 资金费率结算窗口,查询funding收支...")
|
||||
|
||||
# 获取当前活跃持仓
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT id, symbol, direction, entry_ts, COALESCE(funding_fee_usdt, 0) as current_funding
|
||||
FROM live_trades WHERE status IN ('active', 'tp1_hit')
|
||||
""")
|
||||
active = cur.fetchall()
|
||||
|
||||
if not active:
|
||||
logger.info("💰 无活跃持仓,跳过funding查询")
|
||||
return
|
||||
|
||||
# 查币安最近的funding收入记录
|
||||
# 对齐到本次结算周期(00:00/08:00/16:00 UTC)
|
||||
from datetime import datetime, timezone
|
||||
now_utc = datetime.fromtimestamp(now_ts, tz=timezone.utc)
|
||||
hour = now_utc.hour
|
||||
# 找到最近的结算时间点(0/8/16)
|
||||
settlement_hour = (hour // 8) * 8
|
||||
settlement_time = now_utc.replace(hour=settlement_hour, minute=0, second=0, microsecond=0)
|
||||
settlement_start_ms = int(settlement_time.timestamp() * 1000)
|
||||
|
||||
data, status = await binance_request(session, "GET", "/fapi/v1/income", {
|
||||
"incomeType": "FUNDING_FEE",
|
||||
"startTime": settlement_start_ms,
|
||||
"limit": 100,
|
||||
})
|
||||
|
||||
if status != 200 or not isinstance(data, list):
|
||||
logger.warning(f"💰 查询funding income失败: {status}")
|
||||
return
|
||||
|
||||
# 按symbol汇总本次结算的funding(只取本结算周期内的)
|
||||
funding_by_symbol = {}
|
||||
for item in data:
|
||||
sym = item.get("symbol", "")
|
||||
income = float(item.get("income", 0))
|
||||
ts = int(item.get("time", 0))
|
||||
if ts >= settlement_start_ms:
|
||||
funding_by_symbol[sym] = funding_by_symbol.get(sym, 0) + income
|
||||
|
||||
if not funding_by_symbol:
|
||||
logger.info("💰 本次结算无funding记录")
|
||||
return
|
||||
|
||||
# 更新到live_trades
|
||||
for trade_id, symbol, direction, entry_ts, current_funding in active:
|
||||
fr_income = funding_by_symbol.get(symbol, 0)
|
||||
if fr_income != 0:
|
||||
new_total = current_funding + fr_income
|
||||
cur.execute("UPDATE live_trades SET funding_fee_usdt = %s WHERE id = %s", (new_total, trade_id))
|
||||
logger.info(f"[{symbol}] 💰 Funding: {fr_income:+.4f} USDT (累计: {new_total:+.4f})")
|
||||
|
||||
conn.commit()
|
||||
logger.info(f"💰 Funding更新完成: {funding_by_symbol}")
|
||||
|
||||
|
||||
def _log_event(conn, level, category, message, symbol=None, detail=None):
|
||||
"""写live_events表"""
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"INSERT INTO live_events (level, category, symbol, message, detail) VALUES (%s, %s, %s, %s, %s)",
|
||||
(level, category, symbol, message, json.dumps(detail) if detail else None)
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ============ 主循环 ============
|
||||
|
||||
def load_live_risk_usd(conn, default=2.0):
|
||||
"""从live_config动态读取1R金额,与live_executor保持一致"""
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT value FROM live_config WHERE key='risk_per_trade_usd'")
|
||||
row = cur.fetchone()
|
||||
return float(row[0]) if row and row[0] else default
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def ensure_db_conn(conn):
|
||||
"""检查DB连接,断线则重连"""
|
||||
try:
|
||||
conn.cursor().execute("SELECT 1")
|
||||
return conn
|
||||
except Exception:
|
||||
logger.warning("⚠️ DB连接断开,重连中...")
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
return psycopg2.connect(**DB_CONFIG)
|
||||
|
||||
|
||||
async def main():
|
||||
logger.info("=" * 60)
|
||||
logger.info(f"🔄 Position Sync 启动 | 环境={TRADE_ENV} | 间隔={CHECK_INTERVAL}秒")
|
||||
logger.info("=" * 60)
|
||||
|
||||
load_api_keys()
|
||||
conn = psycopg2.connect(**DB_CONFIG)
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
while True:
|
||||
try:
|
||||
conn = ensure_db_conn(conn)
|
||||
# 1. 对账
|
||||
result = await reconcile(session, conn)
|
||||
|
||||
# 2. 检查TP1触发
|
||||
await check_tp1_triggers(session, conn)
|
||||
|
||||
# 3. 检查已平仓
|
||||
await check_closed_positions(session, conn)
|
||||
|
||||
# 4. 资金费率结算追踪
|
||||
await track_funding_fees(session, conn)
|
||||
|
||||
await asyncio.sleep(CHECK_INTERVAL)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 对账异常: {e}", exc_info=True)
|
||||
await asyncio.sleep(10)
|
||||
|
||||
conn.close()
|
||||
logger.info("Position Sync 已停止")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
645
backend/risk_guard.py
Normal file
645
backend/risk_guard.py
Normal file
@ -0,0 +1,645 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Risk Guard - 风控熔断模块
|
||||
实时监控风险指标,触发熔断时自动执行保护动作
|
||||
|
||||
熔断规则:
|
||||
1. 单日亏损超限(-5R) → 全平+停机
|
||||
2. 连续亏损(5连亏) → 暂停开仓1小时
|
||||
3. API连接异常(>30秒) → 暂停开仓
|
||||
4. 余额不足(< 风险×2) → 拒绝开仓
|
||||
5. 数据新鲜度超时 → 禁止新开仓
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
try:
|
||||
from dotenv import load_dotenv; load_dotenv(Path(__file__).parent / ".env")
|
||||
except ImportError:
|
||||
pass
|
||||
import logging
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
from urllib.parse import urlencode
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import psycopg2
|
||||
import aiohttp
|
||||
|
||||
# ============ 配置 ============
|
||||
|
||||
TRADE_ENV = os.getenv("TRADE_ENV", "testnet")
|
||||
BINANCE_ENDPOINTS = {
|
||||
"testnet": "https://testnet.binancefuture.com",
|
||||
"production": "https://fapi.binance.com",
|
||||
}
|
||||
BASE_URL = BINANCE_ENDPOINTS[TRADE_ENV]
|
||||
|
||||
_DB_PASSWORD = os.getenv("DB_PASSWORD") or os.getenv("PG_PASS")
|
||||
if not _DB_PASSWORD:
|
||||
raise RuntimeError("DB_PASSWORD / PG_PASS 未设置,请在 .env 或环境变量中注入数据库密码")
|
||||
if not _DB_PASSWORD:
|
||||
print("FATAL: DB_PASSWORD 未设置(生产环境必须配置)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
DB_CONFIG = {
|
||||
"host": os.getenv("DB_HOST", "10.106.0.3"),
|
||||
"port": int(os.getenv("DB_PORT", "5432")),
|
||||
"dbname": os.getenv("DB_NAME", "arb_engine"),
|
||||
"user": os.getenv("DB_USER", "arb"),
|
||||
"password": _DB_PASSWORD,
|
||||
}
|
||||
|
||||
# 风控参数
|
||||
DAILY_LOSS_LIMIT_R = float(os.getenv("DAILY_LOSS_LIMIT_R", "-5"))
|
||||
CONSECUTIVE_LOSS_LIMIT = int(os.getenv("CONSECUTIVE_LOSS_LIMIT", "5"))
|
||||
CONSECUTIVE_LOSS_COOLDOWN_MIN = int(os.getenv("CONSECUTIVE_LOSS_COOLDOWN_MIN", "60"))
|
||||
API_DISCONNECT_THRESHOLD_SEC = int(os.getenv("API_DISCONNECT_THRESHOLD_SEC", "30"))
|
||||
MIN_BALANCE_MULTIPLE = float(os.getenv("MIN_BALANCE_MULTIPLE", "2"))
|
||||
RISK_PER_TRADE_USD = float(os.getenv("RISK_PER_TRADE_USD", "2"))
|
||||
|
||||
# 超时处置
|
||||
HOLD_TIMEOUT_YELLOW_MIN = 45
|
||||
HOLD_TIMEOUT_RED_MIN = 60
|
||||
HOLD_TIMEOUT_GRACE_MIN = 10 # 红灯后10分钟人工窗口
|
||||
HOLD_TIMEOUT_AUTO_CLOSE_MIN = HOLD_TIMEOUT_RED_MIN + HOLD_TIMEOUT_GRACE_MIN # 70分钟
|
||||
|
||||
# 数据新鲜度
|
||||
MARKET_DATA_STALE_SEC = 30 # 30秒(允许signal_engine启动回灌)
|
||||
ACCOUNT_UPDATE_STALE_SEC = 20
|
||||
|
||||
CHECK_INTERVAL = 5 # 风控检查间隔(秒)
|
||||
|
||||
from trade_config import SYMBOLS, SYMBOL_QTY_PRECISION
|
||||
|
||||
from logging.handlers import RotatingFileHandler
|
||||
_log_fmt = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
||||
logging.basicConfig(level=logging.INFO, format=_log_fmt)
|
||||
logger = logging.getLogger("risk-guard")
|
||||
_fh = RotatingFileHandler("logs/risk_guard.log", maxBytes=10*1024*1024, backupCount=5)
|
||||
_fh.setFormatter(logging.Formatter(_log_fmt))
|
||||
logger.addHandler(_fh)
|
||||
|
||||
# ============ 状态 ============
|
||||
|
||||
class RiskState:
|
||||
"""风控状态管理"""
|
||||
def __init__(self):
|
||||
self.status = "normal" # normal / warning / circuit_break
|
||||
self.block_new_entries = False
|
||||
self.reduce_only = False
|
||||
self.manual_override = False
|
||||
self.circuit_break_reason = None
|
||||
self.circuit_break_time = None
|
||||
self.auto_resume_time = None
|
||||
self.last_api_success = time.time()
|
||||
self.last_market_data = time.time()
|
||||
self.last_account_update = time.time()
|
||||
self.consecutive_losses = 0
|
||||
self.today_realized_r = 0.0
|
||||
self.today_unrealized_r = 0.0
|
||||
self.breaker_history = []
|
||||
# 超时处置队列: {trade_id: {"entered_queue_ts": ..., "notified": bool}}
|
||||
self.timeout_queue = {}
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"status": self.status,
|
||||
"block_new_entries": self.block_new_entries,
|
||||
"reduce_only": self.reduce_only,
|
||||
"circuit_break_reason": self.circuit_break_reason,
|
||||
"circuit_break_time": self.circuit_break_time,
|
||||
"auto_resume_time": self.auto_resume_time,
|
||||
"consecutive_losses": self.consecutive_losses,
|
||||
"today_realized_r": round(self.today_realized_r, 2),
|
||||
"today_unrealized_r": round(self.today_unrealized_r, 2),
|
||||
"today_total_r": round(self.today_realized_r + min(self.today_unrealized_r, 0), 2),
|
||||
}
|
||||
|
||||
|
||||
risk_state = RiskState()
|
||||
|
||||
# ============ API Key ============
|
||||
_api_key = None
|
||||
_secret_key = None
|
||||
|
||||
|
||||
def load_api_keys():
|
||||
global _api_key, _secret_key
|
||||
_api_key = os.getenv("BINANCE_API_KEY")
|
||||
_secret_key = os.getenv("BINANCE_SECRET_KEY")
|
||||
if _api_key and _secret_key:
|
||||
return
|
||||
try:
|
||||
from google.cloud import secretmanager
|
||||
client = secretmanager.SecretManagerServiceClient()
|
||||
project = os.getenv("GCP_PROJECT", "gen-lang-client-0835616737")
|
||||
prefix = "binance-testnet" if TRADE_ENV == "testnet" else "binance-live"
|
||||
_api_key = client.access_secret_version(
|
||||
name=f"projects/{project}/secrets/{prefix}-api-key/versions/latest"
|
||||
).payload.data.decode()
|
||||
_secret_key = client.access_secret_version(
|
||||
name=f"projects/{project}/secrets/{prefix}-secret-key/versions/latest"
|
||||
).payload.data.decode()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load API keys: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def sign_params(params):
|
||||
params["timestamp"] = int(time.time() * 1000)
|
||||
query_string = urlencode(params)
|
||||
signature = hmac.new(_secret_key.encode(), query_string.encode(), hashlib.sha256).hexdigest()
|
||||
params["signature"] = signature
|
||||
return params
|
||||
|
||||
|
||||
async def binance_request(session, method, path, params=None):
|
||||
url = f"{BASE_URL}{path}"
|
||||
headers = {"X-MBX-APIKEY": _api_key}
|
||||
if params is None:
|
||||
params = {}
|
||||
params = sign_params(params)
|
||||
try:
|
||||
if method == "GET":
|
||||
async with session.get(url, params=params, headers=headers) as resp:
|
||||
data = await resp.json()
|
||||
risk_state.last_api_success = time.time()
|
||||
return data, resp.status
|
||||
elif method == "POST":
|
||||
async with session.post(url, params=params, headers=headers) as resp:
|
||||
data = await resp.json()
|
||||
risk_state.last_api_success = time.time()
|
||||
return data, resp.status
|
||||
elif method == "DELETE":
|
||||
async with session.delete(url, params=params, headers=headers) as resp:
|
||||
data = await resp.json()
|
||||
risk_state.last_api_success = time.time()
|
||||
return data, resp.status
|
||||
except Exception as e:
|
||||
logger.error(f"API request failed: {e}")
|
||||
return {"error": str(e)}, 500
|
||||
|
||||
|
||||
# ============ 风控检查 ============
|
||||
|
||||
def check_daily_loss(conn):
|
||||
"""检查今日已实现亏损"""
|
||||
cur = conn.cursor()
|
||||
# 今日UTC起始
|
||||
today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
today_start_ms = int(today_start.timestamp() * 1000)
|
||||
|
||||
cur.execute("""
|
||||
SELECT COALESCE(SUM(pnl_r), 0) as total_r,
|
||||
COUNT(*) FILTER (WHERE pnl_r < 0) as loss_count
|
||||
FROM live_trades
|
||||
WHERE exit_ts >= %s AND status NOT IN ('active', 'tp1_hit')
|
||||
""", (today_start_ms,))
|
||||
row = cur.fetchone()
|
||||
realized_r = float(row[0]) if row[0] else 0
|
||||
risk_state.today_realized_r = realized_r
|
||||
|
||||
# 检查连续亏损
|
||||
cur.execute("""
|
||||
SELECT pnl_r FROM live_trades
|
||||
WHERE status NOT IN ('active', 'tp1_hit')
|
||||
ORDER BY exit_ts DESC LIMIT %s
|
||||
""", (CONSECUTIVE_LOSS_LIMIT,))
|
||||
recent = [r[0] for r in cur.fetchall()]
|
||||
consecutive = 0
|
||||
for r in recent:
|
||||
if r and r < 0:
|
||||
consecutive += 1
|
||||
else:
|
||||
break
|
||||
risk_state.consecutive_losses = consecutive
|
||||
|
||||
return realized_r, consecutive
|
||||
|
||||
|
||||
async def check_unrealized_loss(session, risk_usd_dynamic=2.0):
|
||||
"""检查未实现亏损"""
|
||||
data, status = await binance_request(session, "GET", "/fapi/v2/positionRisk")
|
||||
total_unrealized = 0
|
||||
if status == 200 and isinstance(data, list):
|
||||
for pos in data:
|
||||
pnl = float(pos.get("unRealizedProfit", 0))
|
||||
total_unrealized += pnl
|
||||
# 转为R
|
||||
unrealized_r = total_unrealized / risk_usd_dynamic if risk_usd_dynamic > 0 else 0
|
||||
risk_state.today_unrealized_r = unrealized_r
|
||||
return unrealized_r
|
||||
|
||||
|
||||
async def check_balance(session):
|
||||
"""检查余额是否足够"""
|
||||
data, status = await binance_request(session, "GET", "/fapi/v2/balance")
|
||||
if status == 200 and isinstance(data, list):
|
||||
for asset in data:
|
||||
if asset.get("asset") == "USDT":
|
||||
available = float(asset.get("availableBalance", 0))
|
||||
return available
|
||||
return 0
|
||||
|
||||
|
||||
def check_data_freshness(conn=None):
|
||||
"""检查数据新鲜度"""
|
||||
now = time.time()
|
||||
issues = []
|
||||
|
||||
api_gap = now - risk_state.last_api_success
|
||||
if api_gap > API_DISCONNECT_THRESHOLD_SEC:
|
||||
issues.append(f"API无响应{api_gap:.0f}秒")
|
||||
|
||||
# 检查行情数据新鲜度(signal_indicators最新ts)
|
||||
if conn:
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT MAX(ts) FROM signal_indicators")
|
||||
row = cur.fetchone()
|
||||
if row and row[0]:
|
||||
market_age = now - (row[0] / 1000)
|
||||
if market_age > MARKET_DATA_STALE_SEC:
|
||||
issues.append(f"行情数据延迟{market_age:.1f}秒")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
async def check_hold_timeout(session, conn):
|
||||
"""检查持仓超时,管理处置队列"""
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT id, symbol, direction, entry_ts, entry_price, risk_distance
|
||||
FROM live_trades
|
||||
WHERE status IN ('active', 'tp1_hit')
|
||||
""")
|
||||
now_ms = int(time.time() * 1000)
|
||||
|
||||
for row in cur.fetchall():
|
||||
trade_id, symbol, direction, entry_ts, entry_price, rd = row
|
||||
hold_min = (now_ms - entry_ts) / 60000
|
||||
|
||||
if hold_min >= HOLD_TIMEOUT_AUTO_CLOSE_MIN:
|
||||
# 70分钟:人工窗口到期,自动平仓
|
||||
if trade_id in risk_state.timeout_queue:
|
||||
logger.warning(f"[{symbol}] ⏰ 持仓{hold_min:.0f}分钟,人工窗口到期,自动市价平仓!")
|
||||
close_side = "SELL" if direction == "LONG" else "BUY"
|
||||
# 取消所有挂单
|
||||
await binance_request(session, "DELETE", "/fapi/v1/allOpenOrders", {"symbol": symbol})
|
||||
# 查仓位大小
|
||||
pos_data, _ = await binance_request(session, "GET", "/fapi/v2/positionRisk", {"symbol": symbol})
|
||||
if isinstance(pos_data, list):
|
||||
for p in pos_data:
|
||||
amt = abs(float(p.get("positionAmt", 0)))
|
||||
if amt > 0 and p["symbol"] == symbol:
|
||||
qty_prec = SYMBOL_QTY_PRECISION.get(symbol, 3)
|
||||
qty_str = f"{amt:.{qty_prec}f}"
|
||||
close_data, close_status = await binance_request(session, "POST", "/fapi/v1/order", {
|
||||
"symbol": symbol, "side": close_side, "type": "MARKET",
|
||||
"quantity": qty_str, "reduceOnly": "true",
|
||||
})
|
||||
if close_status != 200:
|
||||
logger.error(f"[{symbol}] ❌ 超时自动平仓失败: {close_data}")
|
||||
else:
|
||||
logger.info(f"[{symbol}] 🔴 超时自动平仓完成 qty={qty_str}")
|
||||
del risk_state.timeout_queue[trade_id]
|
||||
|
||||
elif hold_min >= HOLD_TIMEOUT_RED_MIN:
|
||||
# 60分钟:进入处置队列+10分钟倒计时
|
||||
if trade_id not in risk_state.timeout_queue:
|
||||
risk_state.timeout_queue[trade_id] = {
|
||||
"entered_queue_ts": time.time(),
|
||||
"notified": False,
|
||||
"symbol": symbol,
|
||||
}
|
||||
remaining = HOLD_TIMEOUT_GRACE_MIN
|
||||
logger.warning(f"[{symbol}] 🔴 持仓{hold_min:.0f}分钟超时! 进入处置队列, {remaining}分钟后自动平仓")
|
||||
# TODO: Discord通知范总
|
||||
|
||||
elif not risk_state.timeout_queue[trade_id]["notified"]:
|
||||
risk_state.timeout_queue[trade_id]["notified"] = True
|
||||
# TODO: Discord紧急通知
|
||||
|
||||
elif hold_min >= HOLD_TIMEOUT_YELLOW_MIN:
|
||||
logger.info(f"[{symbol}] 🟡 持仓{hold_min:.0f}分钟,接近超时")
|
||||
|
||||
|
||||
# ============ 熔断动作 ============
|
||||
|
||||
async def trigger_circuit_break(session, conn, reason: str, action: str = "block_new"):
|
||||
"""触发熔断"""
|
||||
now = time.time()
|
||||
risk_state.status = "circuit_break"
|
||||
risk_state.circuit_break_reason = reason
|
||||
risk_state.circuit_break_time = now
|
||||
|
||||
if action == "block_new":
|
||||
risk_state.block_new_entries = True
|
||||
logger.error(f"🔴 熔断触发: {reason} | 动作: 禁止新开仓")
|
||||
|
||||
elif action == "reduce_only":
|
||||
risk_state.block_new_entries = True
|
||||
risk_state.reduce_only = True
|
||||
logger.error(f"🔴 熔断触发: {reason} | 动作: 只减仓模式")
|
||||
|
||||
elif action == "close_all":
|
||||
risk_state.block_new_entries = True
|
||||
risk_state.reduce_only = True
|
||||
logger.error(f"🔴🔴 熔断触发: {reason} | 动作: 全部平仓!")
|
||||
|
||||
# 执行全平
|
||||
for symbol in SYMBOLS:
|
||||
await binance_request(session, "DELETE", "/fapi/v1/allOpenOrders", {"symbol": symbol})
|
||||
pos_data, _ = await binance_request(session, "GET", "/fapi/v2/positionRisk", {"symbol": symbol})
|
||||
if isinstance(pos_data, list):
|
||||
for p in pos_data:
|
||||
amt = float(p.get("positionAmt", 0))
|
||||
if amt != 0:
|
||||
close_side = "SELL" if amt > 0 else "BUY"
|
||||
qty_prec = SYMBOL_QTY_PRECISION.get(symbol, 3)
|
||||
qty_str = f"{abs(amt):.{qty_prec}f}"
|
||||
close_resp, close_status = await binance_request(session, "POST", "/fapi/v1/order", {
|
||||
"symbol": symbol, "side": close_side, "type": "MARKET",
|
||||
"quantity": qty_str, "reduceOnly": "true",
|
||||
})
|
||||
if close_status != 200:
|
||||
logger.error(f"[{symbol}] ❌ 紧急平仓失败: {close_resp}")
|
||||
_log_event(conn, "critical", "risk", f"紧急平仓失败 {symbol}", symbol,
|
||||
{"response": str(close_resp)})
|
||||
else:
|
||||
logger.info(f"[{symbol}] 🔴 紧急平仓 {close_side} qty={qty_str}")
|
||||
# 二次验仓
|
||||
verify, v_status = await binance_request(session, "GET", "/fapi/v2/positionRisk", {"symbol": symbol})
|
||||
if v_status == 200 and isinstance(verify, list):
|
||||
still_open = any(abs(float(p.get("positionAmt", 0))) > 0 and p.get("symbol") == symbol for p in verify)
|
||||
if still_open:
|
||||
logger.error(f"[{symbol}] ❌ 紧急平仓后仍有仓位! 需人工介入")
|
||||
_log_event(conn, "critical", "risk", f"紧急平仓后仍有仓位 {symbol}", symbol)
|
||||
|
||||
# 记录历史
|
||||
risk_state.breaker_history.append({
|
||||
"time": now,
|
||||
"reason": reason,
|
||||
"action": action,
|
||||
})
|
||||
|
||||
# 写状态文件(供其他进程读取)
|
||||
write_risk_state()
|
||||
|
||||
# 写event到DB
|
||||
_log_event(conn, "critical", "risk", f"🔴 熔断: {reason} | 动作: {action}", detail={"reason": reason, "action": action})
|
||||
|
||||
|
||||
def write_risk_state():
|
||||
"""写风控状态到文件(供live_executor读取判断是否可开仓)"""
|
||||
state_path = "/tmp/risk_guard_state.json"
|
||||
try:
|
||||
with open(state_path, "w") as f:
|
||||
json.dump(risk_state.to_dict(), f)
|
||||
except Exception as e:
|
||||
logger.error(f"写风控状态失败: {e}")
|
||||
|
||||
|
||||
def _log_event(conn, level, category, message, symbol=None, detail=None):
|
||||
"""写live_events表"""
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"INSERT INTO live_events (level, category, symbol, message, detail) VALUES (%s, %s, %s, %s, %s)",
|
||||
(level, category, symbol, message, json.dumps(detail) if detail else None)
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def check_auto_resume():
|
||||
"""检查是否可以自动恢复"""
|
||||
if risk_state.status != "circuit_break":
|
||||
return
|
||||
|
||||
now = time.time()
|
||||
|
||||
# 连续亏损冷却期到了
|
||||
if (risk_state.circuit_break_reason
|
||||
and "连续亏损" in risk_state.circuit_break_reason
|
||||
and risk_state.circuit_break_time):
|
||||
elapsed_min = (now - risk_state.circuit_break_time) / 60
|
||||
if elapsed_min >= CONSECUTIVE_LOSS_COOLDOWN_MIN:
|
||||
logger.info(f"✅ 连续亏损冷却期结束({CONSECUTIVE_LOSS_COOLDOWN_MIN}分钟),自动恢复交易")
|
||||
risk_state.status = "normal"
|
||||
risk_state.block_new_entries = False
|
||||
risk_state.reduce_only = False
|
||||
risk_state.circuit_break_reason = None
|
||||
write_risk_state()
|
||||
|
||||
# API恢复(仅限API断连导致的熔断,日限亏损等不自动恢复)
|
||||
if (risk_state.circuit_break_reason
|
||||
and risk_state.circuit_break_reason.startswith("API")
|
||||
and "日限" not in risk_state.circuit_break_reason
|
||||
and "人工" not in risk_state.circuit_break_reason):
|
||||
api_gap = now - risk_state.last_api_success
|
||||
if api_gap < 10: # API恢复正常10秒
|
||||
logger.info("✅ API连接恢复,自动恢复交易")
|
||||
risk_state.status = "normal"
|
||||
risk_state.block_new_entries = False
|
||||
risk_state.circuit_break_reason = None
|
||||
write_risk_state()
|
||||
|
||||
# 数据新鲜度恢复(熔断原因含"数据异常"且当前数据已恢复新鲜)
|
||||
if (risk_state.circuit_break_reason
|
||||
and "数据异常" in risk_state.circuit_break_reason
|
||||
and "人工" not in risk_state.circuit_break_reason):
|
||||
# 检查数据是否已恢复
|
||||
api_gap = now - risk_state.last_api_success
|
||||
if api_gap < 10:
|
||||
logger.info("✅ 数据新鲜度恢复,自动恢复交易")
|
||||
risk_state.status = "normal"
|
||||
risk_state.block_new_entries = False
|
||||
risk_state.circuit_break_reason = None
|
||||
write_risk_state()
|
||||
|
||||
|
||||
# ============ 前端紧急指令处理 ============
|
||||
|
||||
EMERGENCY_FILE = "/tmp/risk_guard_emergency.json"
|
||||
|
||||
async def check_emergency_commands(session, conn):
|
||||
"""读取前端发的紧急操作指令并执行"""
|
||||
try:
|
||||
with open(EMERGENCY_FILE) as f:
|
||||
cmd = json.load(f)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
except Exception:
|
||||
return
|
||||
|
||||
action = cmd.get("action")
|
||||
user = cmd.get("user", "unknown")
|
||||
logger.info(f"📩 收到紧急指令: {action} (操作人: {user})")
|
||||
|
||||
if action == "close_all":
|
||||
await trigger_circuit_break(session, conn, f"人工紧急全平 (操作人: {user})", "close_all")
|
||||
|
||||
elif action == "block_new":
|
||||
risk_state.block_new_entries = True
|
||||
risk_state.status = "warning"
|
||||
risk_state.circuit_break_reason = f"人工禁止新开仓 (操作人: {user})"
|
||||
write_risk_state()
|
||||
logger.warning(f"🟡 人工禁止新开仓 (操作人: {user})")
|
||||
_log_event(conn, "warn", "risk", f"🟡 人工禁止新开仓 (操作人: {user})")
|
||||
|
||||
elif action == "resume":
|
||||
risk_state.status = "normal"
|
||||
risk_state.block_new_entries = False
|
||||
risk_state.reduce_only = False
|
||||
risk_state.circuit_break_reason = None
|
||||
risk_state.circuit_break_time = None
|
||||
write_risk_state()
|
||||
logger.info(f"✅ 人工恢复交易 (操作人: {user})")
|
||||
_log_event(conn, "info", "risk", f"✅ 人工恢复交易 (操作人: {user})")
|
||||
|
||||
else:
|
||||
logger.warning(f"⚠ 未知紧急指令: {action}")
|
||||
|
||||
# 操作完成+state已写入后,才删除emergency文件(消除TOCTOU竞争)
|
||||
try:
|
||||
os.remove(EMERGENCY_FILE)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ============ 主循环 ============
|
||||
|
||||
def load_live_risk_usd(conn, default=2.0):
|
||||
"""从live_config动态读取1R金额,与live_executor保持一致"""
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT value FROM live_config WHERE key='risk_per_trade_usd'")
|
||||
row = cur.fetchone()
|
||||
return float(row[0]) if row and row[0] else default
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def ensure_db_conn(conn):
|
||||
"""检查DB连接,断线则重连"""
|
||||
try:
|
||||
conn.cursor().execute("SELECT 1")
|
||||
return conn
|
||||
except Exception:
|
||||
logger.warning("⚠️ DB连接断开,重连中...")
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
return psycopg2.connect(**DB_CONFIG)
|
||||
|
||||
|
||||
async def main():
|
||||
logger.info("=" * 60)
|
||||
logger.info(f"🛡 Risk Guard 启动 | 环境={TRADE_ENV}")
|
||||
logger.info(f" 日限={DAILY_LOSS_LIMIT_R}R | 连亏限={CONSECUTIVE_LOSS_LIMIT}次 | 冷却={CONSECUTIVE_LOSS_COOLDOWN_MIN}分钟")
|
||||
logger.info(f" 超时: {HOLD_TIMEOUT_YELLOW_MIN}min黄/{HOLD_TIMEOUT_RED_MIN}min红/{HOLD_TIMEOUT_AUTO_CLOSE_MIN}min自动平")
|
||||
logger.info("=" * 60)
|
||||
|
||||
load_api_keys()
|
||||
conn = psycopg2.connect(**DB_CONFIG)
|
||||
|
||||
# 初始状态写入
|
||||
write_risk_state()
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
while True:
|
||||
try:
|
||||
conn = ensure_db_conn(conn)
|
||||
# 0. 检查自动恢复
|
||||
check_auto_resume()
|
||||
|
||||
# 0.5 检查前端紧急操作指令
|
||||
await check_emergency_commands(session, conn)
|
||||
|
||||
# 1. 今日亏损检查
|
||||
realized_r, consecutive = check_daily_loss(conn)
|
||||
_live_risk_usd = load_live_risk_usd(conn)
|
||||
unrealized_r = await check_unrealized_loss(session, _live_risk_usd)
|
||||
total_r = realized_r + min(unrealized_r, 0) # 已实现 + 未实现亏损
|
||||
|
||||
if total_r <= DAILY_LOSS_LIMIT_R and risk_state.status != "circuit_break":
|
||||
await trigger_circuit_break(
|
||||
session, conn,
|
||||
f"今日亏损{total_r:.2f}R,超过日限{DAILY_LOSS_LIMIT_R}R",
|
||||
"close_all"
|
||||
)
|
||||
|
||||
# 2. 连续亏损检查
|
||||
if (consecutive >= CONSECUTIVE_LOSS_LIMIT
|
||||
and risk_state.status != "circuit_break"):
|
||||
await trigger_circuit_break(
|
||||
session, conn,
|
||||
f"连续亏损{consecutive}次,超过限制{CONSECUTIVE_LOSS_LIMIT}次",
|
||||
"block_new"
|
||||
)
|
||||
risk_state.auto_resume_time = time.time() + CONSECUTIVE_LOSS_COOLDOWN_MIN * 60
|
||||
|
||||
# 3. API连接检查
|
||||
freshness_issues = check_data_freshness(conn)
|
||||
if freshness_issues and risk_state.status != "circuit_break":
|
||||
await trigger_circuit_break(
|
||||
session, conn,
|
||||
f"数据异常: {'; '.join(freshness_issues)}",
|
||||
"block_new"
|
||||
)
|
||||
|
||||
# 4. 余额检查
|
||||
balance = await check_balance(session)
|
||||
threshold = RISK_PER_TRADE_USD * MIN_BALANCE_MULTIPLE
|
||||
if balance < threshold:
|
||||
if risk_state.circuit_break_reason != "LOW_BALANCE":
|
||||
risk_state.block_new_entries = True
|
||||
risk_state.status = "warning"
|
||||
risk_state.circuit_break_reason = "LOW_BALANCE"
|
||||
logger.warning(f"🟡 余额不足: ${balance:.2f} < ${threshold:.2f},暂停开仓")
|
||||
else:
|
||||
if risk_state.circuit_break_reason == "LOW_BALANCE":
|
||||
risk_state.block_new_entries = False
|
||||
risk_state.status = "normal"
|
||||
risk_state.circuit_break_reason = None
|
||||
logger.info(f"✅ 余额恢复: ${balance:.2f} >= ${threshold:.2f},解除暂停")
|
||||
|
||||
# 5. 持仓超时检查
|
||||
await check_hold_timeout(session, conn)
|
||||
|
||||
# 6. 写状态
|
||||
write_risk_state()
|
||||
|
||||
# 日志(每60秒输出一次状态)
|
||||
if int(time.time()) % 60 < CHECK_INTERVAL:
|
||||
logger.info(
|
||||
f"📊 风控状态: {risk_state.status} | "
|
||||
f"已实现={realized_r:+.2f}R | 未实现={unrealized_r:+.2f}R | "
|
||||
f"合计={total_r:+.2f}R | 连亏={consecutive} | "
|
||||
f"余额=${balance:.2f}"
|
||||
)
|
||||
|
||||
await asyncio.sleep(CHECK_INTERVAL)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 风控检查异常: {e}", exc_info=True)
|
||||
await asyncio.sleep(10)
|
||||
|
||||
conn.close()
|
||||
logger.info("Risk Guard 已停止")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
File diff suppressed because it is too large
Load Diff
19
backend/strategies/v51_baseline.json
Normal file
19
backend/strategies/v51_baseline.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "v51_baseline",
|
||||
"version": "5.1",
|
||||
"threshold": 75,
|
||||
"weights": {
|
||||
"direction": 45,
|
||||
"crowding": 20,
|
||||
"environment": 15,
|
||||
"confirmation": 15,
|
||||
"auxiliary": 5
|
||||
},
|
||||
"accel_bonus": 5,
|
||||
"tp_sl": {
|
||||
"sl_multiplier": 1.4,
|
||||
"tp1_multiplier": 1.05,
|
||||
"tp2_multiplier": 2.1
|
||||
},
|
||||
"signals": ["cvd", "p99", "accel", "ls_ratio", "oi", "coinbase_premium"]
|
||||
}
|
||||
21
backend/strategies/v52_8signals.json
Normal file
21
backend/strategies/v52_8signals.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"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": 2.1,
|
||||
"tp1_multiplier": 1.4,
|
||||
"tp2_multiplier": 3.15
|
||||
},
|
||||
"signals": ["cvd", "p99", "accel", "ls_ratio", "oi", "coinbase_premium", "funding_rate", "liquidation"]
|
||||
}
|
||||
47
backend/strategies/v53.json
Normal file
47
backend/strategies/v53.json
Normal file
@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "v53",
|
||||
"version": "5.3",
|
||||
"description": "V5.3 统一策略(BTC/ETH/XRP/SOL): 四层评分 55/25/15/5 + per-symbol 四门控制",
|
||||
"threshold": 75,
|
||||
"flip_threshold": 85,
|
||||
"weights": {
|
||||
"direction": 55,
|
||||
"crowding": 25,
|
||||
"environment": 15,
|
||||
"auxiliary": 5
|
||||
},
|
||||
"tp_sl": {
|
||||
"sl_multiplier": 2.0,
|
||||
"tp1_multiplier": 1.5,
|
||||
"tp2_multiplier": 3.0,
|
||||
"tp_maker": true
|
||||
},
|
||||
"symbols": ["BTCUSDT", "ETHUSDT", "XRPUSDT", "SOLUSDT"],
|
||||
"symbol_gates": {
|
||||
"BTCUSDT": {
|
||||
"min_vol_threshold": 0.002,
|
||||
"whale_threshold_usd": 100000,
|
||||
"whale_flow_threshold_pct": 0.5,
|
||||
"obi_veto_threshold": 0.30,
|
||||
"spot_perp_divergence_veto": 0.003
|
||||
},
|
||||
"ETHUSDT": {
|
||||
"min_vol_threshold": 0.003,
|
||||
"whale_threshold_usd": 50000,
|
||||
"obi_veto_threshold": 0.35,
|
||||
"spot_perp_divergence_veto": 0.005
|
||||
},
|
||||
"SOLUSDT": {
|
||||
"min_vol_threshold": 0.004,
|
||||
"whale_threshold_usd": 20000,
|
||||
"obi_veto_threshold": 0.45,
|
||||
"spot_perp_divergence_veto": 0.008
|
||||
},
|
||||
"XRPUSDT": {
|
||||
"min_vol_threshold": 0.0025,
|
||||
"whale_threshold_usd": 30000,
|
||||
"obi_veto_threshold": 0.40,
|
||||
"spot_perp_divergence_veto": 0.006
|
||||
}
|
||||
}
|
||||
}
|
||||
60
backend/strategies/v53_fast.json
Normal file
60
backend/strategies/v53_fast.json
Normal file
@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "v53_fast",
|
||||
"version": "5.3-fast",
|
||||
"description": "V5.3 Fast 实验变体: CVD 5m/30m 短窗口 + OBI正向加分 + accel独立触发。对照组: v53(30m/4h)。",
|
||||
"threshold": 75,
|
||||
"flip_threshold": 85,
|
||||
"cvd_window_fast_ms": 300000,
|
||||
"cvd_window_mid_ms": 1800000,
|
||||
"weights": {
|
||||
"direction": 55,
|
||||
"crowding": 25,
|
||||
"environment": 15,
|
||||
"auxiliary": 5
|
||||
},
|
||||
"obi_scoring": {
|
||||
"strong_threshold": 0.30,
|
||||
"weak_threshold": 0.15,
|
||||
"strong_score": 5,
|
||||
"weak_score": 3
|
||||
},
|
||||
"accel_independent": {
|
||||
"enabled": true,
|
||||
"accel_percentile_threshold": 0.95,
|
||||
"min_direction_score": 35
|
||||
},
|
||||
"tp_sl": {
|
||||
"sl_multiplier": 2.0,
|
||||
"tp1_multiplier": 1.5,
|
||||
"tp2_multiplier": 3.0,
|
||||
"tp_maker": true
|
||||
},
|
||||
"symbols": ["BTCUSDT", "ETHUSDT", "XRPUSDT", "SOLUSDT"],
|
||||
"symbol_gates": {
|
||||
"BTCUSDT": {
|
||||
"min_vol_threshold": 0.002,
|
||||
"whale_threshold_usd": 100000,
|
||||
"whale_flow_threshold_pct": 0.5,
|
||||
"obi_veto_threshold": 0.30,
|
||||
"spot_perp_divergence_veto": 0.003
|
||||
},
|
||||
"ETHUSDT": {
|
||||
"min_vol_threshold": 0.003,
|
||||
"whale_threshold_usd": 50000,
|
||||
"obi_veto_threshold": 0.35,
|
||||
"spot_perp_divergence_veto": 0.005
|
||||
},
|
||||
"SOLUSDT": {
|
||||
"min_vol_threshold": 0.004,
|
||||
"whale_threshold_usd": 20000,
|
||||
"obi_veto_threshold": 0.45,
|
||||
"spot_perp_divergence_veto": 0.008
|
||||
},
|
||||
"XRPUSDT": {
|
||||
"min_vol_threshold": 0.0025,
|
||||
"whale_threshold_usd": 30000,
|
||||
"obi_veto_threshold": 0.40,
|
||||
"spot_perp_divergence_veto": 0.006
|
||||
}
|
||||
}
|
||||
}
|
||||
49
backend/strategies/v53_middle.json
Normal file
49
backend/strategies/v53_middle.json
Normal file
@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "v53_middle",
|
||||
"version": "5.3",
|
||||
"description": "V5.3 Middle版(BTC/ETH/XRP/SOL): CVD 15m/1h 窗口,适合1h信号时框",
|
||||
"threshold": 75,
|
||||
"flip_threshold": 85,
|
||||
"cvd_fast_window": "15m",
|
||||
"cvd_slow_window": "1h",
|
||||
"weights": {
|
||||
"direction": 55,
|
||||
"crowding": 25,
|
||||
"environment": 15,
|
||||
"auxiliary": 5
|
||||
},
|
||||
"tp_sl": {
|
||||
"sl_multiplier": 2.0,
|
||||
"tp1_multiplier": 1.5,
|
||||
"tp2_multiplier": 3.0,
|
||||
"tp_maker": true
|
||||
},
|
||||
"symbols": ["BTCUSDT", "ETHUSDT", "XRPUSDT", "SOLUSDT"],
|
||||
"symbol_gates": {
|
||||
"BTCUSDT": {
|
||||
"min_vol_threshold": 0.002,
|
||||
"whale_threshold_usd": 100000,
|
||||
"whale_flow_threshold_pct": 0.5,
|
||||
"obi_veto_threshold": 0.30,
|
||||
"spot_perp_divergence_veto": 0.003
|
||||
},
|
||||
"ETHUSDT": {
|
||||
"min_vol_threshold": 0.003,
|
||||
"whale_threshold_usd": 50000,
|
||||
"obi_veto_threshold": 0.35,
|
||||
"spot_perp_divergence_veto": 0.005
|
||||
},
|
||||
"SOLUSDT": {
|
||||
"min_vol_threshold": 0.004,
|
||||
"whale_threshold_usd": 20000,
|
||||
"obi_veto_threshold": 0.45,
|
||||
"spot_perp_divergence_veto": 0.008
|
||||
},
|
||||
"XRPUSDT": {
|
||||
"min_vol_threshold": 0.0025,
|
||||
"whale_threshold_usd": 30000,
|
||||
"obi_veto_threshold": 0.40,
|
||||
"spot_perp_divergence_veto": 0.006
|
||||
}
|
||||
}
|
||||
}
|
||||
14
backend/trade_config.py
Normal file
14
backend/trade_config.py
Normal file
@ -0,0 +1,14 @@
|
||||
"""交易配置常量 — 所有实盘模块共用"""
|
||||
|
||||
SYMBOLS = ["BTCUSDT", "ETHUSDT", "XRPUSDT", "SOLUSDT"]
|
||||
|
||||
# 币安合约精度(qty=数量小数位, price=价格小数位, min_notional=最小名义价值)
|
||||
SYMBOL_PRECISION = {
|
||||
"BTCUSDT": {"qty": 3, "price": 1, "min_notional": 100},
|
||||
"ETHUSDT": {"qty": 3, "price": 2, "min_notional": 20},
|
||||
"XRPUSDT": {"qty": 0, "price": 4, "min_notional": 5},
|
||||
"SOLUSDT": {"qty": 2, "price": 4, "min_notional": 5},
|
||||
}
|
||||
|
||||
# qty精度快捷查询
|
||||
SYMBOL_QTY_PRECISION = {sym: p["qty"] for sym, p in SYMBOL_PRECISION.items()}
|
||||
36
docs/AB_TEST_CHECKLIST.md
Normal file
36
docs/AB_TEST_CHECKLIST.md
Normal 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.1:500+笔(当前282)
|
||||
- V5.2:200+笔(当前12)
|
||||
- 每策略每币种50+笔
|
||||
379
docs/DETAILED_CODE_REVIEW.md
Normal file
379
docs/DETAILED_CODE_REVIEW.md
Normal 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` | 本地 PG(127.0.0.1)|
|
||||
| live_executor 的 LISTEN 监听 | Cloud SQL(10.106.0.3)|
|
||||
| live_executor 的轮询查 `signal_indicators` | Cloud SQL(10.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 SQL(DB_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 SQL),F2 也保证 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`(SQLite),V5 信号全在 PG 的 `signal_indicators` 表,此脚本从不读取
|
||||
2. 只覆盖 BTC/ETH,XRP/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`(建表 SQL)vs `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`(建表 SQL)vs `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/ETH,XRP/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/ETH,XRP/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 写本地 PG,live_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 | 签名扫描 |
|
||||
145
docs/PROBLEM_REPORT.md
Normal file
145
docs/PROBLEM_REPORT.md
Normal file
@ -0,0 +1,145 @@
|
||||
# 项目问题报告
|
||||
|
||||
> 生成时间:2026-03-03
|
||||
> 基于 commit `0d9dffa` 的代码分析
|
||||
|
||||
---
|
||||
|
||||
## 🔴 高危问题(可能导致实盘出错)
|
||||
|
||||
### P1:数据库从未真正迁移到云端
|
||||
|
||||
**现象**:你以为整个系统已经跑在 Cloud SQL 上,实际上只有 `agg_trades` 原始成交数据在双写。其他核心数据全在**本地 PG(127.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、截图等),测试网数据库直接裸奔。
|
||||
|
||||
### P8:JWT 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 和安全扫描 |
|
||||
61
docs/V52-TODO.md
Normal file
61
docs/V52-TODO.md
Normal 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小时数据 |
|
||||
117
docs/ai/00-system-overview.md
Normal file
117
docs/ai/00-system-overview.md
Normal 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
|
||||
137
docs/ai/01-architecture-map.md
Normal file
137
docs/ai/01-architecture-map.md
Normal 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 0–100, 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
|
||||
218
docs/ai/02-module-cheatsheet.md
Normal file
218
docs/ai/02-module-cheatsheet.md
Normal 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
242
docs/ai/03-api-contracts.md
Normal 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
301
docs/ai/04-data-model.md
Normal 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 0–100 |
|
||||
| `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
|
||||
251
docs/ai/05-build-run-test.md
Normal file
251
docs/ai/05-build-run-test.md
Normal 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 VM,PM2 管理进程。
|
||||
- 后端无构建步骤,直接 `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.x(package.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`(RotatingFileHandler,10MB×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
162
docs/ai/06-decision-log.md
Normal 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
|
||||
|
||||
### 决策 1:PostgreSQL 作为进程间消息总线
|
||||
**决策**:使用 PostgreSQL `NOTIFY/LISTEN` 在 signal_engine 和 live_executor 之间传递信号,而非 Redis pub/sub 或消息队列。
|
||||
|
||||
**原因**(从代码推断):
|
||||
- 系统已强依赖 PG;避免引入新的基础设施依赖。
|
||||
- 信号触发频率低(每 15 秒最多一次),PG NOTIFY 完全满足延迟要求。
|
||||
- 信号 payload 直接写入 `signal_indicators` 表,NOTIFY 仅做触发通知,消费者可直接查表。
|
||||
|
||||
**取舍**:单点依赖 PG;PG 宕机时信号传递和持久化同时失败(可接受,因为两者本就强耦合)。
|
||||
|
||||
**来源**:`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改15,CPU降60%,信号质量无影响)`
|
||||
|
||||
---
|
||||
|
||||
### 决策 3:agg_trades 表按月范围分区
|
||||
**决策**:`agg_trades` 使用 `PARTITION BY RANGE(time_ms)`,按月创建子表(如 `agg_trades_202603`)。
|
||||
|
||||
**原因**:
|
||||
- aggTrades 是最大的写入表(每秒数百条),无分区会导致单表膨胀。
|
||||
- 按月分区支持高效的时间范围查询(PG 分区裁剪)。
|
||||
- 旧分区可独立归档或删除,不影响主表。
|
||||
|
||||
**取舍**:分区管理需要维护(`ensure_partitions()` 自动创建当月+未来2个月分区,需定期执行);跨分区查询性能取决于分区裁剪是否生效(`time_ms` 条件必须是常量)。
|
||||
|
||||
**来源**:`db.py:191-201, 360-393`
|
||||
|
||||
---
|
||||
|
||||
### 决策 4:Cloud 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 ≥ threshold(75);"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`
|
||||
|
||||
---
|
||||
|
||||
### 决策 9:live_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] 所有决策均从代码推断,无明确的 ADR(Architecture Decision Record)文档。
|
||||
- [unknown] 策略配置是否支持热重载(signal_engine 是否每次循环都重读 JSON)未确认。
|
||||
- [risk] 决策 4(双写)+ 决策 9(live 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
100
docs/ai/07-glossary.md
Normal 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%`,将单次资金费率换算为年化百分比。 |
|
||||
| **CVD(Cumulative Volume Delta)** | 累计成交量差值 = 主动买量 - 主动卖量。正值表示买方主导,负值表示卖方主导。本项目计算三个窗口:CVD_fast(30分钟)、CVD_mid(4小时)、CVD_day(UTC日内)。 |
|
||||
| **aggTrade** | Binance 聚合成交数据:同一方向、同一价格、同一时刻的多笔成交合并为一条记录,包含 `is_buyer_maker` 字段(0=主动买,1=主动卖)。 |
|
||||
| **is_buyer_maker** | `0`:买方是 taker(主动买入),`1`:买方是 maker(被动成交,即主动卖)。CVD 计算:0→买量,1→卖量。 |
|
||||
| **VWAP(Volume Weighted Average Price)** | 成交量加权平均价格。用于判断当前价格相对于短期平均成本的位置。 |
|
||||
| **ATR(Average 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 Interest(OI,持仓量)** | 市场上所有未平仓合约的总名义价值(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 / TP2(Take Profit)** | 止盈目标价。TP1 为第一目标(触发后平一半仓位),TP2 为第二目标(平剩余)。 |
|
||||
| **SL(Stop Loss)** | 止损价。SL 触发后视 TP1 是否已命中:未命中→亏损 1R;已命中→保本(sl_be 状态)。 |
|
||||
| **Tier(档位)** | 仓位大小分级。`light`=0.5×R,`standard`=1.0×R,`heavy`=1.5×R。信号分数越高触发越重的档位:score ≥ max(threshold+10, 85) → heavy;score ≥ 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×ATR,TP1=1.05×ATR,TP2=2.1×ATR。 |
|
||||
| **v52_8signals** | V5.2 扩展策略。8 个信号(v51 + funding_rate + liquidation)。SL=2.1×ATR,TP1=1.4×ATR,TP2=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 SQL,Cloud 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` — "万分之" 显示注释
|
||||
141
docs/ai/99-open-questions.md
Normal file
141
docs/ai/99-open-questions.md
Normal 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
|
||||
|
||||
### 高优先级(影响正确性)
|
||||
|
||||
#### Q1:users 表 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`
|
||||
|
||||
---
|
||||
|
||||
#### Q2:live_executor 读 Cloud SQL,signal_engine 写本地 PG,双写延迟是否可接受
|
||||
**问题**:signal_engine 写入本地 PG(`signal_indicators`),同时双写 Cloud SQL;live_executor 直连 Cloud SQL 读取信号。若某次双写失败或延迟,live_executor 可能错过信号或读到不一致数据。
|
||||
|
||||
**影响**:实盘信号丢失或执行延迟。
|
||||
|
||||
**建议行动**:确认 NOTIFY 是否也发送到 Cloud SQL(即 live_executor 通过 LISTEN 接收信号,不依赖轮询读表);或将 live_executor 改为连接本地 PG。
|
||||
|
||||
**来源**:`live_executor.py:50-55`,`db.py:76-95`
|
||||
|
||||
---
|
||||
|
||||
#### Q3:requirements.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 函数结构)
|
||||
|
||||
---
|
||||
|
||||
#### Q5:uvicorn 端口确认
|
||||
**问题**:从 `frontend/next.config.ts` 推断 uvicorn 运行在 `127.0.0.1:4332`,但没有找到后端启动脚本明确指定此端口。
|
||||
|
||||
**建议行动**:在 `ecosystem.dev.config.js` 或启动脚本中显式记录端口。
|
||||
|
||||
**来源**:`frontend/next.config.ts:8`
|
||||
|
||||
---
|
||||
|
||||
#### Q6:market_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 定义)
|
||||
|
||||
---
|
||||
|
||||
#### Q7:liquidations 表 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
52
docs/ai/INDEX.md
Normal 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)
|
||||
251
docs/arbitrage-engine/execution-state-machine.md
Normal file
251
docs/arbitrage-engine/execution-state-machine.md
Normal 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 入场挂单相对盘口的偏移(通常为 1–2 个 tick)。
|
||||
- `entry_timeout_ms`:入场 maker 挂单最大等待时间(如 3000–5000ms)。
|
||||
- `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
|
||||
205
docs/arbitrage-engine/funding-rate-arbitrage-plan.md
Normal file
205
docs/arbitrage-engine/funding-rate-arbitrage-plan.md
Normal 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,000,1倍杠杆)
|
||||
|
||||
BTC涨跌:两边对冲,净盈亏=0
|
||||
资金费率:每8小时直接收USDT
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 全周期真实数据(2019-2026,Binance实算)
|
||||
|
||||
### BTC(BTCUSDT永续,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%
|
||||
|
||||
### ETH(ETHUSDT永续,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. **策略已被机构验证**:Ethena(TVL数十亿)、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.95(5%折扣)
|
||||
- 支持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-Codex)15轮交叉验证:
|
||||
- 数据来源:Binance fapi/v1/fundingRate 官方API
|
||||
- 覆盖周期:2019年9月至2026年2月(约6.5年)
|
||||
- 验证内容:费率均值、年化收益、负费率占比、手续费敏感性、PM模式资金效率
|
||||
- 外部参考:Presto Research、Ethena、CoinCryptoRank、FMZ量化
|
||||
20
docs/arbitrage-engine/index.mdx
Normal file
20
docs/arbitrage-engine/index.mdx
Normal 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(产品+技术+商业)
|
||||
138
docs/arbitrage-engine/phase0-progress.md
Normal file
138
docs/arbitrage-engine/phase0-progress.md
Normal file
@ -0,0 +1,138 @@
|
||||
---
|
||||
title: Phase 0 开发进度
|
||||
---
|
||||
|
||||
> 更新日期:2026年2月27日
|
||||
> 状态:🟡 Phase 0 进行中(监控面板已上线,SaaS MVP已上线)
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — 已完成
|
||||
|
||||
### 监控面板(arb.zhouyangclaw.com)✅
|
||||
|
||||
**上线时间**:2026-02-26
|
||||
|
||||
**技术栈**:
|
||||
- 后端:FastAPI(Python,端口4332)
|
||||
- 前端:Next.js 16 + shadcn/ui + Tailwind + Recharts(端口4333)
|
||||
- 部署:小周服务器 34.84.9.167,Caddy反代,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组合)
|
||||
```
|
||||
|
||||
**性能优化**:
|
||||
- rates:3秒缓存(2秒前端刷新,不会触发限速)
|
||||
- history/stats:60秒缓存(避免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,小周服务器)
|
||||
|
||||
**数据库**:SQLite(arb.db),signal_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 Key(Read + Trade,禁止Withdraw)
|
||||
2. 确认Portfolio Margin账户已开通
|
||||
3. 初始资金就位(建议$500 = BTC$250 + ETH$250)
|
||||
540
docs/arbitrage-engine/requirements-v1.3.md
Normal file
540
docs/arbitrage-engine/requirements-v1.3.md
Normal 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 — 开仓/平仓需人工确认,持仓期间自动化 |
|
||||
| 初始实盘资金 | $500(BTC $250 + ETH $250) |
|
||||
| 收益处理 | 留账户复利,不自动提取 |
|
||||
| 部署服务器 | 小周服务器 34.84.9.167(GCP东京,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)
|
||||
|
||||
### 认证与安全
|
||||
- 账号密码 + TOTP(Google 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. 数据库结构(PostgreSQL,v1.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. 商业模式设计
|
||||
|
||||
### 方案A:SaaS订阅制(推荐先做)
|
||||
- 参考定价:$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.167(GCP asia-northeast1-b)
|
||||
- **选择理由**:
|
||||
- Binance API TCP延迟 11ms(露露服务器22ms的一半)
|
||||
- 内存可用 6.4GB、磁盘可用 187GB
|
||||
- 仅1个PM2服务运行,负载极低
|
||||
- GCP基础设施安全性高于普通VPS
|
||||
- 非root用户运行(fzq1228)
|
||||
|
||||
### 网络访问方案(域名 + HTTPS + 强认证)
|
||||
- **访问方式**:子域名 + Caddy自动HTTPS
|
||||
- **不使用IP白名单**(范总IP不固定)
|
||||
- **安全靠认证层保障**:
|
||||
|
||||
| 安全层 | 措施 |
|
||||
|--------|------|
|
||||
| 传输层 | HTTPS(Caddy自动TLS证书) |
|
||||
| 认证层 | 账号密码 + TOTP双因素 |
|
||||
| 会话层 | 30分钟超时自动登出 |
|
||||
| 防暴破 | 连续5次登录失败锁定15分钟 |
|
||||
| 审计层 | 所有登录/操作记录,append-only |
|
||||
| 数据库 | PostgreSQL仅监听localhost |
|
||||
| 进程隔离 | 单独系统用户运行套利引擎 |
|
||||
|
||||
### GCP防火墙规则(需配置)
|
||||
- 开放端口:仅HTTPS(443)
|
||||
- 其他端口:全部关闭
|
||||
- SSH:仅密钥登录
|
||||
|
||||
### Phase 2 安全升级路径
|
||||
- 可选:Cloudflare Zero Trust(Tunnel + 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. 开始开发
|
||||
203
docs/arbitrage-engine/strategy-plaza-data-contract.md
Normal file
203
docs/arbitrage-engine/strategy-plaza-data-contract.md
Normal 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 | 初版,露露起草 + 小范审阅 |
|
||||
417
docs/arbitrage-engine/v2-v4-plan.mdx
Normal file
417
docs/arbitrage-engine/v2-v4-plan.mdx
Normal 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)*
|
||||
239
docs/arbitrage-engine/v5-signal-system.mdx
Normal file
239
docs/arbitrage-engine/v5-signal-system.mdx
Normal file
@ -0,0 +1,239 @@
|
||||
---
|
||||
title: V5 短线交易信号系统方案
|
||||
---
|
||||
|
||||
# V5 短线交易信号系统方案
|
||||
|
||||
> 版本:v5.0 | 日期:2026-02-27 | 状态:方案定稿,待开发
|
||||
>
|
||||
> 来源:露露(Opus 4.6)× 小周(GPT-5.3-Codex)10轮讨论
|
||||
|
||||
---
|
||||
|
||||
## 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 CVD(Cumulative Volume Delta)
|
||||
|
||||
```
|
||||
CVD = Σ(主动买量) - Σ(主动卖量)
|
||||
|
||||
三轨并行:
|
||||
- CVD_fast:滚动30m窗口(入场信号)
|
||||
- CVD_mid:滚动4h窗口(方向过滤)
|
||||
- CVD_day:UTC日内重置(盘中基线)
|
||||
```
|
||||
|
||||
入场优先看 fast,方向必须与 mid 同向。
|
||||
|
||||
### 3.2 大单阈值(动态分位数)
|
||||
|
||||
```
|
||||
基于最近24h成交量分布:
|
||||
- P99:超大单阈值
|
||||
- P95:大单阈值
|
||||
- 兜底下限:max(P95, 5 BTC)
|
||||
|
||||
区分"大单买"和"大单卖"的不对称性:
|
||||
- 上涨趋势中出现P99大卖单,意义远大于P99大买单
|
||||
```
|
||||
|
||||
### 3.3 ATR(Average True Range)
|
||||
|
||||
```
|
||||
周期:5分钟K线,14根
|
||||
用于:
|
||||
1. 波动压缩→扩张判断(分位数)
|
||||
2. 止损距离计算
|
||||
```
|
||||
|
||||
### 3.4 VWAP(Volume 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** | **短线交易信号系统方案定稿** |
|
||||
146
docs/arbitrage-engine/v51-optimization-plan.md
Normal file
146
docs/arbitrage-engine/v51-optimization-plan.md
Normal 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%交易频次) |
|
||||
| 手续费节省 | - | ~65R(108×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/rd,rd变大则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 2:TP/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是否需要范总确认?
|
||||
163
docs/arbitrage-engine/v51-performance-analysis.md
Normal file
163
docs/arbitrage-engine/v51-performance-analysis.md
Normal 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 USD,1R=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)远超TP(132),SL吃掉232.85R,TP只回收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)
|
||||
250
docs/arbitrage-engine/v51-signal-enhancement.md
Normal file
250
docs/arbitrage-engine/v51-signal-enhancement.md
Normal 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 权重分配
|
||||
|
||||
| 层级 | 数据源 | 权重 | 角色 |
|
||||
|------|--------|------|------|
|
||||
| **方向层** | aggTrades(CVD三轨 + P95/P99大单) | **45%** | 核心方向判断 |
|
||||
| **拥挤层** | L/S Ratio + Top Trader Position | **20%** | 市场拥挤度 |
|
||||
| **环境层** | Open Interest 变化 | **15%** | 资金活跃度/可交易性门槛 |
|
||||
| **确认层** | 多时间框架一致性 | **15%** | 方向确认 |
|
||||
| **辅助层** | Coinbase Premium | **5%** | 机构资金流向 |
|
||||
|
||||
### 2.2 各层级详细计算
|
||||
|
||||
#### 方向层(45分)
|
||||
|
||||
- **CVD_fast(30m滚动)方向**:与信号方向一致 +15
|
||||
- **CVD_mid(4h滚动)方向**:与信号方向一致 +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_5m:5分钟K线,14周期 → 管入场灵敏度
|
||||
- ATR_1h:1小时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 免费 API(V5.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 | 待范总最终确认*
|
||||
132
docs/arbitrage-engine/v51-signal-system.md
Normal file
132
docs/arbitrage-engine/v51-signal-system.md
Normal 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%(最佳档位)
|
||||
175
docs/arbitrage-engine/v51_gemini_analysis.md
Normal file
175
docs/arbitrage-engine/v51_gemini_analysis.md
Normal 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` 终止计算)。
|
||||
|
||||
|
||||
|
||||
|
||||
* **子项 B:P99 大单流入 (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 文件的模板?
|
||||
383
docs/arbitrage-engine/v52-development.md
Normal file
383
docs/arbitrage-engine/v52-development.md
Normal 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 Bug(hotfix已上线)
|
||||
|
||||
| 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 | 全部写好测好,一次性部署 | 减少重启 |
|
||||
|
||||
---
|
||||
|
||||
## 十一、数据需求
|
||||
|
||||
### 当前数据量
|
||||
- aggTrades:7039万条(BTC 23天 + ETH 3天 + XRP/SOL 3天)
|
||||
- signal_indicators:2.7万+条
|
||||
- paper_trades:181笔
|
||||
- market_indicators:5400+条
|
||||
- liquidations:3600+条
|
||||
|
||||
### 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开发的完整参考,开发过程中持续更新。*
|
||||
*商业机密:策略细节不对外暴露。*
|
||||
269
docs/arbitrage-engine/v52-evolution-roadmap.md
Normal file
269
docs/arbitrage-engine/v52-evolution-roadmap.md
Normal 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-Arena(GitHub开源)— 相似度85%
|
||||
- **地址**:https://github.com/HammerGPT/Hyper-Alpha-Arena
|
||||
- **核心**:监控Order Flow + OI变化 + Funding Rate极端值,触发自动交易
|
||||
- 支持币安合约+Hyperliquid,用LLM(GPT-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/月起 | 鲸鱼行为 |
|
||||
193
docs/arbitrage-engine/v52-performance-analysis.md
Normal file
193
docs/arbitrage-engine/v52-performance-analysis.md
Normal file
@ -0,0 +1,193 @@
|
||||
---
|
||||
title: V5.2 模拟盘执行分析报告
|
||||
date: 2026-03-03
|
||||
---
|
||||
|
||||
# V5.2 模拟盘执行分析报告
|
||||
|
||||
> 数据口径:真实成交价(agg_trades)+ 手续费扣除,calc_version=2
|
||||
> 分析日期:2026-03-03
|
||||
> 策略名称:v52_8signals(8信号源)
|
||||
> 参与分析:露露(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 USD,1R=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为正(+11R),V5.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.90R,TP只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变成-3R,8个信号源的叠加没有提升预测能力,反而带来更多噪声
|
||||
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中均可验证这是评分失真的根源
|
||||
151
docs/arbitrage-engine/v52-signal-system.md
Normal file
151
docs/arbitrage-engine/v52-signal-system.md
Normal 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_score(0~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+笔 → ML(XGBoost等)
|
||||
|
||||
## 已知问题
|
||||
|
||||
1. **方向层与确认层同源重复** — 等数据验证
|
||||
2. **清算样本少** — 才积累2天,ratio波动大
|
||||
3. **币种间FR基准差异大** — BTC max=0.0046% vs SOL=0.022%,线性映射已自动处理
|
||||
304
docs/arbitrage-engine/v53-design.md
Normal file
304
docs/arbitrage-engine/v53-design.md
Normal file
@ -0,0 +1,304 @@
|
||||
---
|
||||
title: V5.3 统一信号系统设计案
|
||||
date: 2026-03-03
|
||||
updated: 2026-03-03
|
||||
---
|
||||
|
||||
# V5.3 统一信号系统设计案
|
||||
|
||||
> 目标:让策略从"手工打分规则"升级为"可持续训练和迭代的小模型系统"。统一架构覆盖 BTC/ETH/XRP/SOL,per-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分:不开仓
|
||||
- 75–84分:标准仓(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 首次评估报告。
|
||||
108
docs/arbitrage-engine/v53-implementation-checklist.md
Normal file
108
docs/arbitrage-engine/v53-implementation-checklist.md
Normal 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
|
||||
- [ ] BTC:OOS 净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` 变更记录
|
||||
265
docs/arbitrage-engine/v54-requirements.md
Normal file
265
docs/arbitrage-engine/v54-requirements.md
Normal 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 / 清算 / 市场数据)
|
||||
├── 计算基础 Feature(CVD / ATR / VWAP / whale flow / OBI / spot-perp div)
|
||||
└── 广播 feature_event(每15秒一次)
|
||||
|
||||
Strategy Workers(策略工厂)
|
||||
├── Worker-1:我的BTC策略01(BTCUSDT,asyncio协程)
|
||||
├── Worker-2:我的ETH策略01(ETHUSDT,asyncio协程)
|
||||
├── 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_id(UUID)关联
|
||||
|
||||
### 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 通过后,再开始写数据合约文档
|
||||
@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { authFetch, useAuth } from "@/lib/auth";
|
||||
|
||||
interface UserInfo {
|
||||
id: number;
|
||||
@ -12,36 +13,46 @@ interface UserInfo {
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter();
|
||||
const { isLoggedIn, loading, logout } = useAuth();
|
||||
const [user, setUser] = useState<UserInfo | null>(null);
|
||||
const [discordId, setDiscordId] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem("arb_token");
|
||||
if (!token) { router.push("/login"); return; }
|
||||
fetch("/api/user/me", { headers: { Authorization: `Bearer ${token}` } })
|
||||
.then(r => { if (!r.ok) { router.push("/login"); return null; } return r.json(); })
|
||||
.then(d => { if (d) { setUser(d); setDiscordId(d.discord_id || ""); } });
|
||||
}, [router]);
|
||||
if (loading) return;
|
||||
if (!isLoggedIn) { router.push("/login"); return; }
|
||||
authFetch("/api/auth/me")
|
||||
.then(r => r.ok ? r.json() : Promise.reject())
|
||||
.then(d => {
|
||||
setUser({
|
||||
id: d.id,
|
||||
email: d.email,
|
||||
discord_id: d.discord_id || null,
|
||||
tier: d.subscription?.tier || "free",
|
||||
expires_at: d.subscription?.expires_at || null,
|
||||
});
|
||||
setDiscordId(d.discord_id || "");
|
||||
})
|
||||
.catch(() => router.push("/login"));
|
||||
}, [loading, isLoggedIn, router]);
|
||||
|
||||
const bindDiscord = async () => {
|
||||
setSaving(true); setMsg("");
|
||||
const token = localStorage.getItem("arb_token");
|
||||
const r = await fetch("/api/user/bind-discord", {
|
||||
try {
|
||||
const r = await authFetch("/api/user/bind-discord", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ discord_id: discordId }),
|
||||
});
|
||||
const d = await r.json();
|
||||
setMsg(r.ok ? "✅ 绑定成功" : d.detail || "绑定失败");
|
||||
setSaving(false);
|
||||
setMsg(r.ok ? "\u2705 绑定成功" : d.detail || "绑定失败");
|
||||
if (r.ok && user) setUser({ ...user, discord_id: discordId });
|
||||
} catch { setMsg("绑定失败"); }
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const logout = () => { localStorage.removeItem("arb_token"); router.push("/"); };
|
||||
|
||||
if (!user) return <div className="text-slate-500 p-8">加载中...</div>;
|
||||
if (loading || !user) return <div className="text-slate-500 p-8">加载中...</div>;
|
||||
|
||||
const tierLabel: Record<string, string> = { free: "免费版", pro: "Pro", premium: "Premium" };
|
||||
|
||||
@ -80,7 +91,7 @@ export default function DashboardPage() {
|
||||
{saving ? "保存中..." : "绑定"}
|
||||
</button>
|
||||
</div>
|
||||
{msg && <p className={`text-sm ${msg.startsWith("✅") ? "text-emerald-400" : "text-red-400"}`}>{msg}</p>}
|
||||
{msg && <p className={`text-sm ${msg.startsWith("\u2705") ? "text-emerald-400" : "text-red-400"}`}>{msg}</p>}
|
||||
<p className="text-slate-400 text-xs">如何获取Discord ID:设置 → 外观 → 开发者模式 → 右键个人头像 → 复制用户ID</p>
|
||||
</div>
|
||||
|
||||
|
||||
@ -9,6 +9,8 @@
|
||||
--muted: #64748b;
|
||||
--primary: #2563eb;
|
||||
--primary-foreground: #ffffff;
|
||||
--font-geist-sans: "Segoe UI", "PingFang SC", "Noto Sans", sans-serif;
|
||||
--font-geist-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
|
||||
@ -1,13 +1,9 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import { AuthProvider } from "@/lib/auth";
|
||||
import AuthHeader from "@/components/AuthHeader";
|
||||
|
||||
const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] });
|
||||
const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Arbitrage Engine",
|
||||
description: "Funding rate arbitrage monitoring system",
|
||||
@ -16,7 +12,7 @@ export const metadata: Metadata = {
|
||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<html lang="zh">
|
||||
<body className={`${geistSans.variable} ${geistMono.variable} antialiased min-h-screen bg-slate-50 text-slate-900`}>
|
||||
<body className="antialiased min-h-screen bg-slate-50 text-slate-900">
|
||||
<AuthProvider>
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar />
|
||||
|
||||
@ -1,130 +1,760 @@
|
||||
"use client";
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { authFetch, useAuth } from "@/lib/auth";
|
||||
import { XAxis, YAxis, Tooltip, ResponsiveContainer, ReferenceLine, Area, AreaChart } from "recharts";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import {
|
||||
LineChart, Line, XAxis, YAxis, Tooltip, Legend,
|
||||
ResponsiveContainer, ReferenceLine, CartesianGrid
|
||||
} from "recharts";
|
||||
|
||||
interface ChartPoint {
|
||||
time: string;
|
||||
btcRate: number;
|
||||
ethRate: number;
|
||||
btcPrice: number;
|
||||
ethPrice: number;
|
||||
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 }); }
|
||||
function fmtMs(ms: number) { return ms > 999 ? `${(ms/1000).toFixed(1)}s` : `${ms}ms`; }
|
||||
|
||||
export default function LivePage() {
|
||||
const [data, setData] = useState<ChartPoint[]>([]);
|
||||
const [count, setCount] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [hours, setHours] = useState(2);
|
||||
|
||||
const fetchSnapshots = useCallback(async () => {
|
||||
try {
|
||||
const json = await api.snapshots(hours, 3600);
|
||||
const rows = json.data || [];
|
||||
setCount(json.count || 0);
|
||||
// 降采样:每30条取1条,避免图表过密
|
||||
const step = Math.max(1, Math.floor(rows.length / 300));
|
||||
const sampled = rows.filter((_, i) => i % step === 0);
|
||||
setData(sampled.map(row => ({
|
||||
time: new Date(row.ts * 1000).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" }),
|
||||
btcRate: parseFloat((row.btc_rate * 100).toFixed(5)),
|
||||
ethRate: parseFloat((row.eth_rate * 100).toFixed(5)),
|
||||
btcPrice: row.btc_price,
|
||||
ethPrice: row.eth_price,
|
||||
})));
|
||||
} catch { /* ignore */ } finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [hours]);
|
||||
const LIVE_STRATEGY = "v52_8signals";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// L0: 顶部固定风险条(sticky,永远可见)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
function L0_RiskBar() {
|
||||
const [risk, setRisk] = useState<any>(null);
|
||||
const [recon, setRecon] = useState<any>(null);
|
||||
const [account, setAccount] = useState<any>(null);
|
||||
useEffect(() => {
|
||||
fetchSnapshots();
|
||||
const iv = setInterval(fetchSnapshots, 10_000);
|
||||
return () => clearInterval(iv);
|
||||
}, [fetchSnapshots]);
|
||||
const f = async () => {
|
||||
try { const r = await authFetch("/api/live/risk-status"); if (r.ok) setRisk(await r.json()); } catch {}
|
||||
try { const r = await authFetch("/api/live/reconciliation"); if (r.ok) setRecon(await r.json()); } catch {}
|
||||
try { const r = await authFetch("/api/live/account"); if (r.ok) setAccount(await r.json()); } catch {}
|
||||
};
|
||||
f(); const iv = setInterval(f, 5000); return () => clearInterval(iv);
|
||||
}, []);
|
||||
|
||||
const riskStatus = risk?.status || "unknown";
|
||||
const riskColor = riskStatus === "normal" ? "bg-emerald-500" : riskStatus === "circuit_break" ? "bg-red-500" : "bg-amber-500";
|
||||
const reconOk = recon?.status === "ok";
|
||||
const totalR = (risk?.today_realized_r || 0) + Math.min(risk?.today_unrealized_r || 0, 0);
|
||||
const rBudgetPct = Math.abs(totalR / 5 * 100); // -5R日限
|
||||
const rBudgetColor = rBudgetPct >= 100 ? "text-red-500" : rBudgetPct >= 80 ? "text-amber-500" : "text-emerald-500";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">实时费率变动</h1>
|
||||
<p className="text-slate-500 text-sm mt-1">
|
||||
8小时结算周期内的实时费率曲线 · 已记录 <span className="text-blue-600 font-medium">{count.toLocaleString()}</span> 条快照
|
||||
</p>
|
||||
<div className="sticky top-0 z-50 bg-slate-900 text-white px-4 py-2 rounded-lg shadow-lg flex items-center justify-between flex-wrap gap-2 text-[11px]">
|
||||
{/* 交易状态 */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={`w-2 h-2 rounded-full ${riskColor} ${riskStatus === "circuit_break" ? "animate-pulse" : ""}`} />
|
||||
<span className="font-medium">{riskStatus === "normal" ? "运行中" : riskStatus === "circuit_break" ? "🔴 熔断" : riskStatus === "warning" ? "⚠️ 警告" : "未知"}</span>
|
||||
{risk?.block_new_entries && <span className="px-1 py-0.5 rounded bg-red-800 text-red-200 text-[9px]">禁新仓</span>}
|
||||
{risk?.reduce_only && <span className="px-1 py-0.5 rounded bg-red-800 text-red-200 text-[9px]">只减仓</span>}
|
||||
</div>
|
||||
|
||||
{/* R预算 */}
|
||||
<div className="flex items-center gap-3 font-mono">
|
||||
<div>
|
||||
<span className="text-slate-400">已实现</span>
|
||||
<span className={`ml-1 font-bold ${(risk?.today_realized_r||0) >= 0 ? "text-emerald-400" : "text-red-400"}`}>{(risk?.today_realized_r||0) >= 0 ? "+" : ""}{risk?.today_realized_r||0}R</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-400">未实现</span>
|
||||
<span className={`ml-1 font-bold ${(risk?.today_unrealized_r||0) >= 0 ? "text-emerald-400" : "text-red-400"}`}>{(risk?.today_unrealized_r||0) >= 0 ? "+" : ""}{risk?.today_unrealized_r||0}R</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-400">日限</span>
|
||||
<span className={`ml-1 font-bold ${rBudgetColor}`}>{totalR >= 0 ? "+" : ""}{totalR.toFixed(1)}/-5R</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 对账+清算 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${reconOk ? "bg-emerald-400" : "bg-red-400 animate-pulse"}`} />
|
||||
<span className="text-slate-300">对账{reconOk ? "✓" : `✗(${recon?.diffs?.length||0})`}</span>
|
||||
</div>
|
||||
<div className="text-slate-300">连亏 <span className="font-bold text-white">{risk?.consecutive_losses||0}</span></div>
|
||||
{risk?.circuit_break_reason && <span className="text-red-300 text-[9px] max-w-[150px] truncate">{risk.circuit_break_reason}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// L1: 一键止血区
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
function L1_EmergencyPanel() {
|
||||
const [confirming, setConfirming] = useState<string|null>(null);
|
||||
const [msg, setMsg] = useState("");
|
||||
const doAction = async (action: string) => {
|
||||
try { const r = await authFetch(`/api/live/${action}`, { method: "POST" }); const j = await r.json(); setMsg(j.message || j.error || "已执行"); setConfirming(null); setTimeout(() => setMsg(""), 5000); } catch { setMsg("操作失败"); }
|
||||
};
|
||||
const ConfirmBtn = ({ action, label, color }: { action: string; label: string; color: string }) => (
|
||||
confirming === action ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[10px] text-red-600">确认?</span>
|
||||
<button onClick={() => doAction(action)} className={`px-2 py-1 rounded text-[10px] font-bold ${color} text-white`}>确认</button>
|
||||
<button onClick={() => setConfirming(null)} className="px-2 py-1 rounded text-[10px] bg-slate-200 text-slate-600">取消</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setConfirming(action)} className={`px-3 py-1.5 rounded-lg text-[11px] font-bold ${color} text-white`}>{label}</button>
|
||||
)
|
||||
);
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white px-4 py-2.5">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<h3 className="font-semibold text-slate-800 text-xs">⚡ 止血操作</h3>
|
||||
<div className="flex gap-2 items-center">
|
||||
<ConfirmBtn action="emergency-close" label="🔴 全平" color="bg-red-500 hover:bg-red-600" />
|
||||
<ConfirmBtn action="block-new" label="🟡 禁新仓" color="bg-amber-500 hover:bg-amber-600" />
|
||||
<button onClick={() => doAction("resume")} className="px-3 py-1.5 rounded-lg text-[11px] font-bold bg-emerald-500 text-white hover:bg-emerald-600">✅ 恢复</button>
|
||||
</div>
|
||||
</div>
|
||||
{msg && <p className="text-[10px] text-blue-600 mt-1">{msg}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// L1.5: 实盘配置面板
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
function L15_LiveConfig() {
|
||||
const [config, setConfig] = useState<Record<string, any>>({});
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState<Record<string, string>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const f = async () => {
|
||||
try {
|
||||
const r = await authFetch("/api/live/config");
|
||||
if (r.ok) { const d = await r.json(); setConfig(d); }
|
||||
} catch {}
|
||||
};
|
||||
f();
|
||||
}, []);
|
||||
|
||||
const configOrder = ["risk_per_trade_usd", "initial_capital", "risk_pct", "max_positions", "leverage", "enabled_strategies", "trade_env"];
|
||||
const configIcons: Record<string, string> = {
|
||||
risk_per_trade_usd: "🎯", initial_capital: "💰", risk_pct: "📊",
|
||||
max_positions: "📦", leverage: "⚡", enabled_strategies: "🧠", trade_env: "🌐"
|
||||
};
|
||||
|
||||
const startEdit = () => {
|
||||
const d: Record<string, string> = {};
|
||||
for (const k of configOrder) { d[k] = config[k]?.value || ""; }
|
||||
setDraft(d); setEditing(true);
|
||||
};
|
||||
|
||||
const saveConfig = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const r = await authFetch("/api/live/config", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(draft) });
|
||||
if (r.ok) {
|
||||
const r2 = await authFetch("/api/live/config");
|
||||
if (r2.ok) setConfig(await r2.json());
|
||||
setEditing(false);
|
||||
}
|
||||
} catch {} finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const riskUsd = parseFloat(config.risk_per_trade_usd?.value || "2");
|
||||
|
||||
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">
|
||||
<h3 className="font-semibold text-slate-800 text-xs flex items-center gap-2">
|
||||
⚙️ 实盘配置
|
||||
<span className="text-blue-600 font-bold text-sm">1R = ${riskUsd.toFixed(2)}</span>
|
||||
</h3>
|
||||
{!editing ? (
|
||||
<button onClick={startEdit} className="px-2 py-0.5 rounded text-[10px] bg-slate-100 text-slate-600 hover:bg-slate-200">编辑</button>
|
||||
) : (
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => setEditing(false)} className="px-2 py-0.5 rounded text-[10px] bg-slate-100 text-slate-500">取消</button>
|
||||
<button onClick={saveConfig} disabled={saving} className="px-2 py-0.5 rounded text-[10px] bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50">{saving ? "保存中..." : "保存"}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3 grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-2">
|
||||
{configOrder.map(k => {
|
||||
const c = config[k];
|
||||
if (!c) return null;
|
||||
return (
|
||||
<div key={k} className="text-center">
|
||||
<div className="text-[10px] text-slate-400 mb-0.5">{configIcons[k]} {c.label}</div>
|
||||
{editing ? (
|
||||
<input value={draft[k] || ""} onChange={e => setDraft({...draft, [k]: e.target.value})}
|
||||
className="w-full text-center text-xs font-mono border border-slate-300 rounded px-1 py-0.5 focus:border-blue-500 focus:outline-none" />
|
||||
) : (
|
||||
<div className="text-sm font-bold text-slate-800 font-mono">
|
||||
{k === "risk_per_trade_usd" ? `$${c.value}` : k === "risk_pct" ? `${c.value}%` : k === "leverage" ? `${c.value}x` : c.value}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// L2: 账户概览(8卡片)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
function L2_AccountOverview() {
|
||||
const [data, setData] = useState<any>(null);
|
||||
const [summary, setSummary] = useState<any>(null);
|
||||
useEffect(() => {
|
||||
const f = async () => {
|
||||
try { const r = await authFetch("/api/live/account"); if (r.ok) setData(await r.json()); } catch {}
|
||||
try { const r = await authFetch(`/api/live/summary?strategy=${LIVE_STRATEGY}`); if (r.ok) setSummary(await r.json()); } catch {}
|
||||
};
|
||||
f(); const iv = setInterval(f, 10000); return () => clearInterval(iv);
|
||||
}, []);
|
||||
const d = data || {};
|
||||
const s = summary || {};
|
||||
const cards = [
|
||||
{ label: "账户权益", value: `$${(d.equity||0).toFixed(2)}`, color: "" },
|
||||
{ label: "可用保证金", value: `$${(d.available_margin||0).toFixed(2)}`, color: "" },
|
||||
{ label: "已用保证金", value: `$${(d.used_margin||0).toFixed(2)}`, color: "" },
|
||||
{ label: "有效杠杆", value: `${d.effective_leverage||0}x`, color: (d.effective_leverage||0) > 10 ? "text-red-500" : "" },
|
||||
{ label: "今日净PnL", value: `${(d.today_realized_r||0)>=0?"+":""}${d.today_realized_r||0}R ($${d.today_realized_usdt||0})`, color: (d.today_realized_r||0)>=0 ? "text-emerald-600" : "text-red-500" },
|
||||
{ label: "总净PnL", value: `${(s.total_pnl_r||0)>=0?"+":""}${s.total_pnl_r||0}R ($${s.total_pnl_usdt||0})`, color: (s.total_pnl_r||0)>=0 ? "text-emerald-600" : "text-red-500" },
|
||||
{ label: "成本占比", value: `$${(s.total_fee_usdt||0)+(s.total_funding_usdt||0)}`, color: "text-amber-600" },
|
||||
{ label: "胜率/PF", value: `${s.win_rate||0}% / ${s.profit_factor||0}`, color: "" },
|
||||
];
|
||||
return (
|
||||
<div className="grid grid-cols-4 lg:grid-cols-8 gap-1.5">
|
||||
{cards.map((c, i) => (
|
||||
<div key={i} className="bg-white rounded-lg border border-slate-200 px-2 py-1.5">
|
||||
<p className="text-[9px] text-slate-400 truncate">{c.label}</p>
|
||||
<p className={`font-mono font-bold text-sm ${c.color || "text-slate-800"} truncate`}>{c.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// L3: 当前持仓(WS实时)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
function L3_Positions() {
|
||||
const [positions, setPositions] = useState<any[]>([]);
|
||||
const [wsPrices, setWsPrices] = useState<Record<string,number>>({});
|
||||
const [recon, setRecon] = useState<any>(null);
|
||||
const [riskUsd, setRiskUsd] = useState(2);
|
||||
|
||||
useEffect(() => {
|
||||
const f = async () => {
|
||||
try { const r = await authFetch(`/api/live/positions?strategy=${LIVE_STRATEGY}`); if (r.ok) { const j = await r.json(); setPositions(j.data||[]); } } catch {}
|
||||
try { const r = await authFetch("/api/live/reconciliation"); if (r.ok) setRecon(await r.json()); } catch {}
|
||||
try { const r = await authFetch("/api/live/config"); if (r.ok) { const cfg = await r.json(); setRiskUsd(parseFloat(cfg?.risk_per_trade_usd?.value ?? "2")); } } catch {}
|
||||
};
|
||||
f(); const iv = setInterval(f, 5000); return () => clearInterval(iv);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const streams = ["btcusdt","ethusdt","xrpusdt","solusdt"].map(s=>`${s}@aggTrade`).join("/");
|
||||
const ws = new WebSocket(`wss://fstream.binance.com/stream?streams=${streams}`);
|
||||
ws.onmessage = (e) => { try { const m = JSON.parse(e.data); if (m.data) setWsPrices(p => ({...p, [m.data.s]: parseFloat(m.data.p)})); } catch {} };
|
||||
return () => ws.close();
|
||||
}, []);
|
||||
|
||||
// 从对账数据获取清算距离
|
||||
const liqDist: Record<string, number> = {};
|
||||
if (recon?.exchange_positions) {
|
||||
for (const ep of recon.exchange_positions) {
|
||||
if (ep.liquidation_price > 0 && ep.mark_price > 0) {
|
||||
const dist = ep.direction === "LONG"
|
||||
? (ep.mark_price - ep.liquidation_price) / ep.mark_price * 100
|
||||
: (ep.liquidation_price - ep.mark_price) / ep.mark_price * 100;
|
||||
liqDist[ep.symbol] = dist;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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">L3 当前持仓 <span className="text-[10px] text-emerald-500">● 实时</span></h3>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-100">
|
||||
{positions.map((p: any) => {
|
||||
const sym = p.symbol?.replace("USDT","") || "";
|
||||
const holdMin = p.hold_time_min || Math.round((Date.now()-p.entry_ts)/60000);
|
||||
const cp = wsPrices[p.symbol] || p.current_price || 0;
|
||||
const entry = p.entry_price || 0;
|
||||
const rd = p.risk_distance || 1;
|
||||
const fullR = rd > 0 ? (p.direction==="LONG"?(cp-entry)/rd:(entry-cp)/rd) : 0;
|
||||
const tp1R = rd > 0 ? (p.direction==="LONG"?((p.tp1_price||0)-entry)/rd:(entry-(p.tp1_price||0))/rd) : 0;
|
||||
const unrealR = p.tp1_hit ? 0.5*tp1R+0.5*fullR : fullR;
|
||||
const unrealUsdt = unrealR * riskUsd;
|
||||
const holdColor = holdMin>=60?"text-red-500 font-bold":holdMin>=45?"text-amber-500":"text-slate-400";
|
||||
const dist = liqDist[p.symbol];
|
||||
const distColor = dist !== undefined ? (dist < 8 ? "bg-slate-900 text-white" : dist < 12 ? "bg-red-50 text-red-700" : dist < 20 ? "bg-amber-50 text-amber-700" : "bg-emerald-50 text-emerald-700") : "bg-slate-50 text-slate-400";
|
||||
|
||||
return (
|
||||
<div key={p.id} className="px-3 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-xs font-bold ${p.direction==="LONG"?"text-emerald-600":"text-red-500"}`}>{p.direction==="LONG"?"🟢":"🔴"} {sym} {p.direction}</span>
|
||||
<span className="text-[10px] text-slate-400">评分{p.score} · {p.tier==="heavy"?"加仓":"标准"}</span>
|
||||
{dist !== undefined && <span className={`text-[9px] px-1.5 py-0.5 rounded font-mono font-bold ${distColor}`}>清算{dist.toFixed(1)}%</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={`font-mono text-[10px] ${unrealUsdt>=0?"text-emerald-500":"text-red-400"}`}>({unrealUsdt>=0?"+":""}${unrealUsdt.toFixed(2)})</span>
|
||||
<span className={`text-[10px] ${holdColor}`}>{holdMin}m</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* 价格行 */}
|
||||
<div className="flex gap-3 mt-1 text-[10px] font-mono text-slate-600 flex-wrap">
|
||||
<span>入场: ${fmtPrice(entry)}</span>
|
||||
<span>成交: ${fmtPrice(p.fill_price||entry)}</span>
|
||||
<span className="text-blue-600">现价: ${cp ? fmtPrice(cp) : "-"}</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="flex gap-2 mt-1 flex-wrap">
|
||||
<span className={`text-[9px] px-1.5 py-0.5 rounded ${Math.abs(p.slippage_bps||0)>8?"bg-red-50 text-red-700":Math.abs(p.slippage_bps||0)>2.5?"bg-amber-50 text-amber-700":"bg-emerald-50 text-emerald-700"}`}>滑点 {(p.slippage_bps||0).toFixed(1)}bps</span>
|
||||
<span className={`text-[9px] px-1.5 py-0.5 rounded ${(p.protection_gap_ms||0)>5000?"bg-red-50 text-red-700":(p.protection_gap_ms||0)>2000?"bg-amber-50 text-amber-700":"bg-emerald-50 text-emerald-700"}`}>裸奔 {fmtMs(p.protection_gap_ms||0)}</span>
|
||||
<span className="text-[9px] px-1.5 py-0.5 rounded bg-blue-50 text-blue-700">S→O {fmtMs(p.signal_to_order_ms||0)}</span>
|
||||
<span className="text-[9px] px-1.5 py-0.5 rounded bg-blue-50 text-blue-700">O→F {fmtMs(p.order_to_fill_ms||0)}</span>
|
||||
<span className="text-[9px] px-1.5 py-0.5 rounded bg-slate-100 text-slate-600">#{p.binance_order_id||"-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// L4: 执行质量面板
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
function L4_ExecutionQuality() {
|
||||
const [data, setData] = useState<any>(null);
|
||||
useEffect(() => {
|
||||
const f = async () => { try { const r = await authFetch("/api/live/execution-quality"); if (r.ok) setData(await r.json()); } catch {} };
|
||||
f(); const iv = setInterval(f, 30000); return () => clearInterval(iv);
|
||||
}, []);
|
||||
if (!data || data.error) return null;
|
||||
const o = data.overall || {};
|
||||
const MetricRow = ({ label, stat, unit, yellowThresh, redThresh }: any) => {
|
||||
const color = stat?.p95 > redThresh ? "text-red-500" : stat?.p95 > yellowThresh ? "text-amber-500" : "text-emerald-600";
|
||||
return (
|
||||
<div className="flex items-center justify-between text-[11px]">
|
||||
<span className="text-slate-500">{label}</span>
|
||||
<div className="flex gap-3 font-mono">
|
||||
<span className="text-slate-400">avg <span className="text-slate-800 font-bold">{stat?.avg}{unit}</span></span>
|
||||
<span className="text-slate-400">P50 <span className="text-slate-800">{stat?.p50}{unit}</span></span>
|
||||
<span className="text-slate-400">P95 <span className={`font-bold ${color}`}>{stat?.p95}{unit}</span></span>
|
||||
</div>
|
||||
</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">L4 执行质量 <span className="text-[10px] text-slate-400">{data.total_trades}笔</span></h3></div>
|
||||
<div className="p-3 space-y-2">
|
||||
<MetricRow label="滑点" stat={o.slippage_bps} unit="bps" yellowThresh={2.5} redThresh={8} />
|
||||
<MetricRow label="信号→下单" stat={o.signal_to_order_ms} unit="ms" yellowThresh={250} redThresh={1200} />
|
||||
<MetricRow label="下单→成交" stat={o.order_to_fill_ms} unit="ms" yellowThresh={600} redThresh={3500} />
|
||||
<MetricRow label="裸奔时间" stat={o.protection_gap_ms} unit="ms" yellowThresh={2000} redThresh={5000} />
|
||||
</div>
|
||||
{data.by_symbol && Object.keys(data.by_symbol).length > 0 && (
|
||||
<div className="px-3 pb-3">
|
||||
<p className="text-[10px] text-slate-400 mb-1">按币种</p>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-1.5">
|
||||
{Object.entries(data.by_symbol).map(([sym, v]: [string, any]) => (
|
||||
<div key={sym} className="rounded-lg bg-slate-50 px-2 py-1.5 text-[10px]">
|
||||
<span className="font-mono font-bold">{sym.replace("USDT","")}</span>
|
||||
<span className="text-slate-400 ml-1">{v.count}笔</span>
|
||||
<div className="font-mono mt-0.5">
|
||||
<span>滑点P95: {v.slippage_bps?.p95}bps</span>
|
||||
<span className="ml-2">裸奔P95: {fmtMs(v.protection_gap_ms?.p95||0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 text-sm">
|
||||
{[1, 2, 6, 12, 24].map(h => (
|
||||
<button
|
||||
key={h}
|
||||
onClick={() => setHours(h)}
|
||||
className={`px-3 py-1.5 rounded-lg border transition-colors ${hours === h ? "bg-blue-600 text-white border-blue-600" : "border-slate-200 text-slate-600 hover:border-blue-400"}`}
|
||||
>
|
||||
{h}h
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-slate-400 py-12 text-center">加载中...</div>
|
||||
) : data.length === 0 ? (
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 p-12 text-center text-slate-400">
|
||||
暂无数据(后端刚启动,2秒后开始积累)
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 费率图 */}
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm p-6">
|
||||
<h2 className="text-slate-700 font-semibold mb-1">资金费率实时变动</h2>
|
||||
<p className="text-slate-400 text-xs mb-4">正值=多头付空头,负值=空头付多头。费率上升=多头情绪加热</p>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<LineChart data={data} margin={{ top: 4, right: 8, bottom: 4, left: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
|
||||
<XAxis dataKey="time" tick={{ fill: "#94a3b8", fontSize: 10 }} tickLine={false} interval="preserveStartEnd" />
|
||||
<YAxis tick={{ fill: "#94a3b8", fontSize: 10 }} tickLine={false} axisLine={false}
|
||||
tickFormatter={v => `${v.toFixed(3)}%`} width={60} />
|
||||
<Tooltip formatter={(v) => [`${Number(v).toFixed(5)}%`]}
|
||||
contentStyle={{ background: "#fff", border: "1px solid #e2e8f0", borderRadius: 8, fontSize: 12 }} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<ReferenceLine y={0} stroke="#94a3b8" strokeDasharray="4 2" />
|
||||
<Line type="monotone" dataKey="btcRate" name="BTC费率" stroke="#2563eb" strokeWidth={1.5} dot={false} connectNulls />
|
||||
<Line type="monotone" dataKey="ethRate" name="ETH费率" stroke="#7c3aed" strokeWidth={1.5} dot={false} connectNulls />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* 价格图 */}
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm p-6">
|
||||
<h2 className="text-slate-700 font-semibold mb-1">标记价格走势</h2>
|
||||
<p className="text-slate-400 text-xs mb-4">与费率对比观察:价格快速拉升时,资金费率通常同步上涨(多头加杠杆)</p>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<LineChart data={data} margin={{ top: 4, right: 8, bottom: 4, left: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
|
||||
<XAxis dataKey="time" tick={{ fill: "#94a3b8", fontSize: 10 }} tickLine={false} interval="preserveStartEnd" />
|
||||
<YAxis yAxisId="btc" orientation="left" tick={{ fill: "#2563eb", fontSize: 10 }} tickLine={false} axisLine={false}
|
||||
tickFormatter={v => `$${(v/1000).toFixed(0)}k`} width={55} />
|
||||
<YAxis yAxisId="eth" orientation="right" tick={{ fill: "#7c3aed", fontSize: 10 }} tickLine={false} axisLine={false}
|
||||
tickFormatter={v => `$${v.toFixed(0)}`} width={55} />
|
||||
<Tooltip formatter={(v, name) => [name?.toString().includes("BTC") ? `$${Number(v).toLocaleString()}` : `$${Number(v).toFixed(2)}`, name]}
|
||||
contentStyle={{ background: "#fff", border: "1px solid #e2e8f0", borderRadius: 8, fontSize: 12 }} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Line yAxisId="btc" type="monotone" dataKey="btcPrice" name="BTC价格" stroke="#2563eb" strokeWidth={1.5} dot={false} connectNulls />
|
||||
<Line yAxisId="eth" type="monotone" dataKey="ethPrice" name="ETH价格" stroke="#7c3aed" strokeWidth={1.5} dot={false} connectNulls />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* 说明 */}
|
||||
<div className="rounded-lg border border-blue-100 bg-blue-50 px-5 py-3 text-sm text-slate-600">
|
||||
<span className="text-blue-600 font-medium">数据说明:</span>
|
||||
每2秒从Binance拉取实时溢价指数,本地永久存储。这是8小时结算周期内费率变动的原始数据,不在任何公开数据源中提供。
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// L5: 对账面板
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
function L5_Reconciliation() {
|
||||
const [data, setData] = useState<any>(null);
|
||||
useEffect(() => {
|
||||
const f = async () => { try { const r = await authFetch("/api/live/reconciliation"); if (r.ok) setData(await r.json()); } catch {} };
|
||||
f(); const iv = setInterval(f, 30000); return () => clearInterval(iv);
|
||||
}, []);
|
||||
if (!data) return null;
|
||||
const ok = data.status === "ok";
|
||||
return (
|
||||
<div className={`rounded-xl border ${ok ? "border-slate-200" : "border-red-300"} bg-white overflow-hidden`}>
|
||||
<div className={`px-3 py-2 border-b ${ok ? "border-slate-100" : "border-red-200 bg-red-50"}`}>
|
||||
<h3 className="font-semibold text-slate-800 text-xs">L5 对账 {ok ? <span className="text-emerald-500">✓ 一致</span> : <span className="text-red-500">✗ 差异</span>}</h3>
|
||||
</div>
|
||||
<div className="p-3 grid grid-cols-2 gap-4 text-[11px]">
|
||||
<div>
|
||||
<p className="text-slate-400 mb-1">本地仓位 ({data.local_positions?.length || 0})</p>
|
||||
{(data.local_positions || []).map((p: any) => (
|
||||
<div key={p.id} className="font-mono">{p.symbol?.replace("USDT","")} {p.direction} @ {fmtPrice(p.entry_price)}</div>
|
||||
))}
|
||||
{!data.local_positions?.length && <div className="text-slate-300">无</div>}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-slate-400 mb-1">币安仓位 ({data.exchange_positions?.length || 0})</p>
|
||||
{(data.exchange_positions || []).map((p: any, i: number) => (
|
||||
<div key={i} className="font-mono">{p.symbol?.replace("USDT","")} {p.direction} qty={p.amount} liq={fmtPrice(p.liquidation_price)}</div>
|
||||
))}
|
||||
{!data.exchange_positions?.length && <div className="text-slate-300">无</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3 pb-2 text-[10px] text-slate-400">
|
||||
挂单: 本地预期 {data.local_orders||0} / 币安 {data.exchange_orders||0}
|
||||
</div>
|
||||
{data.diffs?.length > 0 && (
|
||||
<div className="px-3 pb-3 space-y-1">
|
||||
{data.diffs.map((d: any, i: number) => (
|
||||
<div key={i} className={`text-[10px] px-2 py-1 rounded ${d.severity==="critical"?"bg-red-50 text-red-700":"bg-amber-50 text-amber-700"}`}>
|
||||
⚠ [{d.symbol}] {d.detail}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// L6: 风控状态
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
function L6_RiskStatus() {
|
||||
const [risk, setRisk] = useState<any>(null);
|
||||
useEffect(() => {
|
||||
const f = async () => { try { const r = await authFetch("/api/live/risk-status"); if (r.ok) setRisk(await r.json()); } catch {} };
|
||||
f(); const iv = setInterval(f, 5000); return () => clearInterval(iv);
|
||||
}, []);
|
||||
if (!risk) return null;
|
||||
const thresholds = [
|
||||
{ rule: "单日亏损 > -5R", status: (risk.today_total_r||0) > -5 ? "✅" : "🔴" },
|
||||
{ rule: "连续亏损 < 5次", status: (risk.consecutive_losses||0) < 5 ? "✅" : "🔴" },
|
||||
{ rule: "API连接正常", status: risk.status !== "circuit_break" || !risk.circuit_break_reason?.includes("API") ? "✅" : "🔴" },
|
||||
];
|
||||
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">L6 风控状态</h3></div>
|
||||
<div className="p-3">
|
||||
<div className="grid grid-cols-3 gap-2 text-[11px] mb-2">
|
||||
{thresholds.map((t, i) => (
|
||||
<div key={i} className="flex items-center gap-1"><span>{t.status}</span><span className="text-slate-600">{t.rule}</span></div>
|
||||
))}
|
||||
</div>
|
||||
{risk.circuit_break_reason && (
|
||||
<div className="text-[10px] bg-red-50 text-red-700 px-2 py-1.5 rounded">
|
||||
<span className="font-bold">熔断原因:</span>{risk.circuit_break_reason}
|
||||
{risk.auto_resume_time && <span className="ml-2 text-slate-500">预计恢复: {new Date(risk.auto_resume_time * 1000).toLocaleTimeString("zh-CN")}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// L7: 实时事件流
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
function L7_EventStream() {
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [filter, setFilter] = useState("all");
|
||||
useEffect(() => {
|
||||
const f = async () => {
|
||||
try {
|
||||
const r = await authFetch(`/api/live/events?limit=30&level=${filter}`);
|
||||
if (r.ok) { const d = await r.json(); setEvents(d.data || []); }
|
||||
} catch {}
|
||||
};
|
||||
f(); const iv = setInterval(f, 5000); return () => clearInterval(iv);
|
||||
}, [filter]);
|
||||
|
||||
const levelIcon: Record<string, string> = { info: "ℹ️", warn: "⚠️", error: "❌", critical: "🔴" };
|
||||
const levelColor: Record<string, string> = {
|
||||
info: "text-blue-600 bg-blue-50", warn: "text-amber-600 bg-amber-50",
|
||||
error: "text-red-600 bg-red-50", critical: "text-red-700 bg-red-100 font-bold"
|
||||
};
|
||||
const catColor: Record<string, string> = {
|
||||
trade: "bg-emerald-100 text-emerald-700", risk: "bg-red-100 text-red-700",
|
||||
system: "bg-slate-100 text-slate-600", reconciliation: "bg-violet-100 text-violet-700"
|
||||
};
|
||||
|
||||
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">
|
||||
<h3 className="font-semibold text-slate-800 text-xs">L7 事件流</h3>
|
||||
<div className="flex gap-1">
|
||||
{["all","critical","warn","info"].map(l => (
|
||||
<button key={l} onClick={() => setFilter(l)}
|
||||
className={`px-1.5 py-0.5 rounded text-[9px] ${filter===l?"bg-blue-600 text-white":"bg-slate-100 text-slate-500 hover:bg-slate-200"}`}>
|
||||
{l==="all"?"全部":l==="critical"?"严重":l==="warn"?"警告":"信息"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-56 overflow-y-auto">
|
||||
{events.length === 0 ? <div className="text-center text-slate-400 text-sm py-6">暂无事件</div> : (
|
||||
<div className="divide-y divide-slate-50">
|
||||
{events.map((e: any) => {
|
||||
const t = new Date(typeof e.ts === "number" ? (e.ts > 1e12 ? e.ts : e.ts*1000) : e.ts);
|
||||
const timeStr = t.toLocaleTimeString("zh-CN", {hour:"2-digit",minute:"2-digit",second:"2-digit",hour12:false});
|
||||
const dateStr = t.toLocaleDateString("zh-CN", {month:"2-digit",day:"2-digit"});
|
||||
return (
|
||||
<div key={e.id} className={`px-3 py-1.5 flex items-start gap-2 text-[10px] ${levelColor[e.level] || ""}`}>
|
||||
<span className="shrink-0">{levelIcon[e.level] || "📝"}</span>
|
||||
<span className="shrink-0 text-slate-400 font-mono">{dateStr} {timeStr}</span>
|
||||
{e.category && <span className={`shrink-0 px-1 py-0.5 rounded text-[8px] ${catColor[e.category] || "bg-slate-100 text-slate-500"}`}>{e.category}</span>}
|
||||
{e.symbol && <span className="shrink-0 font-mono text-slate-500">{e.symbol.replace("USDT","")}</span>}
|
||||
<span className="flex-1">{e.message}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// L8: 实盘 vs 模拟盘对照
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
function L8_PaperComparison() {
|
||||
const [data, setData] = useState<any>(null);
|
||||
useEffect(() => {
|
||||
const f = async () => { try { const r = await authFetch("/api/live/paper-comparison?limit=20"); if (r.ok) setData(await r.json()); } catch {} };
|
||||
f(); const iv = setInterval(f, 30000); return () => clearInterval(iv);
|
||||
}, []);
|
||||
if (!data || !data.data?.length) return null;
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden">
|
||||
<div className="px-3 py-2 border-b border-slate-100">
|
||||
<h3 className="font-semibold text-slate-800 text-xs">L8 实盘 vs 模拟盘 <span className="text-[10px] text-slate-400">平均R差: {data.avg_pnl_diff_r}R</span></h3>
|
||||
</div>
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
<table className="w-full text-[10px]">
|
||||
<thead className="bg-slate-50 sticky top-0"><tr className="text-slate-500">
|
||||
<th className="px-2 py-1 text-left">币种</th><th className="px-2 py-1">方向</th>
|
||||
<th className="px-2 py-1 text-right">实盘入场</th><th className="px-2 py-1 text-right">模拟入场</th><th className="px-2 py-1 text-right">价差bps</th>
|
||||
<th className="px-2 py-1 text-right">实盘PnL</th><th className="px-2 py-1 text-right">模拟PnL</th><th className="px-2 py-1 text-right">R差</th>
|
||||
</tr></thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{data.data.map((r: any, i: number) => (
|
||||
<tr key={i} className="hover:bg-slate-50">
|
||||
<td className="px-2 py-1 font-mono">{r.symbol?.replace("USDT","")}</td>
|
||||
<td className={`px-2 py-1 text-center font-bold ${r.direction==="LONG"?"text-emerald-600":"text-red-500"}`}>{r.direction}</td>
|
||||
<td className="px-2 py-1 text-right font-mono">{r.live_entry ? fmtPrice(r.live_entry) : "-"}</td>
|
||||
<td className="px-2 py-1 text-right font-mono">{r.paper_entry ? fmtPrice(r.paper_entry) : "-"}</td>
|
||||
<td className="px-2 py-1 text-right font-mono">{r.entry_diff_bps || "-"}</td>
|
||||
<td className={`px-2 py-1 text-right font-mono ${(r.live_pnl||0)>=0?"text-emerald-600":"text-red-500"}`}>{r.live_pnl?.toFixed(2) || "-"}</td>
|
||||
<td className={`px-2 py-1 text-right font-mono ${(r.paper_pnl||0)>=0?"text-emerald-600":"text-red-500"}`}>{r.paper_pnl?.toFixed(2) || "-"}</td>
|
||||
<td className={`px-2 py-1 text-right font-mono font-bold ${(r.pnl_diff_r||0)>=0?"text-emerald-600":"text-red-500"}`}>{r.pnl_diff_r?.toFixed(2) || "-"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// L9: 权益曲线+回撤
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
function L9_EquityCurve() {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
useEffect(() => {
|
||||
const f = async () => { try { const r = await authFetch(`/api/live/equity-curve?strategy=${LIVE_STRATEGY}`); if (r.ok) { const j = await r.json(); setData(j.data||[]); } } catch {} };
|
||||
f(); const iv = setInterval(f, 30000); return () => clearInterval(iv);
|
||||
}, []);
|
||||
if (data.length < 2) return null;
|
||||
// 计算回撤
|
||||
let peak = 0;
|
||||
const withDD = data.map(d => {
|
||||
if (d.pnl > peak) peak = d.pnl;
|
||||
return { ...d, dd: -(peak - d.pnl) };
|
||||
});
|
||||
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">L9 权益曲线 + 回撤</h3></div>
|
||||
<div className="p-2" style={{ height: 200 }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={withDD}>
|
||||
<XAxis dataKey="ts" tickFormatter={v => bjt(v)} tick={{ fontSize: 10 }} />
|
||||
<YAxis tick={{ fontSize: 10 }} tickFormatter={v => `${v}R`} />
|
||||
<Tooltip labelFormatter={v => bjt(Number(v))} />
|
||||
<ReferenceLine y={0} stroke="#94a3b8" strokeDasharray="3 3" />
|
||||
<Area type="monotone" dataKey="pnl" name="累计PnL" stroke="#10b981" fill="#d1fae5" strokeWidth={2} />
|
||||
<Area type="monotone" dataKey="dd" name="回撤" stroke="#ef4444" fill="#fee2e2" strokeWidth={1} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// L10: 历史交易表
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
type FS = "all"|"BTC"|"ETH"|"XRP"|"SOL";
|
||||
type FR = "all"|"win"|"loss";
|
||||
|
||||
function L10_TradeHistory() {
|
||||
const [trades, setTrades] = useState<any[]>([]);
|
||||
const [symbol, setSymbol] = useState<FS>("all");
|
||||
const [result, setResult] = useState<FR>("all");
|
||||
useEffect(() => {
|
||||
const f = async () => { try { const r = await authFetch(`/api/live/trades?symbol=${symbol}&result=${result}&strategy=${LIVE_STRATEGY}&limit=50`); if (r.ok) { const j = await r.json(); setTrades(j.data||[]); } } catch {} };
|
||||
f(); const iv = setInterval(f, 15000); return () => clearInterval(iv);
|
||||
}, [symbol, result]);
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden">
|
||||
<div className="px-3 py-2 border-b border-slate-100 flex items-center justify-between flex-wrap gap-1">
|
||||
<h3 className="font-semibold text-slate-800 text-xs">L10 历史交易</h3>
|
||||
<div className="flex gap-1">
|
||||
{(["all","BTC","ETH","XRP","SOL"] as FS[]).map(s => (<button key={s} onClick={()=>setSymbol(s)} className={`px-2 py-0.5 rounded text-[10px] ${symbol===s?"bg-slate-800 text-white":"text-slate-500 hover:bg-slate-100"}`}>{s==="all"?"全部":s}</button>))}
|
||||
<span className="text-slate-300">|</span>
|
||||
{(["all","win","loss"] as FR[]).map(r => (<button key={r} onClick={()=>setResult(r)} className={`px-2 py-0.5 rounded text-[10px] ${result===r?"bg-slate-800 text-white":"text-slate-500 hover:bg-slate-100"}`}>{r==="all"?"全部":r==="win"?"盈":"亏"}</button>))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{trades.length === 0 ? <div className="text-center text-slate-400 text-sm py-6">暂无交易记录</div> : (
|
||||
<table className="w-full text-[10px]">
|
||||
<thead className="bg-slate-50 sticky top-0"><tr className="text-slate-500">
|
||||
<th className="px-1.5 py-1 text-left">币种</th><th className="px-1.5 py-1">方向</th>
|
||||
<th className="px-1.5 py-1 text-right">入场</th><th className="px-1.5 py-1 text-right">出场</th>
|
||||
<th className="px-1.5 py-1 text-right">Gross</th>
|
||||
<th className="px-1.5 py-1 text-right">Fee</th>
|
||||
<th className="px-1.5 py-1 text-right">FR</th>
|
||||
<th className="px-1.5 py-1 text-right">Slip</th>
|
||||
<th className="px-1.5 py-1 text-right font-bold">Net</th>
|
||||
<th className="px-1.5 py-1">状态</th>
|
||||
<th className="px-1.5 py-1 text-right">时长</th>
|
||||
</tr></thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{trades.map((t: any) => {
|
||||
const hm = t.exit_ts && t.entry_ts ? Math.round((t.exit_ts-t.entry_ts)/60000) : 0;
|
||||
const gross = t.gross_pnl_r || 0;
|
||||
const fee = t.fee_r || 0;
|
||||
const fr = t.funding_r || 0;
|
||||
const slip = t.slippage_r || 0;
|
||||
const net = t.net_pnl_r ?? t.pnl_r ?? 0;
|
||||
return (<tr key={t.id} className="hover:bg-slate-50">
|
||||
<td className="px-1.5 py-1 font-mono">{t.symbol?.replace("USDT","")}</td>
|
||||
<td className={`px-1.5 py-1 text-center font-bold ${t.direction==="LONG"?"text-emerald-600":"text-red-500"}`}>{t.direction==="LONG"?"▲":"▼"}</td>
|
||||
<td className="px-1.5 py-1 text-right font-mono">{fmtPrice(t.entry_price)}</td>
|
||||
<td className="px-1.5 py-1 text-right font-mono">{t.exit_price?fmtPrice(t.exit_price):"-"}</td>
|
||||
<td className={`px-1.5 py-1 text-right font-mono ${gross>=0?"text-emerald-600":"text-red-500"}`}>{gross>=0?"+":""}{gross.toFixed(2)}</td>
|
||||
<td className="px-1.5 py-1 text-right font-mono text-amber-600">-{fee.toFixed(2)}</td>
|
||||
<td className="px-1.5 py-1 text-right font-mono text-violet-600">{fr > 0 ? `-${fr.toFixed(2)}` : "0"}</td>
|
||||
<td className="px-1.5 py-1 text-right font-mono text-slate-500">{slip > 0 ? `-${slip.toFixed(2)}` : "0"}</td>
|
||||
<td className={`px-1.5 py-1 text-right font-mono font-bold ${net>0?"text-emerald-600":net<0?"text-red-500":"text-slate-500"}`}>{net>0?"+":""}{net.toFixed(2)}R</td>
|
||||
<td className="px-1.5 py-1 text-center"><span className={`px-1 py-0.5 rounded text-[8px] ${t.status==="tp"?"bg-emerald-100 text-emerald-700":t.status==="sl"?"bg-red-100 text-red-700":t.status==="sl_be"?"bg-amber-100 text-amber-700":"bg-slate-100 text-slate-600"}`}>{t.status==="tp"?"止盈":t.status==="sl"?"止损":t.status==="sl_be"?"保本":t.status}</span></td>
|
||||
<td className="px-1.5 py-1 text-right text-slate-400">{hm}m</td>
|
||||
</tr>);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// L11: 系统健康
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
function L11_SystemHealth() {
|
||||
const [data, setData] = useState<any>(null);
|
||||
useEffect(() => {
|
||||
const f = async () => { try { const r = await authFetch("/api/live/health"); if (r.ok) setData(await r.json()); } catch {} };
|
||||
f(); const iv = setInterval(f, 10000); return () => clearInterval(iv);
|
||||
}, []);
|
||||
if (!data) return null;
|
||||
const procs = data.processes || {};
|
||||
const fresh = data.data_freshness || {};
|
||||
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">L11 系统健康</h3></div>
|
||||
<div className="p-3">
|
||||
{Object.keys(procs).length > 0 && (
|
||||
<div className="mb-2">
|
||||
<p className="text-[10px] text-slate-400 mb-1">进程状态</p>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-1.5">
|
||||
{Object.entries(procs).map(([name, p]: [string, any]) => (
|
||||
<div key={name} className={`rounded-lg px-2 py-1 text-[10px] ${p.status==="online"?"bg-emerald-50":"bg-red-50"}`}>
|
||||
<span className="font-medium">{name}</span>
|
||||
<span className={`ml-1 ${p.status==="online"?"text-emerald-600":"text-red-500"}`}>{p.status}</span>
|
||||
<span className="text-slate-400 ml-1">{p.memory_mb}MB ↻{p.restarts}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{fresh.market_data && (
|
||||
<div className="text-[10px]">
|
||||
<span className="text-slate-400">行情数据: </span>
|
||||
<span className={`font-mono ${fresh.market_data.status==="green"?"text-emerald-600":fresh.market_data.status==="yellow"?"text-amber-500":"text-red-500"}`}>
|
||||
{fresh.market_data.age_sec}秒前 {fresh.market_data.status==="green"?"✓":"⚠"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 主页面
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
export default function LiveTradingPage() {
|
||||
const { isLoggedIn, loading } = useAuth();
|
||||
if (loading) return <div className="text-center text-slate-400 py-8">加载中...</div>;
|
||||
if (!isLoggedIn) return (
|
||||
<div className="flex flex-col items-center justify-center h-64 gap-4">
|
||||
<div className="text-5xl">🔒</div>
|
||||
<p className="text-slate-600 font-medium">请先登录查看实盘</p>
|
||||
<Link href="/login" className="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm">登录</Link>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<L0_RiskBar />
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-slate-900">⚡ 实盘交易</h1>
|
||||
<p className="text-[10px] text-slate-500">V5.2策略 · 币安USDT永续合约 · 测试网</p>
|
||||
</div>
|
||||
</div>
|
||||
<L1_EmergencyPanel />
|
||||
<L15_LiveConfig />
|
||||
<L2_AccountOverview />
|
||||
<L3_Positions />
|
||||
<L4_ExecutionQuality />
|
||||
<L5_Reconciliation />
|
||||
<L6_RiskStatus />
|
||||
|
||||
{/* L7: 实时事件流 */}
|
||||
<L7_EventStream />
|
||||
|
||||
<L8_PaperComparison />
|
||||
<L9_EquityCurve />
|
||||
<L10_TradeHistory />
|
||||
<L11_SystemHealth />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
676
frontend/app/paper-v52/page.tsx
Normal file
676
frontend/app/paper-v52/page.tsx
Normal file
@ -0,0 +1,676 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { authFetch, useAuth } from "@/lib/auth";
|
||||
import { XAxis, YAxis, Tooltip, ResponsiveContainer, ReferenceLine, Area, AreaChart } from "recharts";
|
||||
|
||||
// ─── 工具函数 ────────────────────────────────────────────────────
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
function parseFactors(raw: any) {
|
||||
if (!raw) return null;
|
||||
if (typeof raw === "string") {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
type StrategyFilter = "v52_8signals";
|
||||
const PAPER_STRATEGY: StrategyFilter = "v52_8signals";
|
||||
|
||||
function strategyBadgeClass() {
|
||||
return "bg-emerald-100 text-emerald-700 border border-emerald-200";
|
||||
}
|
||||
|
||||
function strategyBadgeText() {
|
||||
return "✨ V5.2";
|
||||
}
|
||||
|
||||
// ─── 控制面板(开关+配置)──────────────────────────────────────
|
||||
|
||||
function ControlPanel() {
|
||||
const [config, setConfig] = useState<any>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const f = async () => {
|
||||
try {
|
||||
const r = await authFetch("/api/paper/config");
|
||||
if (r.ok) setConfig(await r.json());
|
||||
} catch {}
|
||||
};
|
||||
f();
|
||||
}, []);
|
||||
|
||||
const toggle = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const r = await authFetch("/api/paper/config", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled: !config.enabled }),
|
||||
});
|
||||
if (r.ok) setConfig(await r.json().then((j) => j.config));
|
||||
} catch {
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!config) return null;
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl border-2 ${config.enabled ? "border-emerald-400 bg-emerald-50" : "border-slate-200 bg-white"} px-3 py-2 flex items-center justify-between`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={toggle}
|
||||
disabled={saving}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
|
||||
config.enabled ? "bg-red-500 text-white hover:bg-red-600" : "bg-emerald-500 text-white hover:bg-emerald-600"
|
||||
}`}
|
||||
>
|
||||
{saving ? "..." : config.enabled ? "⏹ 停止模拟盘" : "▶️ 启动模拟盘"}
|
||||
</button>
|
||||
<span className={`text-xs font-medium ${config.enabled ? "text-emerald-700" : "text-slate-500"}`}>
|
||||
{config.enabled ? "🟢 运行中" : "⚪ 已停止"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-4 text-[10px] text-slate-500">
|
||||
<span>初始资金: ${config.initial_balance?.toLocaleString()}</span>
|
||||
<span>单笔风险: {(config.risk_per_trade * 100).toFixed(0)}%</span>
|
||||
<span>最大持仓: {config.max_positions}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 总览面板 ────────────────────────────────────────────────────
|
||||
|
||||
function SummaryCards({ strategy }: { strategy: StrategyFilter }) {
|
||||
const [data, setData] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const f = async () => {
|
||||
try {
|
||||
const r = await authFetch(`/api/paper/summary?strategy=${strategy}`);
|
||||
if (r.ok) setData(await r.json());
|
||||
} catch {}
|
||||
};
|
||||
f();
|
||||
const iv = setInterval(f, 10000);
|
||||
return () => clearInterval(iv);
|
||||
}, [strategy]);
|
||||
|
||||
if (!data) return <div className="text-center text-slate-400 text-sm py-4">加载中...</div>;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 lg:grid-cols-7 gap-1.5">
|
||||
<div className="bg-white rounded-lg border border-slate-200 px-2.5 py-2">
|
||||
<p className="text-[10px] text-slate-400">当前资金</p>
|
||||
<p className={`font-mono font-bold text-base ${data.balance >= 10000 ? "text-emerald-600" : "text-red-500"}`}>
|
||||
${data.balance?.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-slate-200 px-2.5 py-2">
|
||||
<p className="text-[10px] text-slate-400">总盈亏(R)</p>
|
||||
<p className={`font-mono font-bold text-lg ${data.total_pnl >= 0 ? "text-emerald-600" : "text-red-500"}`}>
|
||||
{data.total_pnl >= 0 ? "+" : ""}
|
||||
{data.total_pnl}R
|
||||
</p>
|
||||
<p className={`font-mono text-[10px] ${data.total_pnl_usdt >= 0 ? "text-emerald-500" : "text-red-400"}`}>
|
||||
{data.total_pnl_usdt >= 0 ? "+" : ""}${data.total_pnl_usdt}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-slate-200 px-2.5 py-2">
|
||||
<p className="text-[10px] text-slate-400">胜率</p>
|
||||
<p className="font-mono font-bold text-lg text-slate-800">{data.win_rate}%</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-slate-200 px-2.5 py-2">
|
||||
<p className="text-[10px] text-slate-400">总交易</p>
|
||||
<p className="font-mono font-bold text-lg text-slate-800">{data.total_trades}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-slate-200 px-2.5 py-2">
|
||||
<p className="text-[10px] text-slate-400">持仓中</p>
|
||||
<p className="font-mono font-bold text-lg text-blue-600">{data.active_positions}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-slate-200 px-2.5 py-2">
|
||||
<p className="text-[10px] text-slate-400">盈亏比(PF)</p>
|
||||
<p className="font-mono font-bold text-lg text-slate-800">{data.profit_factor}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-slate-200 px-2.5 py-2">
|
||||
<p className="text-[10px] text-slate-400">运行</p>
|
||||
<p className="font-mono font-semibold text-sm text-slate-600">{data.start_time ? "运行中 ✅" : "等待首笔"}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 最新信号状态 ────────────────────────────────────────────────
|
||||
|
||||
const COINS = ["BTCUSDT", "ETHUSDT", "XRPUSDT", "SOLUSDT"];
|
||||
|
||||
function LatestSignals() {
|
||||
const [signals, setSignals] = useState<Record<string, any>>({});
|
||||
useEffect(() => {
|
||||
const f = async () => {
|
||||
for (const sym of COINS) {
|
||||
try {
|
||||
const r = await authFetch(`/api/signals/signal-history?symbol=${sym.replace("USDT","")}&limit=1&strategy=v52_8signals`);
|
||||
if (r.ok) {
|
||||
const j = await r.json();
|
||||
if (j.data && j.data.length > 0) {
|
||||
setSignals(prev => ({ ...prev, [sym]: j.data[0] }));
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
};
|
||||
f(); const iv = setInterval(f, 15000); return () => clearInterval(iv);
|
||||
}, []);
|
||||
|
||||
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="divide-y divide-slate-50">
|
||||
{COINS.map(sym => {
|
||||
const s = signals[sym];
|
||||
const coin = sym.replace("USDT", "");
|
||||
const ago = s?.ts ? Math.round((Date.now() - s.ts) / 60000) : null;
|
||||
const fc = s?.factors;
|
||||
return (
|
||||
<div key={sym} className="px-3 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs font-bold text-slate-700 w-8">{coin}</span>
|
||||
{s?.signal ? (
|
||||
<>
|
||||
<span className={`text-xs font-bold ${s.signal === "LONG" ? "text-emerald-600" : "text-red-500"}`}>
|
||||
{s.signal === "LONG" ? "🟢" : "🔴"} {s.signal}
|
||||
</span>
|
||||
<span className="font-mono text-xs font-bold text-slate-800">{s.score}分</span>
|
||||
<span className="text-[9px] px-1.5 py-0.5 rounded bg-emerald-50 text-emerald-700 font-medium">{s.score >= 85 ? "加仓" : "标准"}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-[10px] text-slate-400">暂无信号</span>
|
||||
)}
|
||||
</div>
|
||||
{ago !== null && <span className="text-[10px] text-slate-400">{ago < 60 ? `${ago}m前` : `${Math.round(ago/60)}h前`}</span>}
|
||||
</div>
|
||||
{fc && (
|
||||
<div className="flex gap-1 mt-1 flex-wrap">
|
||||
<span className="text-[9px] px-1 py-0.5 rounded bg-blue-50 text-blue-700">方向{fc.direction?.score ?? 0}/{fc.direction?.max ?? 40}</span>
|
||||
<span className="text-[9px] px-1 py-0.5 rounded bg-violet-50 text-violet-700">拥挤{fc.crowding?.score ?? 0}/{fc.crowding?.max ?? 18}</span>
|
||||
<span className="text-[9px] px-1 py-0.5 rounded bg-cyan-50 text-cyan-700">FR{fc.funding_rate?.score ?? 0}/{fc.funding_rate?.max ?? 5}</span>
|
||||
<span className="text-[9px] px-1 py-0.5 rounded bg-emerald-50 text-emerald-700">环境{fc.environment?.score ?? 0}/{fc.environment?.max ?? 12}</span>
|
||||
<span className="text-[9px] px-1 py-0.5 rounded bg-amber-50 text-amber-700">确认{fc.confirmation?.score ?? 0}/{fc.confirmation?.max ?? 15}</span>
|
||||
<span className="text-[9px] px-1 py-0.5 rounded bg-orange-50 text-orange-700">清算{fc.liquidation?.score ?? 0}/{fc.liquidation?.max ?? 5}</span>
|
||||
<span className="text-[9px] px-1 py-0.5 rounded bg-slate-100 text-slate-600">辅助{fc.auxiliary?.score ?? 0}/{fc.auxiliary?.max ?? 5}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 当前持仓 ────────────────────────────────────────────────────
|
||||
|
||||
function ActivePositions({ strategy }: { strategy: StrategyFilter }) {
|
||||
const [paperRiskUsd, setPaperRiskUsd] = useState(200);
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try { const r = await authFetch("/api/paper/config"); if (r.ok) { const cfg = await r.json(); setPaperRiskUsd((cfg.initial_balance || 10000) * (cfg.risk_per_trade || 0.02)); } } catch {}
|
||||
})();
|
||||
}, []);
|
||||
const [positions, setPositions] = useState<any[]>([]);
|
||||
const [wsPrices, setWsPrices] = useState<Record<string, number>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const f = async () => {
|
||||
try {
|
||||
const r = await authFetch(`/api/paper/positions?strategy=${strategy}`);
|
||||
if (r.ok) {
|
||||
const j = await r.json();
|
||||
setPositions(j.data || []);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
f();
|
||||
const iv = setInterval(f, 10000);
|
||||
return () => clearInterval(iv);
|
||||
}, [strategy]);
|
||||
|
||||
useEffect(() => {
|
||||
const streams = ["btcusdt", "ethusdt", "xrpusdt", "solusdt"].map((s) => `${s}@aggTrade`).join("/");
|
||||
const ws = new WebSocket(`wss://fstream.binance.com/stream?streams=${streams}`);
|
||||
ws.onmessage = (e) => {
|
||||
try {
|
||||
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">
|
||||
V5.2 暂无活跃持仓
|
||||
</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">
|
||||
{positions.map((p: any) => {
|
||||
const sym = p.symbol?.replace("USDT", "") || "";
|
||||
const holdMin = Math.round((Date.now() - p.entry_ts) / 60000);
|
||||
const currentPrice = wsPrices[p.symbol] || p.current_price || 0;
|
||||
const factors = parseFactors(p.score_factors);
|
||||
const frScore = factors?.funding_rate?.score ?? 0;
|
||||
const liqScore = factors?.liquidation?.score ?? 0;
|
||||
const entry = p.entry_price || 0;
|
||||
const riskDist = p.risk_distance || Math.abs(entry - (p.sl_price || entry)) || 1;
|
||||
const fullR = riskDist > 0 ? (p.direction === "LONG" ? (currentPrice - entry) / riskDist : (entry - currentPrice) / riskDist) : 0;
|
||||
const tp1R = riskDist > 0 ? (p.direction === "LONG" ? ((p.tp1_price || 0) - entry) / riskDist : (entry - (p.tp1_price || 0)) / riskDist) : 0;
|
||||
const unrealR = p.tp1_hit ? 0.5 * tp1R + 0.5 * fullR : fullR;
|
||||
const unrealUsdt = unrealR * paperRiskUsd;
|
||||
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={`px-1.5 py-0.5 rounded text-[10px] font-semibold ${strategyBadgeClass()}`}>
|
||||
{strategyBadgeText()}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-500">评分{p.score} · {p.tier === "heavy" ? "加仓" : p.tier === "standard" ? "标准" : "轻仓"}</span>
|
||||
<span className="text-[10px] font-semibold text-emerald-700">FR {frScore >= 0 ? "+" : ""}{frScore} · Liq {liqScore >= 0 ? "+" : ""}{liqScore}</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={`font-mono text-[10px] ${unrealUsdt >= 0 ? "text-emerald-500" : "text-red-400"}`}>
|
||||
({unrealUsdt >= 0 ? "+" : ""}${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-slate-400">{new Date(p.entry_ts).toLocaleString("zh-CN", {hour12:false, month:"2-digit", day:"2-digit", hour:"2-digit", minute:"2-digit", second:"2-digit", fractionalSecondDigits:3} as any)}</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 grid grid-cols-2 gap-2 text-[10px] font-semibold">
|
||||
<div className="text-[10px] text-emerald-600 mt-0.5">FR {frScore >= 0 ? "+" : ""}{frScore} · Liq {liqScore >= 0 ? "+" : ""}{liqScore}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 权益曲线 ────────────────────────────────────────────────────
|
||||
|
||||
function EquityCurve({ strategy }: { strategy: StrategyFilter }) {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const f = async () => {
|
||||
try {
|
||||
const r = await authFetch(`/api/paper/equity-curve?strategy=${strategy}`);
|
||||
if (r.ok) {
|
||||
const j = await r.json();
|
||||
setData(j.data || []);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
f();
|
||||
const iv = setInterval(f, 30000);
|
||||
return () => clearInterval(iv);
|
||||
}, [strategy]);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden">
|
||||
<div className="px-3 py-2 border-b border-slate-100">
|
||||
<h3 className="font-semibold text-slate-800 text-xs">权益曲线 (累计PnL)</h3>
|
||||
</div>
|
||||
{data.length < 2 ? (
|
||||
<div className="px-3 py-6 text-center text-xs text-slate-400">V5.2 暂无足够历史数据</div>
|
||||
) : (
|
||||
<div className="p-2" style={{ height: 200 }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={data}>
|
||||
<XAxis dataKey="ts" tickFormatter={(v) => bjt(v)} tick={{ fontSize: 10 }} />
|
||||
<YAxis tick={{ fontSize: 10 }} tickFormatter={(v) => `${v}R`} />
|
||||
<Tooltip labelFormatter={(v) => bjt(Number(v))} formatter={(v: any) => [`${v}R`, "累计PnL"]} />
|
||||
<ReferenceLine y={0} stroke="#94a3b8" strokeDasharray="3 3" />
|
||||
<Area type="monotone" dataKey="pnl" stroke="#10b981" fill="#d1fae5" strokeWidth={2} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 历史交易列表 ────────────────────────────────────────────────
|
||||
|
||||
type FilterSymbol = "all" | "BTC" | "ETH" | "XRP" | "SOL";
|
||||
type FilterResult = "all" | "win" | "loss";
|
||||
|
||||
function TradeHistory({ strategy }: { strategy: StrategyFilter }) {
|
||||
const [trades, setTrades] = useState<any[]>([]);
|
||||
const [symbol, setSymbol] = useState<FilterSymbol>("all");
|
||||
const [result, setResult] = useState<FilterResult>("all");
|
||||
|
||||
useEffect(() => {
|
||||
const f = async () => {
|
||||
try {
|
||||
const r = await authFetch(`/api/paper/trades?symbol=${symbol}&result=${result}&strategy=${strategy}&limit=50`);
|
||||
if (r.ok) {
|
||||
const j = await r.json();
|
||||
setTrades(j.data || []);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
f();
|
||||
const iv = setInterval(f, 10000);
|
||||
return () => clearInterval(iv);
|
||||
}, [symbol, result, strategy]);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden">
|
||||
<div className="px-3 py-2 border-b border-slate-100 flex items-center justify-between flex-wrap gap-1">
|
||||
<h3 className="font-semibold text-slate-800 text-xs">历史交易</h3>
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
<span className={`px-2 py-0.5 rounded text-[10px] font-semibold ${strategyBadgeClass()}`}>
|
||||
{strategyBadgeText()} 视图
|
||||
</span>
|
||||
<span className="text-slate-300">|</span>
|
||||
{(["all", "BTC", "ETH", "XRP", "SOL"] as FilterSymbol[]).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setSymbol(s)}
|
||||
className={`px-2 py-0.5 rounded text-[10px] ${symbol === s ? "bg-slate-800 text-white" : "text-slate-500 hover:bg-slate-100"}`}
|
||||
>
|
||||
{s === "all" ? "全部" : s}
|
||||
</button>
|
||||
))}
|
||||
<span className="text-slate-300">|</span>
|
||||
{(["all", "win", "loss"] as FilterResult[]).map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setResult(r)}
|
||||
className={`px-2 py-0.5 rounded text-[10px] ${result === r ? "bg-slate-800 text-white" : "text-slate-500 hover:bg-slate-100"}`}
|
||||
>
|
||||
{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-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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{trades.map((t: any) => {
|
||||
const holdMin = t.exit_ts && t.entry_ts ? Math.round((t.exit_ts - t.entry_ts) / 60000) : 0;
|
||||
const factors = parseFactors(t.score_factors);
|
||||
const frScore = factors?.funding_rate?.score ?? 0;
|
||||
const liqScore = factors?.liquidation?.score ?? 0;
|
||||
return (
|
||||
<tr key={t.id} className="hover:bg-slate-50">
|
||||
<td className="px-2 py-1.5 font-mono">{t.symbol?.replace("USDT", "")}</td>
|
||||
<td className="px-2 py-1.5 text-[10px]">
|
||||
<span className={`px-1.5 py-0.5 rounded font-semibold ${strategyBadgeClass()}`}>{strategyBadgeText()}</span>
|
||||
</td>
|
||||
<td className={`px-2 py-1.5 font-bold ${t.direction === "LONG" ? "text-emerald-600" : "text-red-500"}`}>
|
||||
{t.direction === "LONG" ? "🟢" : "🔴"} {t.direction}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-right font-mono">{fmtPrice(t.entry_price)}</td>
|
||||
<td className="px-2 py-1.5 text-right font-mono">{t.exit_price ? fmtPrice(t.exit_price) : "-"}</td>
|
||||
<td className={`px-2 py-1.5 text-right font-mono font-bold ${t.pnl_r > 0 ? "text-emerald-600" : t.pnl_r < 0 ? "text-red-500" : "text-slate-500"}`}>
|
||||
{t.pnl_r > 0 ? "+" : ""}
|
||||
{t.pnl_r?.toFixed(2)}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-center">
|
||||
<span
|
||||
className={`px-1 py-0.5 rounded text-[9px] ${
|
||||
t.status === "tp"
|
||||
? "bg-emerald-100 text-emerald-700"
|
||||
: t.status === "sl"
|
||||
? "bg-red-100 text-red-700"
|
||||
: t.status === "sl_be"
|
||||
? "bg-amber-100 text-amber-700"
|
||||
: t.status === "signal_flip"
|
||||
? "bg-purple-100 text-purple-700"
|
||||
: "bg-slate-100 text-slate-600"
|
||||
}`}
|
||||
>
|
||||
{t.status === "tp"
|
||||
? "止盈"
|
||||
: t.status === "sl"
|
||||
? "止损"
|
||||
: t.status === "sl_be"
|
||||
? "保本"
|
||||
: t.status === "timeout"
|
||||
? "超时"
|
||||
: t.status === "signal_flip"
|
||||
? "翻转"
|
||||
: t.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-right font-mono">
|
||||
<div>{t.score}<span className="text-emerald-600 text-[9px] ml-0.5">(FR{frScore >= 0 ? "+" : ""}{frScore}/Liq{liqScore >= 0 ? "+" : ""}{liqScore})</span></div>
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-right text-slate-400">{holdMin}m</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 统计面板 ────────────────────────────────────────────────────
|
||||
|
||||
function StatsPanel({ strategy }: { strategy: StrategyFilter }) {
|
||||
const [data, setData] = useState<any>(null);
|
||||
const [tab, setTab] = useState("ALL");
|
||||
|
||||
useEffect(() => {
|
||||
const f = async () => {
|
||||
try {
|
||||
const r = await authFetch(`/api/paper/stats?strategy=${strategy}`);
|
||||
if (r.ok) setData(await r.json());
|
||||
} catch {}
|
||||
};
|
||||
f();
|
||||
const iv = setInterval(f, 30000);
|
||||
return () => clearInterval(iv);
|
||||
}, [strategy]);
|
||||
|
||||
useEffect(() => {
|
||||
setTab("ALL");
|
||||
}, [strategy]);
|
||||
|
||||
if (!data || data.error) {
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden">
|
||||
<div className="px-3 py-2 border-b border-slate-100">
|
||||
<h3 className="font-semibold text-slate-800 text-xs">详细统计</h3>
|
||||
</div>
|
||||
<div className="p-3 text-xs text-slate-400">该视图暂无统计数据</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tabs = ["ALL", "BTC", "ETH", "XRP", "SOL"];
|
||||
const 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">
|
||||
<h3 className="font-semibold text-slate-800 text-xs">详细统计</h3>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className={`px-2 py-0.5 rounded text-[10px] font-semibold ${strategyBadgeClass()}`}>{strategyBadgeText()}</span>
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`px-2 py-0.5 rounded text-[10px] font-medium transition-colors ${tab === t ? "bg-slate-800 text-white" : "bg-slate-100 text-slate-500 hover:bg-slate-200"}`}
|
||||
>
|
||||
{t === "ALL" ? "总计" : t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{st ? (
|
||||
<div className="p-3 space-y-3">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2 text-xs">
|
||||
<div>
|
||||
<span className="text-slate-400">胜率</span>
|
||||
<p className="font-mono font-bold">{st.win_rate}%</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-400">盈亏比</span>
|
||||
<p className="font-mono font-bold">{st.win_loss_ratio}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-400">平均盈利</span>
|
||||
<p className="font-mono font-bold text-emerald-600">+{st.avg_win}R</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-400">平均亏损</span>
|
||||
<p className="font-mono font-bold text-red-500">-{st.avg_loss}R</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-400">最大回撤</span>
|
||||
<p className="font-mono font-bold">{st.mdd}R</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-400">夏普比率</span>
|
||||
<p className="font-mono font-bold">{st.sharpe}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-400">总盈亏</span>
|
||||
<p className={`font-mono font-bold ${(st.total_pnl ?? 0) >= 0 ? "text-emerald-600" : "text-red-500"}`}>
|
||||
{(st.total_pnl ?? 0) >= 0 ? "+" : ""}
|
||||
{st.total_pnl ?? "-"}R
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-400">总笔数</span>
|
||||
<p className="font-mono font-bold">{st.total ?? data.total}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-400">做多胜率</span>
|
||||
<p className="font-mono">{st.long_win_rate}% ({st.long_count}笔)</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-400">做空胜率</span>
|
||||
<p className="font-mono">{st.short_win_rate}% ({st.short_count}笔)</p>
|
||||
</div>
|
||||
{tab === "ALL" && data.by_tier && Object.entries(data.by_tier).map(([t, v]: [string, any]) => (
|
||||
<div key={t}>
|
||||
<span className="text-slate-400">{t === "heavy" ? "加仓档" : t === "standard" ? "标准档" : "轻仓档"}</span>
|
||||
<p className="font-mono">{v.win_rate}% ({v.total}笔)</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-3 text-xs text-slate-400">该币种暂无数据</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 主页面 ──────────────────────────────────────────────────────
|
||||
|
||||
export default function PaperTradingV52Page() {
|
||||
const { isLoggedIn, loading } = useAuth();
|
||||
const strategy: StrategyFilter = PAPER_STRATEGY;
|
||||
|
||||
if (loading) return <div className="text-center text-slate-400 py-8">加载中...</div>;
|
||||
|
||||
if (!isLoggedIn)
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-64 gap-4">
|
||||
<div className="text-5xl">🔒</div>
|
||||
<p className="text-slate-600 font-medium">请先登录查看模拟盘</p>
|
||||
<Link href="/login" className="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm">
|
||||
登录
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-slate-900">📈 V5.2 模拟盘</h1>
|
||||
<p className="text-[10px] text-slate-500">仅展示 v52_8signals · 包含 FR / 清算评分与策略标签</p>
|
||||
</div>
|
||||
|
||||
<ControlPanel />
|
||||
<SummaryCards strategy={strategy} />
|
||||
<LatestSignals />
|
||||
<ActivePositions strategy={strategy} />
|
||||
<EquityCurve strategy={strategy} />
|
||||
<TradeHistory strategy={strategy} />
|
||||
<StatsPanel strategy={strategy} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
396
frontend/app/paper-v53/page.tsx
Normal file
396
frontend/app/paper-v53/page.tsx
Normal file
@ -0,0 +1,396 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { authFetch, useAuth } from "@/lib/auth";
|
||||
import { XAxis, YAxis, Tooltip, ResponsiveContainer, ReferenceLine, Area, AreaChart } from "recharts";
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
function parseFactors(raw: any) {
|
||||
if (!raw) return null;
|
||||
if (typeof raw === "string") { try { return JSON.parse(raw); } catch { return null; } }
|
||||
return raw;
|
||||
}
|
||||
|
||||
const STRATEGY = "v53";
|
||||
const ALL_COINS = ["BTCUSDT", "ETHUSDT", "XRPUSDT", "SOLUSDT"];
|
||||
|
||||
// ─── 最新信号 ────────────────────────────────────────────────────
|
||||
|
||||
function LatestSignals() {
|
||||
const [signals, setSignals] = useState<Record<string, any>>({});
|
||||
useEffect(() => {
|
||||
const f = async () => {
|
||||
for (const sym of ALL_COINS) {
|
||||
const coin = sym.replace("USDT", "");
|
||||
try {
|
||||
const r = await authFetch(`/api/signals/signal-history?symbol=${coin}&limit=1&strategy=${STRATEGY}`);
|
||||
if (r.ok) { const j = await r.json(); if (j.data?.length > 0) setSignals(prev => ({ ...prev, [sym]: j.data[0] })); }
|
||||
} catch {}
|
||||
}
|
||||
};
|
||||
f(); const iv = setInterval(f, 15000); return () => clearInterval(iv);
|
||||
}, []);
|
||||
|
||||
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">
|
||||
<h3 className="font-semibold text-slate-800 text-xs">最新信号</h3>
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-semibold bg-blue-100 text-blue-700 border border-blue-200">v53</span>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-50">
|
||||
{ALL_COINS.map(sym => {
|
||||
const s = signals[sym];
|
||||
const coin = sym.replace("USDT", "");
|
||||
const ago = s?.ts ? Math.round((Date.now() - s.ts) / 60000) : null;
|
||||
const fc = s?.factors;
|
||||
const gatePassed = fc?.gate_passed ?? true;
|
||||
return (
|
||||
<div key={sym} className="px-3 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs font-bold text-slate-700 w-8">{coin}</span>
|
||||
{s?.signal ? (
|
||||
<>
|
||||
<span className={`text-xs font-bold ${s.signal === "LONG" ? "text-emerald-600" : "text-red-500"}`}>
|
||||
{s.signal === "LONG" ? "🟢" : "🔴"} {s.signal}
|
||||
</span>
|
||||
<span className="font-mono text-xs font-bold text-slate-800">{s.score}分</span>
|
||||
</>
|
||||
) : <span className="text-[10px] text-slate-400">暂无信号</span>}
|
||||
</div>
|
||||
{ago !== null && <span className="text-[10px] text-slate-400">{ago < 60 ? `${ago}m前` : `${Math.round(ago/60)}h前`}</span>}
|
||||
</div>
|
||||
{fc && (
|
||||
<div className="flex gap-1 mt-1 flex-wrap">
|
||||
<span className={`text-[9px] px-1 py-0.5 rounded ${gatePassed ? "bg-emerald-100 text-emerald-700" : "bg-red-100 text-red-600"}`}>
|
||||
{gatePassed ? "✅" : "❌"} {fc.gate_block || "Gate"}
|
||||
</span>
|
||||
<span className="text-[9px] px-1 py-0.5 rounded bg-blue-50 text-blue-700">方向{fc.direction?.score ?? 0}/55</span>
|
||||
<span className="text-[9px] px-1 py-0.5 rounded bg-violet-50 text-violet-700">拥挤{fc.crowding?.score ?? 0}/25</span>
|
||||
<span className="text-[9px] px-1 py-0.5 rounded bg-emerald-50 text-emerald-700">环境{fc.environment?.score ?? 0}/15</span>
|
||||
<span className="text-[9px] px-1 py-0.5 rounded bg-slate-100 text-slate-600">辅助{fc.auxiliary?.score ?? 0}/5</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 控制面板 ────────────────────────────────────────────────────
|
||||
|
||||
function ControlPanel() {
|
||||
const [config, setConfig] = useState<any>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
useEffect(() => {
|
||||
(async () => { try { const r = await authFetch("/api/paper/config"); if (r.ok) setConfig(await r.json()); } catch {} })();
|
||||
}, []);
|
||||
const toggle = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const r = await authFetch("/api/paper/config", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ enabled: !config.enabled }) });
|
||||
if (r.ok) setConfig(await r.json().then((j: any) => j.config));
|
||||
} catch {} finally { setSaving(false); }
|
||||
};
|
||||
if (!config) return null;
|
||||
return (
|
||||
<div className={`rounded-xl border-2 ${config.enabled ? "border-emerald-400 bg-emerald-50" : "border-slate-200 bg-white"} px-3 py-2 flex items-center justify-between`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={toggle} disabled={saving}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${config.enabled ? "bg-red-500 text-white hover:bg-red-600" : "bg-emerald-500 text-white hover:bg-emerald-600"}`}>
|
||||
{saving ? "..." : config.enabled ? "⏹ 停止" : "▶️ 启动"}
|
||||
</button>
|
||||
<span className={`text-xs font-medium ${config.enabled ? "text-emerald-700" : "text-slate-500"}`}>{config.enabled ? "🟢 运行中" : "⚪ 已停止"}</span>
|
||||
</div>
|
||||
<div className="flex gap-4 text-[10px] text-slate-500">
|
||||
<span>初始: ${config.initial_balance?.toLocaleString()}</span>
|
||||
<span>风险: {(config.risk_per_trade * 100).toFixed(0)}%</span>
|
||||
<span>最大: {config.max_positions}仓</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 总览 ────────────────────────────────────────────────────────
|
||||
|
||||
function SummaryCards() {
|
||||
const [data, setData] = useState<any>(null);
|
||||
useEffect(() => {
|
||||
const f = async () => { try { const r = await authFetch(`/api/paper/summary?strategy=${STRATEGY}`); if (r.ok) setData(await r.json()); } catch {} };
|
||||
f(); const iv = setInterval(f, 10000); return () => clearInterval(iv);
|
||||
}, []);
|
||||
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() {
|
||||
const [positions, setPositions] = useState<any[]>([]);
|
||||
const [wsPrices, setWsPrices] = useState<Record<string, number>>({});
|
||||
const [paperRiskUsd, setPaperRiskUsd] = useState(200);
|
||||
useEffect(() => {
|
||||
(async () => { try { const r = await authFetch("/api/paper/config"); if (r.ok) { const cfg = await r.json(); setPaperRiskUsd((cfg.initial_balance||10000)*(cfg.risk_per_trade||0.02)); } } catch {} })();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const f = async () => { try { const r = await authFetch(`/api/paper/positions?strategy=${STRATEGY}`); if (r.ok) setPositions((await r.json()).data||[]); } catch {} };
|
||||
f(); const iv = setInterval(f, 10000); return () => clearInterval(iv);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const streams = ["btcusdt","ethusdt","xrpusdt","solusdt"].map(s=>`${s}@aggTrade`).join("/");
|
||||
const ws = new WebSocket(`wss://fstream.binance.com/stream?streams=${streams}`);
|
||||
ws.onmessage = (e) => { try { 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">v53 暂无活跃持仓</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">
|
||||
{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*paperRiskUsd;
|
||||
const fc = parseFactors(p.score_factors);
|
||||
const track = fc?.track||(p.symbol==="BTCUSDT"?"BTC":"ALT");
|
||||
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={`px-1.5 py-0.5 rounded text-[10px] font-semibold ${track==="BTC"?"bg-amber-100 text-amber-700":"bg-purple-100 text-purple-700"}`}>{track}</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] 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 ? new Date(p.entry_ts).toLocaleString("zh-CN", {hour12:false, month:"2-digit", day:"2-digit", hour:"2-digit", minute:"2-digit", second:"2-digit"} as any) : "-"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 权益曲线 ────────────────────────────────────────────────────
|
||||
|
||||
function EquityCurve() {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
useEffect(() => {
|
||||
const f = async () => { try { const r = await authFetch(`/api/paper/equity-curve?strategy=${STRATEGY}`); if (r.ok) setData((await r.json()).data||[]); } catch {} };
|
||||
f(); const iv = setInterval(f, 30000); return () => clearInterval(iv);
|
||||
}, []);
|
||||
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(v)} tick={{ fontSize: 10 }} />
|
||||
<YAxis tick={{ fontSize: 10 }} tickFormatter={(v) => `${v}R`} />
|
||||
<Tooltip labelFormatter={(v) => bjt(Number(v))} formatter={(v: any) => [`${v}R`, "累计PnL"]} />
|
||||
<ReferenceLine y={0} stroke="#94a3b8" strokeDasharray="3 3" />
|
||||
<Area type="monotone" dataKey="pnl" stroke="#10b981" fill="#d1fae5" strokeWidth={2} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 历史交易 ────────────────────────────────────────────────────
|
||||
|
||||
type FilterSymbol = "all" | "BTC" | "ETH" | "XRP" | "SOL";
|
||||
type FilterResult = "all" | "win" | "loss";
|
||||
|
||||
function TradeHistory() {
|
||||
const [trades, setTrades] = useState<any[]>([]);
|
||||
const [symbol, setSymbol] = useState<FilterSymbol>("all");
|
||||
const [result, setResult] = useState<FilterResult>("all");
|
||||
useEffect(() => {
|
||||
const f = async () => { try { const r = await authFetch(`/api/paper/trades?symbol=${symbol}&result=${result}&strategy=${STRATEGY}&limit=50`); if (r.ok) setTrades((await r.json()).data||[]); } catch {} };
|
||||
f(); const iv = setInterval(f, 10000); return () => clearInterval(iv);
|
||||
}, [symbol, result]);
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden">
|
||||
<div className="px-3 py-2 border-b border-slate-100 flex items-center justify-between flex-wrap gap-1">
|
||||
<h3 className="font-semibold text-slate-800 text-xs">历史交易</h3>
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{(["all","BTC","ETH","XRP","SOL"] as FilterSymbol[]).map(s => (
|
||||
<button key={s} onClick={() => setSymbol(s)} className={`px-2 py-0.5 rounded text-[10px] ${symbol===s?"bg-slate-800 text-white":"text-slate-500 hover:bg-slate-100"}`}>{s==="all"?"全部":s}</button>
|
||||
))}
|
||||
<span className="text-slate-300">|</span>
|
||||
{(["all","win","loss"] as FilterResult[]).map(r => (
|
||||
<button key={r} onClick={() => setResult(r)} className={`px-2 py-0.5 rounded text-[10px] ${result===r?"bg-slate-800 text-white":"text-slate-500 hover:bg-slate-100"}`}>{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">
|
||||
{trades.map((t: any) => {
|
||||
const holdMin = t.exit_ts&&t.entry_ts?Math.round((t.exit_ts-t.entry_ts)/60000):0;
|
||||
const fc = parseFactors(t.score_factors);
|
||||
const track = fc?.track||(t.symbol==="BTCUSDT"?"BTC":"ALT");
|
||||
const fmtTime = (ms: number) => ms ? new Date(ms).toLocaleString("zh-CN", {hour12:false, month:"2-digit", day:"2-digit", hour:"2-digit", minute:"2-digit"} as any) : "-";
|
||||
return (
|
||||
<tr key={t.id} className="hover:bg-slate-50">
|
||||
<td className="px-2 py-1.5 font-mono">{t.symbol?.replace("USDT","")}<span className={`ml-1 text-[9px] px-1 rounded ${track==="BTC"?"bg-amber-100 text-amber-700":"bg-purple-100 text-purple-700"}`}>{track}</span></td>
|
||||
<td className={`px-2 py-1.5 font-bold ${t.direction==="LONG"?"text-emerald-600":"text-red-500"}`}>{t.direction==="LONG"?"🟢":"🔴"} {t.direction}</td>
|
||||
<td className="px-2 py-1.5 text-right font-mono">{fmtPrice(t.entry_price)}</td>
|
||||
<td className="px-2 py-1.5 text-right font-mono">{t.exit_price?fmtPrice(t.exit_price):"-"}</td>
|
||||
<td className={`px-2 py-1.5 text-right font-mono font-bold ${t.pnl_r>0?"text-emerald-600":t.pnl_r<0?"text-red-500":"text-slate-500"}`}>{t.pnl_r>0?"+":""}{t.pnl_r?.toFixed(2)}</td>
|
||||
<td className="px-2 py-1.5 text-center"><span className={`px-1 py-0.5 rounded text-[9px] ${t.status==="tp"?"bg-emerald-100 text-emerald-700":t.status==="sl"?"bg-red-100 text-red-700":t.status==="sl_be"?"bg-amber-100 text-amber-700":t.status==="signal_flip"?"bg-purple-100 text-purple-700":"bg-slate-100 text-slate-600"}`}>{t.status==="tp"?"止盈":t.status==="sl"?"止损":t.status==="sl_be"?"保本":t.status==="timeout"?"超时":t.status==="signal_flip"?"翻转":t.status}</span></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() {
|
||||
const [data, setData] = useState<any>(null);
|
||||
const [tab, setTab] = useState("ALL");
|
||||
useEffect(() => {
|
||||
const f = async () => { try { const r = await authFetch(`/api/paper/stats?strategy=${STRATEGY}`); if (r.ok) setData(await r.json()); } catch {} };
|
||||
f(); const iv = setInterval(f, 30000); return () => clearInterval(iv);
|
||||
}, []);
|
||||
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 PaperTradingV53Page() {
|
||||
const { isLoggedIn, loading } = useAuth();
|
||||
if (loading) return <div className="text-center text-slate-400 py-8">加载中...</div>;
|
||||
if (!isLoggedIn) return (
|
||||
<div className="flex flex-col items-center justify-center h-64 gap-4">
|
||||
<div className="text-5xl">🔒</div>
|
||||
<p className="text-slate-600 font-medium">请先登录查看模拟盘</p>
|
||||
<Link href="/login" className="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm">登录</Link>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-slate-900">📈 模拟盘 V5.3</h1>
|
||||
<p className="text-[10px] text-slate-500">统一策略 v53 · BTC/ETH/XRP/SOL · 四层评分 55/25/15/5 + per-symbol 四门控制</p>
|
||||
</div>
|
||||
<ControlPanel />
|
||||
<SummaryCards />
|
||||
<LatestSignals />
|
||||
<ActivePositions />
|
||||
<EquityCurve />
|
||||
<TradeHistory />
|
||||
<StatsPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
405
frontend/app/paper-v53fast/page.tsx
Normal file
405
frontend/app/paper-v53fast/page.tsx
Normal file
@ -0,0 +1,405 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { authFetch, useAuth } from "@/lib/auth";
|
||||
import { XAxis, YAxis, Tooltip, ResponsiveContainer, ReferenceLine, Area, AreaChart } from "recharts";
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
function parseFactors(raw: any) {
|
||||
if (!raw) return null;
|
||||
if (typeof raw === "string") { try { return JSON.parse(raw); } catch { return null; } }
|
||||
return raw;
|
||||
}
|
||||
|
||||
const STRATEGY = "v53_fast";
|
||||
const ALL_COINS = ["BTCUSDT", "ETHUSDT", "XRPUSDT", "SOLUSDT"];
|
||||
|
||||
// ─── 最新信号 ────────────────────────────────────────────────────
|
||||
|
||||
function LatestSignals() {
|
||||
const [signals, setSignals] = useState<Record<string, any>>({});
|
||||
useEffect(() => {
|
||||
const f = async () => {
|
||||
for (const sym of ALL_COINS) {
|
||||
const coin = sym.replace("USDT", "");
|
||||
try {
|
||||
const r = await authFetch(`/api/signals/signal-history?symbol=${coin}&limit=1&strategy=${STRATEGY}`);
|
||||
if (r.ok) { const j = await r.json(); if (j.data?.length > 0) setSignals(prev => ({ ...prev, [sym]: j.data[0] })); }
|
||||
} catch {}
|
||||
}
|
||||
};
|
||||
f(); const iv = setInterval(f, 15000); return () => clearInterval(iv);
|
||||
}, []);
|
||||
|
||||
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">
|
||||
<h3 className="font-semibold text-slate-800 text-xs">最新信号</h3>
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-semibold bg-blue-100 text-blue-700 border border-blue-200">v53</span>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-50">
|
||||
{ALL_COINS.map(sym => {
|
||||
const s = signals[sym];
|
||||
const coin = sym.replace("USDT", "");
|
||||
const ago = s?.ts ? Math.round((Date.now() - s.ts) / 60000) : null;
|
||||
const fc = s?.factors;
|
||||
const gatePassed = fc?.gate_passed ?? true;
|
||||
return (
|
||||
<div key={sym} className="px-3 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs font-bold text-slate-700 w-8">{coin}</span>
|
||||
{s?.signal ? (
|
||||
<>
|
||||
<span className={`text-xs font-bold ${s.signal === "LONG" ? "text-emerald-600" : "text-red-500"}`}>
|
||||
{s.signal === "LONG" ? "🟢" : "🔴"} {s.signal}
|
||||
</span>
|
||||
<span className="font-mono text-xs font-bold text-slate-800">{s.score}分</span>
|
||||
</>
|
||||
) : <span className="text-[10px] text-slate-400">暂无信号</span>}
|
||||
</div>
|
||||
{ago !== null && <span className="text-[10px] text-slate-400">{ago < 60 ? `${ago}m前` : `${Math.round(ago/60)}h前`}</span>}
|
||||
</div>
|
||||
{fc && (
|
||||
<div className="flex gap-1 mt-1 flex-wrap">
|
||||
<span className={`text-[9px] px-1 py-0.5 rounded ${gatePassed ? "bg-emerald-100 text-emerald-700" : "bg-red-100 text-red-600"}`}>
|
||||
{gatePassed ? "✅" : "❌"} {fc.gate_block || "Gate"}
|
||||
</span>
|
||||
<span className="text-[9px] px-1 py-0.5 rounded bg-blue-50 text-blue-700">方向{fc.direction?.score ?? 0}/55</span>
|
||||
<span className="text-[9px] px-1 py-0.5 rounded bg-violet-50 text-violet-700">拥挤{fc.crowding?.score ?? 0}/25</span>
|
||||
<span className="text-[9px] px-1 py-0.5 rounded bg-emerald-50 text-emerald-700">环境{fc.environment?.score ?? 0}/15</span>
|
||||
<span className="text-[9px] px-1 py-0.5 rounded bg-slate-100 text-slate-600">辅助{fc.auxiliary?.score ?? 0}/5</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 控制面板 ────────────────────────────────────────────────────
|
||||
|
||||
function ControlPanel() {
|
||||
const [config, setConfig] = useState<any>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
useEffect(() => {
|
||||
(async () => { try { const r = await authFetch("/api/paper/config"); if (r.ok) setConfig(await r.json()); } catch {} })();
|
||||
}, []);
|
||||
const toggle = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const r = await authFetch("/api/paper/config", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ enabled: !config.enabled }) });
|
||||
if (r.ok) setConfig(await r.json().then((j: any) => j.config));
|
||||
} catch {} finally { setSaving(false); }
|
||||
};
|
||||
if (!config) return null;
|
||||
return (
|
||||
<div className={`rounded-xl border-2 ${config.enabled ? "border-emerald-400 bg-emerald-50" : "border-slate-200 bg-white"} px-3 py-2 flex items-center justify-between`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={toggle} disabled={saving}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${config.enabled ? "bg-red-500 text-white hover:bg-red-600" : "bg-emerald-500 text-white hover:bg-emerald-600"}`}>
|
||||
{saving ? "..." : config.enabled ? "⏹ 停止" : "▶️ 启动"}
|
||||
</button>
|
||||
<span className={`text-xs font-medium ${config.enabled ? "text-emerald-700" : "text-slate-500"}`}>{config.enabled ? "🟢 运行中" : "⚪ 已停止"}</span>
|
||||
</div>
|
||||
<div className="flex gap-4 text-[10px] text-slate-500">
|
||||
<span>初始: ${config.initial_balance?.toLocaleString()}</span>
|
||||
<span>风险: {(config.risk_per_trade * 100).toFixed(0)}%</span>
|
||||
<span>最大: {config.max_positions}仓</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 总览 ────────────────────────────────────────────────────────
|
||||
|
||||
function SummaryCards() {
|
||||
const [data, setData] = useState<any>(null);
|
||||
useEffect(() => {
|
||||
const f = async () => { try { const r = await authFetch(`/api/paper/summary?strategy=${STRATEGY}`); if (r.ok) setData(await r.json()); } catch {} };
|
||||
f(); const iv = setInterval(f, 10000); return () => clearInterval(iv);
|
||||
}, []);
|
||||
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() {
|
||||
const [positions, setPositions] = useState<any[]>([]);
|
||||
const [wsPrices, setWsPrices] = useState<Record<string, number>>({});
|
||||
const [paperRiskUsd, setPaperRiskUsd] = useState(200);
|
||||
useEffect(() => {
|
||||
(async () => { try { const r = await authFetch("/api/paper/config"); if (r.ok) { const cfg = await r.json(); setPaperRiskUsd((cfg.initial_balance||10000)*(cfg.risk_per_trade||0.02)); } } catch {} })();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const f = async () => { try { const r = await authFetch(`/api/paper/positions?strategy=${STRATEGY}`); if (r.ok) setPositions((await r.json()).data||[]); } catch {} };
|
||||
f(); const iv = setInterval(f, 10000); return () => clearInterval(iv);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const streams = ["btcusdt","ethusdt","xrpusdt","solusdt"].map(s=>`${s}@aggTrade`).join("/");
|
||||
const ws = new WebSocket(`wss://fstream.binance.com/stream?streams=${streams}`);
|
||||
ws.onmessage = (e) => { try { 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">v53 暂无活跃持仓</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">
|
||||
{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*paperRiskUsd;
|
||||
const fc = parseFactors(p.score_factors);
|
||||
const track = fc?.track||(p.symbol==="BTCUSDT"?"BTC":"ALT");
|
||||
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={`px-1.5 py-0.5 rounded text-[10px] font-semibold ${track==="BTC"?"bg-amber-100 text-amber-700":"bg-purple-100 text-purple-700"}`}>{track}</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] 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 ? new Date(p.entry_ts).toLocaleString("zh-CN", {hour12:false, month:"2-digit", day:"2-digit", hour:"2-digit", minute:"2-digit", second:"2-digit"} as any) : "-"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 权益曲线 ────────────────────────────────────────────────────
|
||||
|
||||
function EquityCurve() {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
useEffect(() => {
|
||||
const f = async () => { try { const r = await authFetch(`/api/paper/equity-curve?strategy=${STRATEGY}`); if (r.ok) setData((await r.json()).data||[]); } catch {} };
|
||||
f(); const iv = setInterval(f, 30000); return () => clearInterval(iv);
|
||||
}, []);
|
||||
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(v)} tick={{ fontSize: 10 }} />
|
||||
<YAxis tick={{ fontSize: 10 }} tickFormatter={(v) => `${v}R`} />
|
||||
<Tooltip labelFormatter={(v) => bjt(Number(v))} formatter={(v: any) => [`${v}R`, "累计PnL"]} />
|
||||
<ReferenceLine y={0} stroke="#94a3b8" strokeDasharray="3 3" />
|
||||
<Area type="monotone" dataKey="pnl" stroke="#10b981" fill="#d1fae5" strokeWidth={2} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 历史交易 ────────────────────────────────────────────────────
|
||||
|
||||
type FilterSymbol = "all" | "BTC" | "ETH" | "XRP" | "SOL";
|
||||
type FilterResult = "all" | "win" | "loss";
|
||||
|
||||
function TradeHistory() {
|
||||
const [trades, setTrades] = useState<any[]>([]);
|
||||
const [symbol, setSymbol] = useState<FilterSymbol>("all");
|
||||
const [result, setResult] = useState<FilterResult>("all");
|
||||
useEffect(() => {
|
||||
const f = async () => { try { const r = await authFetch(`/api/paper/trades?symbol=${symbol}&result=${result}&strategy=${STRATEGY}&limit=50`); if (r.ok) setTrades((await r.json()).data||[]); } catch {} };
|
||||
f(); const iv = setInterval(f, 10000); return () => clearInterval(iv);
|
||||
}, [symbol, result]);
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden">
|
||||
<div className="px-3 py-2 border-b border-slate-100 flex items-center justify-between flex-wrap gap-1">
|
||||
<h3 className="font-semibold text-slate-800 text-xs">历史交易</h3>
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{(["all","BTC","ETH","XRP","SOL"] as FilterSymbol[]).map(s => (
|
||||
<button key={s} onClick={() => setSymbol(s)} className={`px-2 py-0.5 rounded text-[10px] ${symbol===s?"bg-slate-800 text-white":"text-slate-500 hover:bg-slate-100"}`}>{s==="all"?"全部":s}</button>
|
||||
))}
|
||||
<span className="text-slate-300">|</span>
|
||||
{(["all","win","loss"] as FilterResult[]).map(r => (
|
||||
<button key={r} onClick={() => setResult(r)} className={`px-2 py-0.5 rounded text-[10px] ${result===r?"bg-slate-800 text-white":"text-slate-500 hover:bg-slate-100"}`}>{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">
|
||||
{trades.map((t: any) => {
|
||||
const holdMin = t.exit_ts&&t.entry_ts?Math.round((t.exit_ts-t.entry_ts)/60000):0;
|
||||
const fc = parseFactors(t.score_factors);
|
||||
const track = fc?.track||(t.symbol==="BTCUSDT"?"BTC":"ALT");
|
||||
const fmtTime = (ms: number) => ms ? new Date(ms).toLocaleString("zh-CN", {hour12:false, month:"2-digit", day:"2-digit", hour:"2-digit", minute:"2-digit"} as any) : "-";
|
||||
return (
|
||||
<tr key={t.id} className="hover:bg-slate-50">
|
||||
<td className="px-2 py-1.5 font-mono">{t.symbol?.replace("USDT","")}<span className={`ml-1 text-[9px] px-1 rounded ${track==="BTC"?"bg-amber-100 text-amber-700":"bg-purple-100 text-purple-700"}`}>{track}</span></td>
|
||||
<td className={`px-2 py-1.5 font-bold ${t.direction==="LONG"?"text-emerald-600":"text-red-500"}`}>{t.direction==="LONG"?"🟢":"🔴"} {t.direction}</td>
|
||||
<td className="px-2 py-1.5 text-right font-mono">{fmtPrice(t.entry_price)}</td>
|
||||
<td className="px-2 py-1.5 text-right font-mono">{t.exit_price?fmtPrice(t.exit_price):"-"}</td>
|
||||
<td className={`px-2 py-1.5 text-right font-mono font-bold ${t.pnl_r>0?"text-emerald-600":t.pnl_r<0?"text-red-500":"text-slate-500"}`}>{t.pnl_r>0?"+":""}{t.pnl_r?.toFixed(2)}</td>
|
||||
<td className="px-2 py-1.5 text-center"><span className={`px-1 py-0.5 rounded text-[9px] ${t.status==="tp"?"bg-emerald-100 text-emerald-700":t.status==="sl"?"bg-red-100 text-red-700":t.status==="sl_be"?"bg-amber-100 text-amber-700":t.status==="signal_flip"?"bg-purple-100 text-purple-700":"bg-slate-100 text-slate-600"}`}>{t.status==="tp"?"止盈":t.status==="sl"?"止损":t.status==="sl_be"?"保本":t.status==="timeout"?"超时":t.status==="signal_flip"?"翻转":t.status}</span></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() {
|
||||
const [data, setData] = useState<any>(null);
|
||||
const [tab, setTab] = useState("ALL");
|
||||
useEffect(() => {
|
||||
const f = async () => { try { const r = await authFetch(`/api/paper/stats?strategy=${STRATEGY}`); if (r.ok) setData(await r.json()); } catch {} };
|
||||
f(); const iv = setInterval(f, 30000); return () => clearInterval(iv);
|
||||
}, []);
|
||||
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 PaperTradingV53Page() {
|
||||
const { isLoggedIn, loading } = useAuth();
|
||||
if (loading) return <div className="text-center text-slate-400 py-8">加载中...</div>;
|
||||
if (!isLoggedIn) return (
|
||||
<div className="flex flex-col items-center justify-center h-64 gap-4">
|
||||
<div className="text-5xl">🔒</div>
|
||||
<p className="text-slate-600 font-medium">请先登录查看模拟盘</p>
|
||||
<Link href="/login" className="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm">登录</Link>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Fast 实验版标识条 */}
|
||||
<div className="rounded-lg bg-gradient-to-r from-orange-500 to-amber-400 px-3 py-1.5 flex items-center justify-between">
|
||||
<span className="text-white text-xs font-bold">🚀 V5.3 Fast — 实验变体 A/B对照</span>
|
||||
<div className="flex gap-2 text-white text-[10px] font-medium">
|
||||
<span className="bg-white/20 px-2 py-0.5 rounded">CVD 5m/30m</span>
|
||||
<span className="bg-white/20 px-2 py-0.5 rounded">OBI+加分</span>
|
||||
<span className="bg-white/20 px-2 py-0.5 rounded">accel独立触发</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-slate-900">🚀 模拟盘 V5.3 Fast</h1>
|
||||
<p className="text-[10px] text-slate-500">实验变体 v53_fast · BTC/ETH/XRP/SOL · CVD 5m/30m · OBI正向加分 · 与 V5.3 同起点对照</p>
|
||||
</div>
|
||||
<ControlPanel />
|
||||
<SummaryCards />
|
||||
<LatestSignals />
|
||||
<ActivePositions />
|
||||
<EquityCurve />
|
||||
<TradeHistory />
|
||||
<StatsPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
405
frontend/app/paper-v53middle/page.tsx
Normal file
405
frontend/app/paper-v53middle/page.tsx
Normal file
@ -0,0 +1,405 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { authFetch, useAuth } from "@/lib/auth";
|
||||
import { XAxis, YAxis, Tooltip, ResponsiveContainer, ReferenceLine, Area, AreaChart } from "recharts";
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
function parseFactors(raw: any) {
|
||||
if (!raw) return null;
|
||||
if (typeof raw === "string") { try { return JSON.parse(raw); } catch { return null; } }
|
||||
return raw;
|
||||
}
|
||||
|
||||
const STRATEGY = "v53_middle";
|
||||
const ALL_COINS = ["BTCUSDT", "ETHUSDT", "XRPUSDT", "SOLUSDT"];
|
||||
|
||||
// ─── 最新信号 ────────────────────────────────────────────────────
|
||||
|
||||
function LatestSignals() {
|
||||
const [signals, setSignals] = useState<Record<string, any>>({});
|
||||
useEffect(() => {
|
||||
const f = async () => {
|
||||
for (const sym of ALL_COINS) {
|
||||
const coin = sym.replace("USDT", "");
|
||||
try {
|
||||
const r = await authFetch(`/api/signals/signal-history?symbol=${coin}&limit=1&strategy=${STRATEGY}`);
|
||||
if (r.ok) { const j = await r.json(); if (j.data?.length > 0) setSignals(prev => ({ ...prev, [sym]: j.data[0] })); }
|
||||
} catch {}
|
||||
}
|
||||
};
|
||||
f(); const iv = setInterval(f, 15000); return () => clearInterval(iv);
|
||||
}, []);
|
||||
|
||||
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">
|
||||
<h3 className="font-semibold text-slate-800 text-xs">最新信号</h3>
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-semibold bg-blue-100 text-blue-700 border border-blue-200">v53</span>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-50">
|
||||
{ALL_COINS.map(sym => {
|
||||
const s = signals[sym];
|
||||
const coin = sym.replace("USDT", "");
|
||||
const ago = s?.ts ? Math.round((Date.now() - s.ts) / 60000) : null;
|
||||
const fc = s?.factors;
|
||||
const gatePassed = fc?.gate_passed ?? true;
|
||||
return (
|
||||
<div key={sym} className="px-3 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs font-bold text-slate-700 w-8">{coin}</span>
|
||||
{s?.signal ? (
|
||||
<>
|
||||
<span className={`text-xs font-bold ${s.signal === "LONG" ? "text-emerald-600" : "text-red-500"}`}>
|
||||
{s.signal === "LONG" ? "🟢" : "🔴"} {s.signal}
|
||||
</span>
|
||||
<span className="font-mono text-xs font-bold text-slate-800">{s.score}分</span>
|
||||
</>
|
||||
) : <span className="text-[10px] text-slate-400">暂无信号</span>}
|
||||
</div>
|
||||
{ago !== null && <span className="text-[10px] text-slate-400">{ago < 60 ? `${ago}m前` : `${Math.round(ago/60)}h前`}</span>}
|
||||
</div>
|
||||
{fc && (
|
||||
<div className="flex gap-1 mt-1 flex-wrap">
|
||||
<span className={`text-[9px] px-1 py-0.5 rounded ${gatePassed ? "bg-emerald-100 text-emerald-700" : "bg-red-100 text-red-600"}`}>
|
||||
{gatePassed ? "✅" : "❌"} {fc.gate_block || "Gate"}
|
||||
</span>
|
||||
<span className="text-[9px] px-1 py-0.5 rounded bg-blue-50 text-blue-700">方向{fc.direction?.score ?? 0}/55</span>
|
||||
<span className="text-[9px] px-1 py-0.5 rounded bg-violet-50 text-violet-700">拥挤{fc.crowding?.score ?? 0}/25</span>
|
||||
<span className="text-[9px] px-1 py-0.5 rounded bg-emerald-50 text-emerald-700">环境{fc.environment?.score ?? 0}/15</span>
|
||||
<span className="text-[9px] px-1 py-0.5 rounded bg-slate-100 text-slate-600">辅助{fc.auxiliary?.score ?? 0}/5</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 控制面板 ────────────────────────────────────────────────────
|
||||
|
||||
function ControlPanel() {
|
||||
const [config, setConfig] = useState<any>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
useEffect(() => {
|
||||
(async () => { try { const r = await authFetch("/api/paper/config"); if (r.ok) setConfig(await r.json()); } catch {} })();
|
||||
}, []);
|
||||
const toggle = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const r = await authFetch("/api/paper/config", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ enabled: !config.enabled }) });
|
||||
if (r.ok) setConfig(await r.json().then((j: any) => j.config));
|
||||
} catch {} finally { setSaving(false); }
|
||||
};
|
||||
if (!config) return null;
|
||||
return (
|
||||
<div className={`rounded-xl border-2 ${config.enabled ? "border-emerald-400 bg-emerald-50" : "border-slate-200 bg-white"} px-3 py-2 flex items-center justify-between`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={toggle} disabled={saving}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${config.enabled ? "bg-red-500 text-white hover:bg-red-600" : "bg-emerald-500 text-white hover:bg-emerald-600"}`}>
|
||||
{saving ? "..." : config.enabled ? "⏹ 停止" : "▶️ 启动"}
|
||||
</button>
|
||||
<span className={`text-xs font-medium ${config.enabled ? "text-emerald-700" : "text-slate-500"}`}>{config.enabled ? "🟢 运行中" : "⚪ 已停止"}</span>
|
||||
</div>
|
||||
<div className="flex gap-4 text-[10px] text-slate-500">
|
||||
<span>初始: ${config.initial_balance?.toLocaleString()}</span>
|
||||
<span>风险: {(config.risk_per_trade * 100).toFixed(0)}%</span>
|
||||
<span>最大: {config.max_positions}仓</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 总览 ────────────────────────────────────────────────────────
|
||||
|
||||
function SummaryCards() {
|
||||
const [data, setData] = useState<any>(null);
|
||||
useEffect(() => {
|
||||
const f = async () => { try { const r = await authFetch(`/api/paper/summary?strategy=${STRATEGY}`); if (r.ok) setData(await r.json()); } catch {} };
|
||||
f(); const iv = setInterval(f, 10000); return () => clearInterval(iv);
|
||||
}, []);
|
||||
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() {
|
||||
const [positions, setPositions] = useState<any[]>([]);
|
||||
const [wsPrices, setWsPrices] = useState<Record<string, number>>({});
|
||||
const [paperRiskUsd, setPaperRiskUsd] = useState(200);
|
||||
useEffect(() => {
|
||||
(async () => { try { const r = await authFetch("/api/paper/config"); if (r.ok) { const cfg = await r.json(); setPaperRiskUsd((cfg.initial_balance||10000)*(cfg.risk_per_trade||0.02)); } } catch {} })();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const f = async () => { try { const r = await authFetch(`/api/paper/positions?strategy=${STRATEGY}`); if (r.ok) setPositions((await r.json()).data||[]); } catch {} };
|
||||
f(); const iv = setInterval(f, 10000); return () => clearInterval(iv);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const streams = ["btcusdt","ethusdt","xrpusdt","solusdt"].map(s=>`${s}@aggTrade`).join("/");
|
||||
const ws = new WebSocket(`wss://fstream.binance.com/stream?streams=${streams}`);
|
||||
ws.onmessage = (e) => { try { 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">v53 暂无活跃持仓</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">
|
||||
{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*paperRiskUsd;
|
||||
const fc = parseFactors(p.score_factors);
|
||||
const track = fc?.track||(p.symbol==="BTCUSDT"?"BTC":"ALT");
|
||||
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={`px-1.5 py-0.5 rounded text-[10px] font-semibold ${track==="BTC"?"bg-amber-100 text-amber-700":"bg-purple-100 text-purple-700"}`}>{track}</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] 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 ? new Date(p.entry_ts).toLocaleString("zh-CN", {hour12:false, month:"2-digit", day:"2-digit", hour:"2-digit", minute:"2-digit", second:"2-digit"} as any) : "-"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 权益曲线 ────────────────────────────────────────────────────
|
||||
|
||||
function EquityCurve() {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
useEffect(() => {
|
||||
const f = async () => { try { const r = await authFetch(`/api/paper/equity-curve?strategy=${STRATEGY}`); if (r.ok) setData((await r.json()).data||[]); } catch {} };
|
||||
f(); const iv = setInterval(f, 30000); return () => clearInterval(iv);
|
||||
}, []);
|
||||
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(v)} tick={{ fontSize: 10 }} />
|
||||
<YAxis tick={{ fontSize: 10 }} tickFormatter={(v) => `${v}R`} />
|
||||
<Tooltip labelFormatter={(v) => bjt(Number(v))} formatter={(v: any) => [`${v}R`, "累计PnL"]} />
|
||||
<ReferenceLine y={0} stroke="#94a3b8" strokeDasharray="3 3" />
|
||||
<Area type="monotone" dataKey="pnl" stroke="#10b981" fill="#d1fae5" strokeWidth={2} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 历史交易 ────────────────────────────────────────────────────
|
||||
|
||||
type FilterSymbol = "all" | "BTC" | "ETH" | "XRP" | "SOL";
|
||||
type FilterResult = "all" | "win" | "loss";
|
||||
|
||||
function TradeHistory() {
|
||||
const [trades, setTrades] = useState<any[]>([]);
|
||||
const [symbol, setSymbol] = useState<FilterSymbol>("all");
|
||||
const [result, setResult] = useState<FilterResult>("all");
|
||||
useEffect(() => {
|
||||
const f = async () => { try { const r = await authFetch(`/api/paper/trades?symbol=${symbol}&result=${result}&strategy=${STRATEGY}&limit=50`); if (r.ok) setTrades((await r.json()).data||[]); } catch {} };
|
||||
f(); const iv = setInterval(f, 10000); return () => clearInterval(iv);
|
||||
}, [symbol, result]);
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden">
|
||||
<div className="px-3 py-2 border-b border-slate-100 flex items-center justify-between flex-wrap gap-1">
|
||||
<h3 className="font-semibold text-slate-800 text-xs">历史交易</h3>
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{(["all","BTC","ETH","XRP","SOL"] as FilterSymbol[]).map(s => (
|
||||
<button key={s} onClick={() => setSymbol(s)} className={`px-2 py-0.5 rounded text-[10px] ${symbol===s?"bg-slate-800 text-white":"text-slate-500 hover:bg-slate-100"}`}>{s==="all"?"全部":s}</button>
|
||||
))}
|
||||
<span className="text-slate-300">|</span>
|
||||
{(["all","win","loss"] as FilterResult[]).map(r => (
|
||||
<button key={r} onClick={() => setResult(r)} className={`px-2 py-0.5 rounded text-[10px] ${result===r?"bg-slate-800 text-white":"text-slate-500 hover:bg-slate-100"}`}>{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">
|
||||
{trades.map((t: any) => {
|
||||
const holdMin = t.exit_ts&&t.entry_ts?Math.round((t.exit_ts-t.entry_ts)/60000):0;
|
||||
const fc = parseFactors(t.score_factors);
|
||||
const track = fc?.track||(t.symbol==="BTCUSDT"?"BTC":"ALT");
|
||||
const fmtTime = (ms: number) => ms ? new Date(ms).toLocaleString("zh-CN", {hour12:false, month:"2-digit", day:"2-digit", hour:"2-digit", minute:"2-digit"} as any) : "-";
|
||||
return (
|
||||
<tr key={t.id} className="hover:bg-slate-50">
|
||||
<td className="px-2 py-1.5 font-mono">{t.symbol?.replace("USDT","")}<span className={`ml-1 text-[9px] px-1 rounded ${track==="BTC"?"bg-amber-100 text-amber-700":"bg-purple-100 text-purple-700"}`}>{track}</span></td>
|
||||
<td className={`px-2 py-1.5 font-bold ${t.direction==="LONG"?"text-emerald-600":"text-red-500"}`}>{t.direction==="LONG"?"🟢":"🔴"} {t.direction}</td>
|
||||
<td className="px-2 py-1.5 text-right font-mono">{fmtPrice(t.entry_price)}</td>
|
||||
<td className="px-2 py-1.5 text-right font-mono">{t.exit_price?fmtPrice(t.exit_price):"-"}</td>
|
||||
<td className={`px-2 py-1.5 text-right font-mono font-bold ${t.pnl_r>0?"text-emerald-600":t.pnl_r<0?"text-red-500":"text-slate-500"}`}>{t.pnl_r>0?"+":""}{t.pnl_r?.toFixed(2)}</td>
|
||||
<td className="px-2 py-1.5 text-center"><span className={`px-1 py-0.5 rounded text-[9px] ${t.status==="tp"?"bg-emerald-100 text-emerald-700":t.status==="sl"?"bg-red-100 text-red-700":t.status==="sl_be"?"bg-amber-100 text-amber-700":t.status==="signal_flip"?"bg-purple-100 text-purple-700":"bg-slate-100 text-slate-600"}`}>{t.status==="tp"?"止盈":t.status==="sl"?"止损":t.status==="sl_be"?"保本":t.status==="timeout"?"超时":t.status==="signal_flip"?"翻转":t.status}</span></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() {
|
||||
const [data, setData] = useState<any>(null);
|
||||
const [tab, setTab] = useState("ALL");
|
||||
useEffect(() => {
|
||||
const f = async () => { try { const r = await authFetch(`/api/paper/stats?strategy=${STRATEGY}`); if (r.ok) setData(await r.json()); } catch {} };
|
||||
f(); const iv = setInterval(f, 30000); return () => clearInterval(iv);
|
||||
}, []);
|
||||
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 PaperTradingV53Page() {
|
||||
const { isLoggedIn, loading } = useAuth();
|
||||
if (loading) return <div className="text-center text-slate-400 py-8">加载中...</div>;
|
||||
if (!isLoggedIn) return (
|
||||
<div className="flex flex-col items-center justify-center h-64 gap-4">
|
||||
<div className="text-5xl">🔒</div>
|
||||
<p className="text-slate-600 font-medium">请先登录查看模拟盘</p>
|
||||
<Link href="/login" className="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm">登录</Link>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Fast 实验版标识条 */}
|
||||
<div className="rounded-lg bg-gradient-to-r from-orange-500 to-amber-400 px-3 py-1.5 flex items-center justify-between">
|
||||
<span className="text-white text-xs font-bold">🚀 V5.3 Middle — 实验变体 A/B对照</span>
|
||||
<div className="flex gap-2 text-white text-[10px] font-medium">
|
||||
<span className="bg-white/20 px-2 py-0.5 rounded">CVD 5m/30m</span>
|
||||
<span className="bg-white/20 px-2 py-0.5 rounded">OBI+加分</span>
|
||||
<span className="bg-white/20 px-2 py-0.5 rounded">accel独立触发</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-slate-900">🚀 模拟盘 V5.3 Middle</h1>
|
||||
<p className="text-[10px] text-slate-500">实验变体 v53_middle · BTC/ETH/XRP/SOL · CVD 5m/30m · OBI正向加分 · 与 V5.3 同起点对照</p>
|
||||
</div>
|
||||
<ControlPanel />
|
||||
<SummaryCards />
|
||||
<LatestSignals />
|
||||
<ActivePositions />
|
||||
<EquityCurve />
|
||||
<TradeHistory />
|
||||
<StatsPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -2,7 +2,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { authFetch, useAuth } from "@/lib/auth";
|
||||
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, ReferenceLine, Area, AreaChart } from "recharts";
|
||||
import { XAxis, YAxis, Tooltip, ResponsiveContainer, ReferenceLine, Area, AreaChart } from "recharts";
|
||||
|
||||
// ─── 工具函数 ────────────────────────────────────────────────────
|
||||
|
||||
@ -15,6 +15,8 @@ function fmtPrice(p: number) {
|
||||
return p < 100 ? p.toFixed(4) : p.toLocaleString("en-US", { minimumFractionDigits: 1, maximumFractionDigits: 1 });
|
||||
}
|
||||
|
||||
const PAPER_STRATEGY = "v51_baseline";
|
||||
|
||||
// ─── 控制面板(开关+配置)──────────────────────────────────────
|
||||
|
||||
function ControlPanel() {
|
||||
@ -69,7 +71,7 @@ function ControlPanel() {
|
||||
function SummaryCards() {
|
||||
const [data, setData] = useState<any>(null);
|
||||
useEffect(() => {
|
||||
const f = async () => { try { const r = await authFetch("/api/paper/summary"); if (r.ok) setData(await r.json()); } catch {} };
|
||||
const f = async () => { try { const r = await authFetch(`/api/paper/summary?strategy=${PAPER_STRATEGY}`); if (r.ok) setData(await r.json()); } catch {} };
|
||||
f(); const iv = setInterval(f, 10000); return () => clearInterval(iv);
|
||||
}, []);
|
||||
if (!data) return <div className="text-center text-slate-400 text-sm py-4">加载中...</div>;
|
||||
@ -118,7 +120,7 @@ function LatestSignals() {
|
||||
const f = async () => {
|
||||
for (const sym of COINS) {
|
||||
try {
|
||||
const r = await authFetch(`/api/signals/signal-history?symbol=${sym.replace("USDT","")}&limit=1`);
|
||||
const r = await authFetch(`/api/signals/signal-history?symbol=${sym.replace("USDT","")}&limit=1&strategy=v51_baseline`);
|
||||
if (r.ok) {
|
||||
const j = await r.json();
|
||||
if (j.data && j.data.length > 0) {
|
||||
@ -141,8 +143,10 @@ function LatestSignals() {
|
||||
const s = signals[sym];
|
||||
const coin = sym.replace("USDT", "");
|
||||
const ago = s?.ts ? Math.round((Date.now() - s.ts) / 60000) : null;
|
||||
const fc = s?.factors;
|
||||
return (
|
||||
<div key={sym} className="px-3 py-1.5 flex items-center justify-between">
|
||||
<div key={sym} className="px-3 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs font-bold text-slate-700 w-8">{coin}</span>
|
||||
{s?.signal ? (
|
||||
@ -150,14 +154,25 @@ function LatestSignals() {
|
||||
<span className={`text-xs font-bold ${s.signal === "LONG" ? "text-emerald-600" : "text-red-500"}`}>
|
||||
{s.signal === "LONG" ? "🟢" : "🔴"} {s.signal}
|
||||
</span>
|
||||
<span className="font-mono text-[10px] text-slate-500">{s.score}分</span>
|
||||
<span className="font-mono text-xs font-bold text-slate-800">{s.score}分</span>
|
||||
<span className="text-[9px] px-1.5 py-0.5 rounded bg-emerald-50 text-emerald-700 font-medium">{s.score >= 85 ? "加仓" : "标准"}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-[10px] text-slate-400">⚪ 无信号</span>
|
||||
<span className="text-[10px] text-slate-400">暂无信号</span>
|
||||
)}
|
||||
</div>
|
||||
{ago !== null && <span className="text-[10px] text-slate-400">{ago < 60 ? `${ago}m前` : `${Math.round(ago/60)}h前`}</span>}
|
||||
</div>
|
||||
{fc && (
|
||||
<div className="flex gap-1 mt-1 flex-wrap">
|
||||
<span className="text-[9px] px-1 py-0.5 rounded bg-blue-50 text-blue-700">方向{fc.direction?.score ?? 0}/{fc.direction?.max ?? 45}</span>
|
||||
<span className="text-[9px] px-1 py-0.5 rounded bg-violet-50 text-violet-700">拥挤{fc.crowding?.score ?? 0}/{fc.crowding?.max ?? 20}</span>
|
||||
<span className="text-[9px] px-1 py-0.5 rounded bg-emerald-50 text-emerald-700">环境{fc.environment?.score ?? 0}/{fc.environment?.max ?? 15}</span>
|
||||
<span className="text-[9px] px-1 py-0.5 rounded bg-amber-50 text-amber-700">确认{fc.confirmation?.score ?? 0}/{fc.confirmation?.max ?? 15}</span>
|
||||
<span className="text-[9px] px-1 py-0.5 rounded bg-slate-100 text-slate-600">辅助{fc.auxiliary?.score ?? 0}/{fc.auxiliary?.max ?? 5}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@ -168,12 +183,18 @@ function LatestSignals() {
|
||||
// ─── 当前持仓 ────────────────────────────────────────────────────
|
||||
|
||||
function ActivePositions() {
|
||||
const [paperRiskUsd, setPaperRiskUsd] = useState(200);
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try { const r = await authFetch("/api/paper/config"); if (r.ok) { const cfg = await r.json(); setPaperRiskUsd((cfg.initial_balance || 10000) * (cfg.risk_per_trade || 0.02)); } } catch {}
|
||||
})();
|
||||
}, []);
|
||||
const [positions, setPositions] = useState<any[]>([]);
|
||||
const [wsPrices, setWsPrices] = useState<Record<string, number>>({});
|
||||
|
||||
// 从API获取持仓列表(10秒刷新)
|
||||
useEffect(() => {
|
||||
const f = async () => { try { const r = await authFetch("/api/paper/positions"); if (r.ok) { const j = await r.json(); setPositions(j.data || []); } } catch {} };
|
||||
const f = async () => { try { const r = await authFetch(`/api/paper/positions?strategy=${PAPER_STRATEGY}`); if (r.ok) { const j = await r.json(); setPositions(j.data || []); } } catch {} };
|
||||
f(); const iv = setInterval(f, 10000); return () => clearInterval(iv);
|
||||
}, []);
|
||||
|
||||
@ -211,10 +232,12 @@ function ActivePositions() {
|
||||
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 atr = p.atr_at_entry || 1;
|
||||
const riskDist = 2.0 * 0.7 * atr;
|
||||
const unrealR = riskDist > 0 ? (p.direction === "LONG" ? (currentPrice - entry) / riskDist : (entry - currentPrice) / riskDist) : 0;
|
||||
const unrealUsdt = unrealR * 200;
|
||||
const riskDist = p.risk_distance || Math.abs(entry - (p.sl_price || entry)) || 1;
|
||||
// TP1触发后只剩半仓:0.5×TP1锁定 + 0.5×当前浮盈
|
||||
const fullR = riskDist > 0 ? (p.direction === "LONG" ? (currentPrice - entry) / riskDist : (entry - currentPrice) / riskDist) : 0;
|
||||
const tp1R = riskDist > 0 ? (p.direction === "LONG" ? ((p.tp1_price || 0) - entry) / riskDist : (entry - (p.tp1_price || 0)) / riskDist) : 0;
|
||||
const unrealR = p.tp1_hit ? 0.5 * tp1R + 0.5 * fullR : fullR;
|
||||
const unrealUsdt = unrealR * paperRiskUsd;
|
||||
return (
|
||||
<div key={p.id} className="px-3 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
@ -234,8 +257,9 @@ function ActivePositions() {
|
||||
<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">
|
||||
<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-slate-400">{new Date(p.entry_ts).toLocaleString("zh-CN", {hour12:false, year:undefined, month:"2-digit", day:"2-digit", hour:"2-digit", minute:"2-digit", second:"2-digit", fractionalSecondDigits:3} as any)}</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>
|
||||
@ -254,7 +278,7 @@ function ActivePositions() {
|
||||
function EquityCurve() {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
useEffect(() => {
|
||||
const f = async () => { try { const r = await authFetch("/api/paper/equity-curve"); if (r.ok) { const j = await r.json(); setData(j.data || []); } } catch {} };
|
||||
const f = async () => { try { const r = await authFetch(`/api/paper/equity-curve?strategy=${PAPER_STRATEGY}`); if (r.ok) { const j = await r.json(); setData(j.data || []); } } catch {} };
|
||||
f(); const iv = setInterval(f, 30000); return () => clearInterval(iv);
|
||||
}, []);
|
||||
|
||||
@ -293,7 +317,7 @@ function TradeHistory() {
|
||||
useEffect(() => {
|
||||
const f = async () => {
|
||||
try {
|
||||
const r = await authFetch(`/api/paper/trades?symbol=${symbol}&result=${result}&limit=50`);
|
||||
const r = await authFetch(`/api/paper/trades?symbol=${symbol}&result=${result}&strategy=${PAPER_STRATEGY}&limit=50`);
|
||||
if (r.ok) { const j = await r.json(); setTrades(j.data || []); }
|
||||
} catch {}
|
||||
};
|
||||
@ -381,7 +405,7 @@ function StatsPanel() {
|
||||
const [data, setData] = useState<any>(null);
|
||||
const [tab, setTab] = useState("ALL");
|
||||
useEffect(() => {
|
||||
const f = async () => { try { const r = await authFetch("/api/paper/stats"); if (r.ok) setData(await r.json()); } catch {} };
|
||||
const f = async () => { try { const r = await authFetch(`/api/paper/stats?strategy=${PAPER_STRATEGY}`); if (r.ok) setData(await r.json()); } catch {} };
|
||||
f(); const iv = setInterval(f, 30000); return () => clearInterval(iv);
|
||||
}, []);
|
||||
|
||||
@ -443,8 +467,8 @@ export default function PaperTradingPage() {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-slate-900">📊 模拟盘</h1>
|
||||
<p className="text-[10px] text-slate-500">V5.1信号引擎自动交易 · 实时追踪 · 数据驱动优化</p>
|
||||
<h1 className="text-lg font-bold text-slate-900">📈 V5.1 模拟盘</h1>
|
||||
<p className="text-[10px] text-slate-500">仅展示 v51_baseline · V5.1信号引擎自动交易 · 实时追踪</p>
|
||||
</div>
|
||||
|
||||
<ControlPanel />
|
||||
|
||||
527
frontend/app/signals-v52/page.tsx
Normal file
527
frontend/app/signals-v52/page.tsx
Normal file
@ -0,0 +1,527 @@
|
||||
"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;
|
||||
signal: string | null;
|
||||
tier?: "light" | "standard" | "heavy" | null;
|
||||
factors?: {
|
||||
direction?: { score?: number; max?: number };
|
||||
crowding?: { score?: number; max?: number };
|
||||
environment?: { score?: number; max?: number };
|
||||
confirmation?: { score?: number; max?: number };
|
||||
auxiliary?: { score?: number; max?: number };
|
||||
funding_rate?: { score?: number; max?: number; value?: number };
|
||||
liquidation?: { score?: number; max?: number; long_usd?: number; short_usd?: number };
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface MarketIndicatorValue {
|
||||
value: Record<string, unknown>;
|
||||
ts: number;
|
||||
}
|
||||
|
||||
interface MarketIndicatorSet {
|
||||
long_short_ratio?: MarketIndicatorValue;
|
||||
top_trader_position?: MarketIndicatorValue;
|
||||
open_interest_hist?: MarketIndicatorValue;
|
||||
coinbase_premium?: MarketIndicatorValue;
|
||||
}
|
||||
|
||||
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 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>
|
||||
);
|
||||
}
|
||||
|
||||
function MarketIndicatorsCards({ symbol }: { symbol: Symbol }) {
|
||||
const [data, setData] = useState<MarketIndicatorSet | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetch = async () => {
|
||||
try {
|
||||
const res = await authFetch("/api/signals/market-indicators");
|
||||
if (!res.ok) return;
|
||||
const json = await res.json();
|
||||
setData(json[symbol] || null);
|
||||
} catch {}
|
||||
};
|
||||
fetch();
|
||||
const iv = setInterval(fetch, 5000);
|
||||
return () => clearInterval(iv);
|
||||
}, [symbol]);
|
||||
|
||||
if (!data) return <div className="text-center text-slate-400 text-sm py-3">等待市场指标数据...</div>;
|
||||
|
||||
// value可能是JSON字符串或对象,统一解析
|
||||
const parseVal = (v: unknown): Record<string, unknown> => {
|
||||
if (!v) return {};
|
||||
if (typeof v === "string") { try { return JSON.parse(v); } catch { return {}; } }
|
||||
if (typeof v === "object") return v as Record<string, unknown>;
|
||||
return {};
|
||||
};
|
||||
|
||||
const lsVal = parseVal(data.long_short_ratio?.value);
|
||||
const topVal = parseVal(data.top_trader_position?.value);
|
||||
const oiVal = parseVal(data.open_interest_hist?.value);
|
||||
const premVal = parseVal(data.coinbase_premium?.value);
|
||||
|
||||
const longPct = Number(lsVal?.longAccount ?? 0.5) * 100;
|
||||
const shortPct = Number(lsVal?.shortAccount ?? 0.5) * 100;
|
||||
const topLong = Number(topVal?.longAccount ?? 0.5) * 100;
|
||||
const topShort = Number(topVal?.shortAccount ?? 0.5) * 100;
|
||||
const oiValue = Number(oiVal?.sumOpenInterestValue ?? 0);
|
||||
const oiDisplay = oiValue >= 1e9 ? `$${(oiValue / 1e9).toFixed(2)}B` : oiValue >= 1e6 ? `$${(oiValue / 1e6).toFixed(0)}M` : `$${oiValue.toFixed(0)}`;
|
||||
const premium = Number(premVal?.premium_pct ?? 0);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
<div className="bg-slate-50 rounded-lg px-2 py-1.5">
|
||||
<p className="text-[10px] text-slate-400">多空比</p>
|
||||
<p className="text-xs font-mono text-slate-800">L:{longPct.toFixed(1)}% S:{shortPct.toFixed(1)}%</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg px-2 py-1.5">
|
||||
<p className="text-[10px] text-slate-400">大户持仓</p>
|
||||
<p className="text-xs font-mono text-slate-800">多{topLong.toFixed(1)}% {topLong >= 55 ? "📈" : topLong <= 45 ? "📉" : "➖"}</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg px-2 py-1.5">
|
||||
<p className="text-[10px] text-slate-400">OI</p>
|
||||
<p className="text-xs font-mono text-slate-800">{oiDisplay}</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg px-2 py-1.5">
|
||||
<p className="text-[10px] text-slate-400">CB Premium</p>
|
||||
<p className={`text-xs font-mono ${premium >= 0 ? "text-emerald-600" : "text-red-500"}`}>{premium >= 0 ? "+" : ""}{premium.toFixed(4)}%</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 信号历史 ────────────────────────────────────────────────────
|
||||
|
||||
interface SignalRecord {
|
||||
ts: number;
|
||||
score: number;
|
||||
signal: string;
|
||||
}
|
||||
|
||||
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 SignalHistory({ symbol }: { symbol: Symbol }) {
|
||||
const [data, setData] = useState<SignalRecord[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await authFetch(`/api/signals/signal-history?symbol=${symbol}&limit=20&strategy=v52_8signals`);
|
||||
if (!res.ok) return;
|
||||
const json = await res.json();
|
||||
setData(json.data || []);
|
||||
} catch {}
|
||||
};
|
||||
fetchData();
|
||||
const iv = setInterval(fetchData, 15000);
|
||||
return () => clearInterval(iv);
|
||||
}, [symbol]);
|
||||
|
||||
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>
|
||||
<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 IndicatorCards({ symbol }: { symbol: Symbol }) {
|
||||
const [data, setData] = useState<LatestIndicator | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetch = async () => {
|
||||
try {
|
||||
const res = await authFetch("/api/signals/latest?strategy=v52_8signals");
|
||||
if (!res.ok) return;
|
||||
const json = await res.json();
|
||||
setData(json[symbol] || null);
|
||||
} catch {}
|
||||
};
|
||||
fetch();
|
||||
const iv = setInterval(fetch, 5000);
|
||||
return () => clearInterval(iv);
|
||||
}, [symbol]);
|
||||
|
||||
if (!data) return <div className="text-center text-slate-400 text-sm py-4">等待指标数据...</div>;
|
||||
|
||||
const cvdMidDir = data.cvd_mid > 0 ? "多" : "空";
|
||||
const priceVsVwap = data.price > data.vwap_30m ? "上方" : "下方";
|
||||
|
||||
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 (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">{cvdMidDir}头占优</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_day</p>
|
||||
<p className={`font-mono font-bold text-sm ${data.cvd_day >= 0 ? "text-emerald-600" : "text-red-500"}`}>
|
||||
{fmt(data.cvd_day)}
|
||||
</p>
|
||||
<p className="text-[10px] text-slate-400">盘中基线</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ATR + VWAP + 大单 - 4列紧凑 */}
|
||||
<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>
|
||||
|
||||
{/* 信号状态(V5.2)- 七层 */}
|
||||
<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">当前信号</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}/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={data.factors?.direction?.max ?? 40} colorClass="bg-blue-600" />
|
||||
<LayerScore label="拥挤" score={data.factors?.crowding?.score ?? 0} max={data.factors?.crowding?.max ?? 18} colorClass="bg-violet-600" />
|
||||
<LayerScore label="FR" score={data.factors?.funding_rate?.score ?? 0} max={data.factors?.funding_rate?.max ?? 5} colorClass="bg-cyan-600" />
|
||||
<LayerScore label="环境" score={data.factors?.environment?.score ?? 0} max={data.factors?.environment?.max ?? 12} colorClass="bg-emerald-600" />
|
||||
<LayerScore label="确认" score={data.factors?.confirmation?.score ?? 0} max={data.factors?.confirmation?.max ?? 15} colorClass="bg-amber-500" />
|
||||
<LayerScore label="清算" score={data.factors?.liquidation?.score ?? 0} max={data.factors?.liquidation?.max ?? 5} colorClass="bg-orange-500" />
|
||||
<LayerScore label="辅助" score={data.factors?.auxiliary?.score ?? 0} max={data.factors?.auxiliary?.max ?? 5} colorClass="bg-slate-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── CVD三轨图 ──────────────────────────────────────────────────
|
||||
|
||||
function CVDChart({ symbol, minutes }: { symbol: Symbol; minutes: number }) {
|
||||
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}`);
|
||||
if (!res.ok) return;
|
||||
const json = await res.json();
|
||||
setData(json.data || []);
|
||||
if (!silent) setLoading(false);
|
||||
} catch {}
|
||||
}, [symbol, minutes]);
|
||||
|
||||
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">暂无指标数据,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
|
||||
// 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(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 SignalsV52Page() {
|
||||
const { isLoggedIn, loading } = useAuth();
|
||||
const [symbol, setSymbol] = useState<Symbol>("BTC");
|
||||
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.2</h1>
|
||||
<p className="text-slate-500 text-[10px]">七层信号评分 · 包含 Funding Rate / Liquidation 维度</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 ? "bg-blue-600 text-white border-blue-600" : "border-slate-200 text-slate-600 hover:border-blue-400"}`}>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 实时指标卡片 */}
|
||||
<IndicatorCards symbol={symbol} />
|
||||
|
||||
{/* Market Indicators */}
|
||||
<div className="rounded-xl border border-slate-200 bg-white px-3 py-2">
|
||||
<h3 className="font-semibold text-slate-800 text-xs mb-1.5">Market Indicators</h3>
|
||||
<MarketIndicatorsCards symbol={symbol} />
|
||||
</div>
|
||||
|
||||
{/* 信号历史 */}
|
||||
<SignalHistory symbol={symbol} />
|
||||
|
||||
{/* CVD三轨图 */}
|
||||
<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} />
|
||||
</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.2)</h3>
|
||||
</div>
|
||||
<div className="px-3 py-2 space-y-2 text-[11px] text-slate-600">
|
||||
<div>
|
||||
<span className="font-bold text-slate-800">1️⃣ 方向层(40分)</span>
|
||||
<span className="text-slate-500"> — 钱往哪流?</span>
|
||||
<p className="mt-0.5">CVD三轨(30m/4h资金流向)+ P99大单流(鲸鱼动向)+ 加速度奖励。两条CVD同向+大单配合 = 高分。</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-bold text-slate-800">2️⃣ 拥挤层(20分)</span>
|
||||
<span className="text-slate-500"> — 散户在干嘛?反着来</span>
|
||||
<p className="mt-0.5">多空比(散户仓位)+ 大户持仓比。散户疯狂做多→做空加分,跟大户同向加分。</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-bold text-slate-800">3️⃣ FR层(5分)</span>
|
||||
<span className="text-slate-500"> — 费率拥挤是否极端?</span>
|
||||
<p className="mt-0.5">Funding Rate偏离越极端,反向信号权重越高;用于过滤高拥挤下的追涨杀跌。</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-bold text-slate-800">4️⃣ 环境层(15分)</span>
|
||||
<span className="text-slate-500"> — 有没有新钱进场?</span>
|
||||
<p className="mt-0.5">OI变化率(未平仓合约)。OI上涨=新资金进场=趋势延续;OI下降=资金撤离。</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-bold text-slate-800">5️⃣ 确认层(15分)</span>
|
||||
<span className="text-slate-500"> — 多周期共振吗?</span>
|
||||
<p className="mt-0.5">CVD_fast(30m)和CVD_mid(4h)方向一致=高确信度满分15;方向矛盾=0分。</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-bold text-slate-800">6️⃣ 清算层(5分)</span>
|
||||
<span className="text-slate-500"> — 清算热点是否共振?</span>
|
||||
<p className="mt-0.5">多空清算金额不对称时为反向提供弹性加权,帮助识别踩踏与挤空/挤多机会。</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-bold text-slate-800">7️⃣ 辅助层(5分)</span>
|
||||
<span className="text-slate-500"> — 美国机构在干嘛?</span>
|
||||
<p className="mt-0.5">Coinbase Premium(CB vs 币安价差)。正溢价=机构买入=做多加分;负溢价=机构卖出。</p>
|
||||
</div>
|
||||
<div className="pt-1 border-t border-slate-100">
|
||||
<span className="text-blue-600 font-medium">档位:</span><75不开仓 · 75-84标准 · ≥85加仓 · 冷却10分钟
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
565
frontend/app/signals-v53/page.tsx
Normal file
565
frontend/app/signals-v53/page.tsx
Normal file
@ -0,0 +1,565 @@
|
||||
"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; // v53_btc: alt_score_ref(参考分)
|
||||
gate_passed?: boolean; // v53_btc顶层字段
|
||||
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 };
|
||||
// BTC gate fields
|
||||
gate_passed?: boolean;
|
||||
block_reason?: string; // BTC用
|
||||
gate_block?: string; // ALT用
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── ALT Gate 状态卡片 ──────────────────────────────────────────
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── BTC Gate 状态卡片 ───────────────────────────────────────────
|
||||
|
||||
function BTCGateCard({ factors }: { factors: LatestIndicator["factors"] }) {
|
||||
if (!factors) return null;
|
||||
return (
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 mt-2">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<p className="text-[10px] font-semibold text-amber-800">⚡ BTC Gate-Control</p>
|
||||
<span className={`text-[10px] font-bold px-2 py-0.5 rounded ${factors.gate_passed ? "bg-emerald-100 text-emerald-700" : "bg-red-100 text-red-600"}`}>
|
||||
{factors.gate_passed ? "✅ Gate通过" : "❌ 否决"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
<div className="bg-white rounded px-2 py-1">
|
||||
<p className="text-[10px] text-slate-400">波动率</p>
|
||||
<p className="text-xs font-mono text-slate-800">{((factors.atr_pct_price ?? 0) * 100).toFixed(3)}%</p>
|
||||
<p className="text-[9px] text-slate-400">需 ≥0.2%</p>
|
||||
</div>
|
||||
<div className="bg-white rounded px-2 py-1">
|
||||
<p className="text-[10px] text-slate-400">OBI</p>
|
||||
<p className={`text-xs font-mono ${(factors.obi_raw ?? 0) >= 0 ? "text-emerald-600" : "text-red-500"}`}>
|
||||
{((factors.obi_raw ?? 0) * 100).toFixed(2)}%
|
||||
</p>
|
||||
<p className="text-[9px] text-slate-400">盘口失衡</p>
|
||||
</div>
|
||||
<div className="bg-white rounded px-2 py-1">
|
||||
<p className="text-[10px] text-slate-400">期现背离</p>
|
||||
<p className={`text-xs font-mono ${(factors.spot_perp_div ?? 0) >= 0 ? "text-emerald-600" : "text-red-500"}`}>
|
||||
{((factors.spot_perp_div ?? 0) * 10000).toFixed(2)}bps
|
||||
</p>
|
||||
<p className="text-[9px] text-slate-400">spot-perp</p>
|
||||
</div>
|
||||
<div className="bg-white rounded px-2 py-1">
|
||||
<p className="text-[10px] text-slate-400">巨鲸CVD</p>
|
||||
<p className={`text-xs font-mono ${(factors.whale_cvd_ratio ?? 0) >= 0 ? "text-emerald-600" : "text-red-500"}`}>
|
||||
{((factors.whale_cvd_ratio ?? 0) * 100).toFixed(2)}%
|
||||
</p>
|
||||
<p className="text-[9px] text-slate-400">>$100k</p>
|
||||
</div>
|
||||
</div>
|
||||
{factors.block_reason && (
|
||||
<p className="text-[10px] text-red-600 mt-1.5 bg-red-50 rounded px-2 py-1">
|
||||
否决原因: <span className="font-mono">{factors.block_reason}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 实时指标卡片 ────────────────────────────────────────────────
|
||||
|
||||
function IndicatorCards({ symbol }: { symbol: Symbol }) {
|
||||
const [data, setData] = useState<LatestIndicator | null>(null);
|
||||
const strategy = "v53";
|
||||
|
||||
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">
|
||||
{/* 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 (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>
|
||||
|
||||
{/* ATR + VWAP */}
|
||||
<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>
|
||||
|
||||
{/* 四层分数 — ALT和BTC都显示 */}
|
||||
<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>
|
||||
|
||||
{/* ALT Gate 卡片 */}
|
||||
{!isBTC && data.factors && <ALTGateCard symbol={symbol} factors={data.factors} />}
|
||||
|
||||
{/* BTC Gate 卡片 */}
|
||||
{isBTC && data.factors && <BTCGateCard factors={data.factors} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 信号历史 ────────────────────────────────────────────────────
|
||||
|
||||
interface SignalRecord {
|
||||
ts: number;
|
||||
score: number;
|
||||
signal: string;
|
||||
}
|
||||
|
||||
function SignalHistory({ symbol }: { symbol: Symbol }) {
|
||||
const [data, setData] = useState<SignalRecord[]>([]);
|
||||
const strategy = "v53";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── CVD图表 ────────────────────────────────────────────────────
|
||||
|
||||
function CVDChart({ symbol, minutes }: { symbol: Symbol; minutes: number }) {
|
||||
const [data, setData] = useState<IndicatorRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const strategy = "v53";
|
||||
|
||||
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
|
||||
// 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(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 SignalsV53Page() {
|
||||
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} />
|
||||
<SignalHistory symbol={symbol} />
|
||||
|
||||
<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} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm overflow-hidden">
|
||||
<div className="px-3 py-2 border-b border-slate-100">
|
||||
<h3 className="font-semibold text-slate-800 text-xs">📖 V5.3 双轨信号说明</h3>
|
||||
</div>
|
||||
<div className="px-3 py-2 space-y-2 text-[11px] text-slate-600">
|
||||
<div className="p-2 bg-purple-50 rounded-lg border border-purple-100">
|
||||
<span className="font-bold text-purple-800">🟣 ALT轨(ETH/XRP/SOL)— 四层线性评分</span>
|
||||
<div className="mt-1 space-y-1">
|
||||
<p><span className="font-semibold">1️⃣ 方向层(55分)</span> — CVD共振30分(fast+mid同向)+ P99大单对齐20分 + 加速奖励5分。删除独立确认层,解决CVD双重计分问题。</p>
|
||||
<p><span className="font-semibold">2️⃣ 拥挤层(25分)</span> — LSR反向拥挤15分(散户过度拥挤=信号)+ 大户持仓方向10分。</p>
|
||||
<p><span className="font-semibold">3️⃣ 环境层(15分)</span> — OI变化率,新资金进场vs撤离,判断趋势持续性。</p>
|
||||
<p><span className="font-semibold">4️⃣ 辅助层(5分)</span> — Coinbase Premium,美系机构动向。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 bg-amber-50 rounded-lg border border-amber-100">
|
||||
<span className="font-bold text-amber-800">🔵 BTC轨 — Gate-Control逻辑</span>
|
||||
<div className="mt-1 space-y-1">
|
||||
<p><span className="font-semibold">波动率门控</span>:ATR/Price ≥ 0.2%,低波动行情拒绝开仓</p>
|
||||
<p><span className="font-semibold">OBI否决</span>:订单簿失衡超阈值且与信号方向冲突时否决(实时100ms)</p>
|
||||
<p><span className="font-semibold">期现背离否决</span>:spot与perp价差超阈值时否决(实时1s)</p>
|
||||
<p><span className="font-semibold">巨鲸CVD</span>:>$100k成交额净CVD,15分钟滚动窗口实时计算</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-1 border-t border-slate-100">
|
||||
<span className="text-blue-600 font-medium">档位:</span><75不开仓 · 75-84标准 · ≥85加仓 · 冷却10分钟
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
580
frontend/app/signals-v53fast/page.tsx
Normal file
580
frontend/app/signals-v53fast/page.tsx
Normal file
@ -0,0 +1,580 @@
|
||||
"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; // v53_btc: alt_score_ref(参考分)
|
||||
gate_passed?: boolean; // v53_btc顶层字段
|
||||
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; accel_independent_score?: number };
|
||||
crowding?: { score?: number; max?: number; lsr_contrarian?: number; top_trader_position?: number };
|
||||
environment?: { score?: number; max?: number; obi_bonus?: number; oi_base?: number };
|
||||
auxiliary?: { score?: number; max?: number; coinbase_premium?: number };
|
||||
// BTC gate fields
|
||||
gate_passed?: boolean;
|
||||
block_reason?: string; // BTC用
|
||||
gate_block?: string; // ALT用
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── ALT Gate 状态卡片 ──────────────────────────────────────────
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── BTC Gate 状态卡片 ───────────────────────────────────────────
|
||||
|
||||
function BTCGateCard({ factors }: { factors: LatestIndicator["factors"] }) {
|
||||
if (!factors) return null;
|
||||
return (
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 mt-2">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<p className="text-[10px] font-semibold text-amber-800">⚡ BTC Gate-Control</p>
|
||||
<span className={`text-[10px] font-bold px-2 py-0.5 rounded ${factors.gate_passed ? "bg-emerald-100 text-emerald-700" : "bg-red-100 text-red-600"}`}>
|
||||
{factors.gate_passed ? "✅ Gate通过" : "❌ 否决"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
<div className="bg-white rounded px-2 py-1">
|
||||
<p className="text-[10px] text-slate-400">波动率</p>
|
||||
<p className="text-xs font-mono text-slate-800">{((factors.atr_pct_price ?? 0) * 100).toFixed(3)}%</p>
|
||||
<p className="text-[9px] text-slate-400">需 ≥0.2%</p>
|
||||
</div>
|
||||
<div className="bg-white rounded px-2 py-1">
|
||||
<p className="text-[10px] text-slate-400">OBI</p>
|
||||
<p className={`text-xs font-mono ${(factors.obi_raw ?? 0) >= 0 ? "text-emerald-600" : "text-red-500"}`}>
|
||||
{((factors.obi_raw ?? 0) * 100).toFixed(2)}%
|
||||
</p>
|
||||
<p className="text-[9px] text-slate-400">盘口失衡</p>
|
||||
</div>
|
||||
<div className="bg-white rounded px-2 py-1">
|
||||
<p className="text-[10px] text-slate-400">期现背离</p>
|
||||
<p className={`text-xs font-mono ${(factors.spot_perp_div ?? 0) >= 0 ? "text-emerald-600" : "text-red-500"}`}>
|
||||
{((factors.spot_perp_div ?? 0) * 10000).toFixed(2)}bps
|
||||
</p>
|
||||
<p className="text-[9px] text-slate-400">spot-perp</p>
|
||||
</div>
|
||||
<div className="bg-white rounded px-2 py-1">
|
||||
<p className="text-[10px] text-slate-400">巨鲸CVD</p>
|
||||
<p className={`text-xs font-mono ${(factors.whale_cvd_ratio ?? 0) >= 0 ? "text-emerald-600" : "text-red-500"}`}>
|
||||
{((factors.whale_cvd_ratio ?? 0) * 100).toFixed(2)}%
|
||||
</p>
|
||||
<p className="text-[9px] text-slate-400">>$100k</p>
|
||||
</div>
|
||||
</div>
|
||||
{factors.block_reason && (
|
||||
<p className="text-[10px] text-red-600 mt-1.5 bg-red-50 rounded px-2 py-1">
|
||||
否决原因: <span className="font-mono">{factors.block_reason}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 实时指标卡片 ────────────────────────────────────────────────
|
||||
|
||||
function IndicatorCards({ symbol }: { symbol: Symbol }) {
|
||||
const [data, setData] = useState<LatestIndicator | null>(null);
|
||||
const strategy = "v53_fast";
|
||||
|
||||
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">
|
||||
{/* 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 (5m实算★)</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 (30m实算★)</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>
|
||||
|
||||
{/* ATR + VWAP */}
|
||||
<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>
|
||||
|
||||
{/* 四层分数 — ALT和BTC都显示 */}
|
||||
<div className="mt-2 space-y-1">
|
||||
<LayerScore label="方向" score={data.factors?.direction?.score ?? 0} max={55} colorClass="bg-blue-600" />
|
||||
{data.factors?.direction?.accel_independent_score != null && data.factors.direction.accel_independent_score > 0 && (
|
||||
<p className="text-[9px] text-orange-600 pl-1">⚡ accel独立触发 +{data.factors.direction.accel_independent_score}</p>
|
||||
)}
|
||||
<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" />
|
||||
{(data.factors?.environment?.obi_bonus ?? 0) > 0 && (
|
||||
<p className="text-[9px] text-cyan-600 pl-1">📊 OBI正向 +{data.factors?.environment?.obi_bonus}</p>
|
||||
)}
|
||||
<LayerScore label="辅助" score={data.factors?.auxiliary?.score ?? 0} max={5} colorClass="bg-slate-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ALT Gate 卡片 */}
|
||||
{!isBTC && data.factors && <ALTGateCard symbol={symbol} factors={data.factors} />}
|
||||
|
||||
{/* BTC Gate 卡片 */}
|
||||
{isBTC && data.factors && <BTCGateCard factors={data.factors} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 信号历史 ────────────────────────────────────────────────────
|
||||
|
||||
interface SignalRecord {
|
||||
ts: number;
|
||||
score: number;
|
||||
signal: string;
|
||||
}
|
||||
|
||||
function SignalHistory({ symbol }: { symbol: Symbol }) {
|
||||
const [data, setData] = useState<SignalRecord[]>([]);
|
||||
const strategy = "v53_fast";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── CVD图表 ────────────────────────────────────────────────────
|
||||
|
||||
function CVDChart({ symbol, minutes }: { symbol: Symbol; minutes: number }) {
|
||||
const [data, setData] = useState<IndicatorRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const strategy = "v53_fast";
|
||||
|
||||
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
|
||||
// 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(5m实算)"];
|
||||
return [fmt(Number(v)), "CVD_mid(30m实算)"];
|
||||
}}
|
||||
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 SignalsV53Page() {
|
||||
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">
|
||||
{/* Fast 实验版标识条 */}
|
||||
<div className="rounded-lg bg-gradient-to-r from-orange-500 to-amber-400 px-3 py-1.5 flex items-center justify-between">
|
||||
<span className="text-white text-xs font-bold">🚀 V5.3 Fast — 实验变体 A/B对照</span>
|
||||
<div className="flex gap-2 text-white text-[10px] font-medium">
|
||||
<span className="bg-white/20 px-2 py-0.5 rounded">CVD 5m/30m</span>
|
||||
<span className="bg-white/20 px-2 py-0.5 rounded">OBI+加分</span>
|
||||
<span className="bg-white/20 px-2 py-0.5 rounded">accel独立触发</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-slate-900">🚀 信号引擎 V5.3 Fast</h1>
|
||||
<p className="text-slate-500 text-[10px]">
|
||||
CVD 5m/30m · OBI正向加分 · accel独立触发 · 实验变体 ·
|
||||
{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} />
|
||||
<SignalHistory symbol={symbol} />
|
||||
|
||||
<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(DB存30m,实算5m★) · 紫=mid(DB存4h,实算30m★) · 橙=价格</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} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm overflow-hidden">
|
||||
<div className="px-3 py-2 border-b border-slate-100">
|
||||
<h3 className="font-semibold text-slate-800 text-xs">📖 V5.3 双轨信号说明</h3>
|
||||
</div>
|
||||
<div className="px-3 py-2 space-y-2 text-[11px] text-slate-600">
|
||||
<div className="p-2 bg-purple-50 rounded-lg border border-purple-100">
|
||||
<span className="font-bold text-purple-800">🟣 ALT轨(ETH/XRP/SOL)— 四层线性评分</span>
|
||||
<div className="mt-1 space-y-1">
|
||||
<p><span className="font-semibold">1️⃣ 方向层(55分)</span> — CVD共振30分(fast+mid同向)+ P99大单对齐20分 + 加速奖励5分。删除独立确认层,解决CVD双重计分问题。</p>
|
||||
<p><span className="font-semibold">2️⃣ 拥挤层(25分)</span> — LSR反向拥挤15分(散户过度拥挤=信号)+ 大户持仓方向10分。</p>
|
||||
<p><span className="font-semibold">3️⃣ 环境层(15分)</span> — OI变化率,新资金进场vs撤离,判断趋势持续性。</p>
|
||||
<p><span className="font-semibold">4️⃣ 辅助层(5分)</span> — Coinbase Premium,美系机构动向。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 bg-amber-50 rounded-lg border border-amber-100">
|
||||
<span className="font-bold text-amber-800">🔵 BTC轨 — Gate-Control逻辑</span>
|
||||
<div className="mt-1 space-y-1">
|
||||
<p><span className="font-semibold">波动率门控</span>:ATR/Price ≥ 0.2%,低波动行情拒绝开仓</p>
|
||||
<p><span className="font-semibold">OBI否决</span>:订单簿失衡超阈值且与信号方向冲突时否决(实时100ms)</p>
|
||||
<p><span className="font-semibold">期现背离否决</span>:spot与perp价差超阈值时否决(实时1s)</p>
|
||||
<p><span className="font-semibold">巨鲸CVD</span>:>$100k成交额净CVD,15分钟滚动窗口实时计算</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-1 border-t border-slate-100">
|
||||
<span className="text-blue-600 font-medium">档位:</span><75不开仓 · 75-84标准 · ≥85加仓 · 冷却10分钟
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
580
frontend/app/signals-v53middle/page.tsx
Normal file
580
frontend/app/signals-v53middle/page.tsx
Normal file
@ -0,0 +1,580 @@
|
||||
"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; // v53_btc: alt_score_ref(参考分)
|
||||
gate_passed?: boolean; // v53_btc顶层字段
|
||||
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; accel_independent_score?: number };
|
||||
crowding?: { score?: number; max?: number; lsr_contrarian?: number; top_trader_position?: number };
|
||||
environment?: { score?: number; max?: number; obi_bonus?: number; oi_base?: number };
|
||||
auxiliary?: { score?: number; max?: number; coinbase_premium?: number };
|
||||
// BTC gate fields
|
||||
gate_passed?: boolean;
|
||||
block_reason?: string; // BTC用
|
||||
gate_block?: string; // ALT用
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── ALT Gate 状态卡片 ──────────────────────────────────────────
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── BTC Gate 状态卡片 ───────────────────────────────────────────
|
||||
|
||||
function BTCGateCard({ factors }: { factors: LatestIndicator["factors"] }) {
|
||||
if (!factors) return null;
|
||||
return (
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 mt-2">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<p className="text-[10px] font-semibold text-amber-800">⚡ BTC Gate-Control</p>
|
||||
<span className={`text-[10px] font-bold px-2 py-0.5 rounded ${factors.gate_passed ? "bg-emerald-100 text-emerald-700" : "bg-red-100 text-red-600"}`}>
|
||||
{factors.gate_passed ? "✅ Gate通过" : "❌ 否决"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
<div className="bg-white rounded px-2 py-1">
|
||||
<p className="text-[10px] text-slate-400">波动率</p>
|
||||
<p className="text-xs font-mono text-slate-800">{((factors.atr_pct_price ?? 0) * 100).toFixed(3)}%</p>
|
||||
<p className="text-[9px] text-slate-400">需 ≥0.2%</p>
|
||||
</div>
|
||||
<div className="bg-white rounded px-2 py-1">
|
||||
<p className="text-[10px] text-slate-400">OBI</p>
|
||||
<p className={`text-xs font-mono ${(factors.obi_raw ?? 0) >= 0 ? "text-emerald-600" : "text-red-500"}`}>
|
||||
{((factors.obi_raw ?? 0) * 100).toFixed(2)}%
|
||||
</p>
|
||||
<p className="text-[9px] text-slate-400">盘口失衡</p>
|
||||
</div>
|
||||
<div className="bg-white rounded px-2 py-1">
|
||||
<p className="text-[10px] text-slate-400">期现背离</p>
|
||||
<p className={`text-xs font-mono ${(factors.spot_perp_div ?? 0) >= 0 ? "text-emerald-600" : "text-red-500"}`}>
|
||||
{((factors.spot_perp_div ?? 0) * 10000).toFixed(2)}bps
|
||||
</p>
|
||||
<p className="text-[9px] text-slate-400">spot-perp</p>
|
||||
</div>
|
||||
<div className="bg-white rounded px-2 py-1">
|
||||
<p className="text-[10px] text-slate-400">巨鲸CVD</p>
|
||||
<p className={`text-xs font-mono ${(factors.whale_cvd_ratio ?? 0) >= 0 ? "text-emerald-600" : "text-red-500"}`}>
|
||||
{((factors.whale_cvd_ratio ?? 0) * 100).toFixed(2)}%
|
||||
</p>
|
||||
<p className="text-[9px] text-slate-400">>$100k</p>
|
||||
</div>
|
||||
</div>
|
||||
{factors.block_reason && (
|
||||
<p className="text-[10px] text-red-600 mt-1.5 bg-red-50 rounded px-2 py-1">
|
||||
否决原因: <span className="font-mono">{factors.block_reason}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 实时指标卡片 ────────────────────────────────────────────────
|
||||
|
||||
function IndicatorCards({ symbol }: { symbol: Symbol }) {
|
||||
const [data, setData] = useState<LatestIndicator | null>(null);
|
||||
const strategy = "v53_middle";
|
||||
|
||||
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">
|
||||
{/* 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 (5m实算★)</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 (30m实算★)</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>
|
||||
|
||||
{/* ATR + VWAP */}
|
||||
<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>
|
||||
|
||||
{/* 四层分数 — ALT和BTC都显示 */}
|
||||
<div className="mt-2 space-y-1">
|
||||
<LayerScore label="方向" score={data.factors?.direction?.score ?? 0} max={55} colorClass="bg-blue-600" />
|
||||
{data.factors?.direction?.accel_independent_score != null && data.factors.direction.accel_independent_score > 0 && (
|
||||
<p className="text-[9px] text-orange-600 pl-1">⚡ accel独立触发 +{data.factors.direction.accel_independent_score}</p>
|
||||
)}
|
||||
<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" />
|
||||
{(data.factors?.environment?.obi_bonus ?? 0) > 0 && (
|
||||
<p className="text-[9px] text-cyan-600 pl-1">📊 OBI正向 +{data.factors?.environment?.obi_bonus}</p>
|
||||
)}
|
||||
<LayerScore label="辅助" score={data.factors?.auxiliary?.score ?? 0} max={5} colorClass="bg-slate-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ALT Gate 卡片 */}
|
||||
{!isBTC && data.factors && <ALTGateCard symbol={symbol} factors={data.factors} />}
|
||||
|
||||
{/* BTC Gate 卡片 */}
|
||||
{isBTC && data.factors && <BTCGateCard factors={data.factors} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 信号历史 ────────────────────────────────────────────────────
|
||||
|
||||
interface SignalRecord {
|
||||
ts: number;
|
||||
score: number;
|
||||
signal: string;
|
||||
}
|
||||
|
||||
function SignalHistory({ symbol }: { symbol: Symbol }) {
|
||||
const [data, setData] = useState<SignalRecord[]>([]);
|
||||
const strategy = "v53_middle";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── CVD图表 ────────────────────────────────────────────────────
|
||||
|
||||
function CVDChart({ symbol, minutes }: { symbol: Symbol; minutes: number }) {
|
||||
const [data, setData] = useState<IndicatorRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const strategy = "v53_middle";
|
||||
|
||||
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
|
||||
// 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(5m实算)"];
|
||||
return [fmt(Number(v)), "CVD_mid(30m实算)"];
|
||||
}}
|
||||
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 SignalsV53Page() {
|
||||
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">
|
||||
{/* Fast 实验版标识条 */}
|
||||
<div className="rounded-lg bg-gradient-to-r from-orange-500 to-amber-400 px-3 py-1.5 flex items-center justify-between">
|
||||
<span className="text-white text-xs font-bold">🚀 V5.3 Middle — 实验变体 A/B对照</span>
|
||||
<div className="flex gap-2 text-white text-[10px] font-medium">
|
||||
<span className="bg-white/20 px-2 py-0.5 rounded">CVD 5m/30m</span>
|
||||
<span className="bg-white/20 px-2 py-0.5 rounded">OBI+加分</span>
|
||||
<span className="bg-white/20 px-2 py-0.5 rounded">accel独立触发</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-slate-900">🚀 信号引擎 V5.3 Middle</h1>
|
||||
<p className="text-slate-500 text-[10px]">
|
||||
CVD 5m/30m · OBI正向加分 · accel独立触发 · 实验变体 ·
|
||||
{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} />
|
||||
<SignalHistory symbol={symbol} />
|
||||
|
||||
<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(DB存30m,实算5m★) · 紫=mid(DB存4h,实算30m★) · 橙=价格</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} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm overflow-hidden">
|
||||
<div className="px-3 py-2 border-b border-slate-100">
|
||||
<h3 className="font-semibold text-slate-800 text-xs">📖 V5.3 双轨信号说明</h3>
|
||||
</div>
|
||||
<div className="px-3 py-2 space-y-2 text-[11px] text-slate-600">
|
||||
<div className="p-2 bg-purple-50 rounded-lg border border-purple-100">
|
||||
<span className="font-bold text-purple-800">🟣 ALT轨(ETH/XRP/SOL)— 四层线性评分</span>
|
||||
<div className="mt-1 space-y-1">
|
||||
<p><span className="font-semibold">1️⃣ 方向层(55分)</span> — CVD共振30分(fast+mid同向)+ P99大单对齐20分 + 加速奖励5分。删除独立确认层,解决CVD双重计分问题。</p>
|
||||
<p><span className="font-semibold">2️⃣ 拥挤层(25分)</span> — LSR反向拥挤15分(散户过度拥挤=信号)+ 大户持仓方向10分。</p>
|
||||
<p><span className="font-semibold">3️⃣ 环境层(15分)</span> — OI变化率,新资金进场vs撤离,判断趋势持续性。</p>
|
||||
<p><span className="font-semibold">4️⃣ 辅助层(5分)</span> — Coinbase Premium,美系机构动向。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 bg-amber-50 rounded-lg border border-amber-100">
|
||||
<span className="font-bold text-amber-800">🔵 BTC轨 — Gate-Control逻辑</span>
|
||||
<div className="mt-1 space-y-1">
|
||||
<p><span className="font-semibold">波动率门控</span>:ATR/Price ≥ 0.2%,低波动行情拒绝开仓</p>
|
||||
<p><span className="font-semibold">OBI否决</span>:订单簿失衡超阈值且与信号方向冲突时否决(实时100ms)</p>
|
||||
<p><span className="font-semibold">期现背离否决</span>:spot与perp价差超阈值时否决(实时1s)</p>
|
||||
<p><span className="font-semibold">巨鲸CVD</span>:>$100k成交额净CVD,15分钟滚动窗口实时计算</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-1 border-t border-slate-100">
|
||||
<span className="text-blue-600 font-medium">档位:</span><75不开仓 · 75-84标准 · ≥85加仓 · 冷却10分钟
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -175,7 +175,7 @@ function SignalHistory({ symbol }: { symbol: Symbol }) {
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await authFetch(`/api/signals/signal-history?symbol=${symbol}&limit=20`);
|
||||
const res = await authFetch(`/api/signals/signal-history?symbol=${symbol}&limit=20&strategy=v51_baseline`);
|
||||
if (!res.ok) return;
|
||||
const json = await res.json();
|
||||
setData(json.data || []);
|
||||
@ -227,7 +227,7 @@ function IndicatorCards({ symbol }: { symbol: Symbol }) {
|
||||
useEffect(() => {
|
||||
const fetch = async () => {
|
||||
try {
|
||||
const res = await authFetch("/api/signals/latest");
|
||||
const res = await authFetch("/api/signals/latest?strategy=v51_baseline");
|
||||
if (!res.ok) return;
|
||||
const json = await res.json();
|
||||
setData(json[symbol] || null);
|
||||
|
||||
176
frontend/app/strategy-plaza/[id]/page.tsx
Normal file
176
frontend/app/strategy-plaza/[id]/page.tsx
Normal file
@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import { useParams, useSearchParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { authFetch } from "@/lib/auth";
|
||||
import Link from "next/link";
|
||||
import dynamic from "next/dynamic";
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle,
|
||||
PauseCircle,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
} from "lucide-react";
|
||||
|
||||
// ─── Dynamic imports for each strategy's pages ───────────────────
|
||||
const SignalsV53 = dynamic(() => import("@/app/signals-v53/page"), { ssr: false });
|
||||
const SignalsV53Fast = dynamic(() => import("@/app/signals-v53fast/page"), { ssr: false });
|
||||
const SignalsV53Middle = dynamic(() => import("@/app/signals-v53middle/page"), { ssr: false });
|
||||
const PaperV53 = dynamic(() => import("@/app/paper-v53/page"), { ssr: false });
|
||||
const PaperV53Fast = dynamic(() => import("@/app/paper-v53fast/page"), { ssr: false });
|
||||
const PaperV53Middle = dynamic(() => import("@/app/paper-v53middle/page"), { ssr: false });
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────
|
||||
interface StrategySummary {
|
||||
id: string;
|
||||
display_name: string;
|
||||
status: string;
|
||||
started_at: number;
|
||||
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;
|
||||
open_positions: number;
|
||||
pnl_usdt_24h: number;
|
||||
pnl_r_24h: number;
|
||||
cvd_windows?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────
|
||||
function fmtDur(ms: number) {
|
||||
const s = Math.floor((Date.now() - ms) / 1000);
|
||||
const d = Math.floor(s / 86400);
|
||||
const h = Math.floor((s % 86400) / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
if (d > 0) return `${d}天${h}h`;
|
||||
if (h > 0) return `${h}h${m}m`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
if (status === "running") return <span className="flex items-center gap-1 text-xs text-emerald-400"><CheckCircle size={12} />运行中</span>;
|
||||
if (status === "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-400"><AlertCircle size={12} />异常</span>;
|
||||
}
|
||||
|
||||
// ─── Content router ───────────────────────────────────────────────
|
||||
function SignalsContent({ strategyId }: { strategyId: string }) {
|
||||
if (strategyId === "v53") return <SignalsV53 />;
|
||||
if (strategyId === "v53_fast") return <SignalsV53Fast />;
|
||||
if (strategyId === "v53_middle") return <SignalsV53Middle />;
|
||||
return <div className="p-8 text-gray-400">未知策略: {strategyId}</div>;
|
||||
}
|
||||
|
||||
function PaperContent({ strategyId }: { strategyId: string }) {
|
||||
if (strategyId === "v53") return <PaperV53 />;
|
||||
if (strategyId === "v53_fast") return <PaperV53Fast />;
|
||||
if (strategyId === "v53_middle") return <PaperV53Middle />;
|
||||
return <div className="p-8 text-gray-400">未知策略: {strategyId}</div>;
|
||||
}
|
||||
|
||||
// ─── Main Page ────────────────────────────────────────────────────
|
||||
export default function StrategyDetailPage() {
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const strategyId = params?.id as string;
|
||||
const tab = searchParams?.get("tab") || "signals";
|
||||
|
||||
const [summary, setSummary] = useState<StrategySummary | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchSummary = useCallback(async () => {
|
||||
try {
|
||||
const r = await authFetch(`/api/strategy-plaza/${strategyId}/summary`);
|
||||
if (r.ok) {
|
||||
const d = await r.json();
|
||||
setSummary(d);
|
||||
}
|
||||
} catch {}
|
||||
setLoading(false);
|
||||
}, [strategyId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSummary();
|
||||
const iv = setInterval(fetchSummary, 30000);
|
||||
return () => clearInterval(iv);
|
||||
}, [fetchSummary]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-gray-400 animate-pulse">加载中...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isProfit = (summary?.net_usdt ?? 0) >= 0;
|
||||
|
||||
return (
|
||||
<div className="p-4 max-w-full">
|
||||
{/* Back + Strategy Header */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Link href="/strategy-plaza" className="flex items-center gap-1 text-gray-400 hover:text-white text-sm transition-colors">
|
||||
<ArrowLeft size={16} />
|
||||
策略广场
|
||||
</Link>
|
||||
<span className="text-gray-600">/</span>
|
||||
<span className="text-white font-medium">{summary?.display_name ?? strategyId}</span>
|
||||
</div>
|
||||
|
||||
{/* Summary Bar */}
|
||||
{summary && (
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-slate-200 bg-white px-4 py-2.5 mb-4">
|
||||
<StatusBadge status={summary.status} />
|
||||
<span className="text-xs text-slate-400 flex items-center gap-1">
|
||||
<Clock size={10} />运行 {fmtDur(summary.started_at)}
|
||||
</span>
|
||||
{summary.cvd_windows && (
|
||||
<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="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">净R <span className={`font-bold ${isProfit ? "text-emerald-600" : "text-red-500"}`}>{summary.net_r >= 0 ? "+" : ""}{summary.net_r}R</span></span>
|
||||
<span className="text-slate-500">余额 <span className={`font-bold ${isProfit ? "text-emerald-600" : "text-red-500"}`}>{summary.current_balance.toLocaleString()} U</span></span>
|
||||
<span className="text-slate-500">24h <span className={`font-bold ${summary.pnl_usdt_24h >= 0 ? "text-emerald-600" : "text-red-500"}`}>{summary.pnl_usdt_24h >= 0 ? "+" : ""}{summary.pnl_usdt_24h} U</span></span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
{[
|
||||
{ key: "signals", label: "📊 信号引擎" },
|
||||
{ key: "paper", label: "📈 模拟盘" },
|
||||
].map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => router.push(`/strategy-plaza/${strategyId}?tab=${key}`)}
|
||||
className={`px-4 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
|
||||
tab === key
|
||||
? "bg-blue-600 text-white border-blue-600"
|
||||
: "bg-white text-slate-600 border-slate-200 hover:border-blue-300 hover:text-blue-600"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content — direct render of existing pages */}
|
||||
<div>
|
||||
{tab === "signals" ? (
|
||||
<SignalsContent strategyId={strategyId} />
|
||||
) : (
|
||||
<PaperContent strategyId={strategyId} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
259
frontend/app/strategy-plaza/page.tsx
Normal file
259
frontend/app/strategy-plaza/page.tsx
Normal file
@ -0,0 +1,259 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { authFetch } from "@/lib/auth";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Clock,
|
||||
Activity,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
PauseCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
interface StrategyCard {
|
||||
id: string;
|
||||
display_name: string;
|
||||
status: "running" | "paused" | "error";
|
||||
started_at: number;
|
||||
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;
|
||||
open_positions: number;
|
||||
pnl_usdt_24h: number;
|
||||
pnl_r_24h: number;
|
||||
std_r: number;
|
||||
last_trade_at: number | null;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const totalSec = Math.floor((Date.now() - ms) / 1000);
|
||||
const d = Math.floor(totalSec / 86400);
|
||||
const h = Math.floor((totalSec % 86400) / 3600);
|
||||
const m = Math.floor((totalSec % 3600) / 60);
|
||||
if (d > 0) return `${d}天 ${h}小时`;
|
||||
if (h > 0) return `${h}小时 ${m}分`;
|
||||
return `${m}分钟`;
|
||||
}
|
||||
|
||||
function formatTime(ms: number | null): string {
|
||||
if (!ms) return "—";
|
||||
const d = new Date(ms);
|
||||
return d.toLocaleString("zh-CN", {
|
||||
timeZone: "Asia/Shanghai",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
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={11} className="text-emerald-500" />
|
||||
运行中
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "paused") {
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-xs text-amber-600 font-medium">
|
||||
<PauseCircle size={11} className="text-amber-500" />
|
||||
已暂停
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-xs text-red-600 font-medium">
|
||||
<AlertCircle size={11} className="text-red-500" />
|
||||
异常
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StrategyCardComponent({ s }: { s: StrategyCard }) {
|
||||
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 (
|
||||
<Link href={`/strategy-plaza/${s.id}`}>
|
||||
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden hover:border-blue-300 hover:shadow-md transition-all cursor-pointer group">
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b border-slate-100 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-slate-800 text-sm group-hover:text-blue-700 transition-colors">
|
||||
{s.display_name}
|
||||
</h3>
|
||||
<StatusBadge status={s.status} />
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-400 flex items-center gap-1">
|
||||
<Clock size={9} />
|
||||
{formatDuration(s.started_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Main PnL */}
|
||||
<div className="px-4 pt-3 pb-2">
|
||||
<div className="flex items-end justify-between mb-2">
|
||||
<div>
|
||||
<div className="text-[10px] text-slate-400 mb-0.5">当前余额</div>
|
||||
<div className="text-xl font-bold text-slate-800">
|
||||
{s.current_balance.toLocaleString()}
|
||||
<span className="text-xs font-normal text-slate-400 ml-1">USDT</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-[10px] text-slate-400 mb-0.5">累计盈亏</div>
|
||||
<div className={`text-lg font-bold ${isProfit ? "text-emerald-600" : "text-red-500"}`}>
|
||||
{isProfit ? "+" : ""}{s.net_usdt.toLocaleString()} U
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Balance Bar */}
|
||||
<div className="mb-3">
|
||||
<div className="flex justify-between text-[10px] text-slate-400 mb-1">
|
||||
<span>{balancePct}%</span>
|
||||
<span>{s.initial_balance.toLocaleString()} USDT 初始</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-100 rounded-full h-1.5">
|
||||
<div
|
||||
className={`h-1.5 rounded-full ${isProfit ? "bg-emerald-400" : "bg-red-400"}`}
|
||||
style={{ width: `${Math.min(100, Math.max(0, (s.current_balance / s.initial_balance) * 100))}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Row */}
|
||||
<div className="grid grid-cols-3 gap-2 mb-3">
|
||||
<div className="bg-slate-50 rounded-lg p-2 text-center">
|
||||
<div className="text-[10px] text-slate-400">胜率</div>
|
||||
<div className={`text-sm font-bold ${s.win_rate >= 50 ? "text-emerald-600" : s.win_rate >= 45 ? "text-amber-600" : "text-red-500"}`}>
|
||||
{s.win_rate}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-2 text-center">
|
||||
<div className="text-[10px] text-slate-400">净R</div>
|
||||
<div className={`text-sm font-bold ${s.net_r >= 0 ? "text-emerald-600" : "text-red-500"}`}>
|
||||
{s.net_r >= 0 ? "+" : ""}{s.net_r}R
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-2 text-center">
|
||||
<div className="text-[10px] text-slate-400">交易数</div>
|
||||
<div className="text-sm font-bold text-slate-700">{s.trade_count}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Avg win/loss */}
|
||||
<div className="flex gap-2 mb-3">
|
||||
<div className="flex-1 bg-emerald-50 rounded-lg px-2.5 py-1.5">
|
||||
<span className="text-[10px] text-emerald-600">平均赢</span>
|
||||
<span className="float-right text-[10px] font-bold text-emerald-600">+{s.avg_win_r}R</span>
|
||||
</div>
|
||||
<div className="flex-1 bg-red-50 rounded-lg px-2.5 py-1.5">
|
||||
<span className="text-[10px] text-red-500">平均亏</span>
|
||||
<span className="float-right text-[10px] font-bold text-red-500">{s.avg_loss_r}R</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-4 py-2.5 border-t border-slate-100 flex items-center justify-between bg-slate-50/60">
|
||||
<div className="flex items-center gap-1">
|
||||
{is24hProfit ? (
|
||||
<TrendingUp size={12} className="text-emerald-500" />
|
||||
) : (
|
||||
<TrendingDown size={12} className="text-red-500" />
|
||||
)}
|
||||
<span className={`text-[10px] font-medium ${is24hProfit ? "text-emerald-600" : "text-red-500"}`}>
|
||||
24h {is24hProfit ? "+" : ""}{s.pnl_usdt_24h.toLocaleString()} U
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[10px] text-slate-400">
|
||||
<Activity size={9} />
|
||||
{s.open_positions > 0 ? (
|
||||
<span className="text-amber-600 font-medium">{s.open_positions}仓持仓中</span>
|
||||
) : (
|
||||
<span>上次: {formatTime(s.last_trade_at)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StrategyPlazaPage() {
|
||||
useAuth();
|
||||
const [strategies, setStrategies] = useState<StrategyCard[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const res = await authFetch("/api/strategy-plaza");
|
||||
const data = await res.json();
|
||||
setStrategies(data.strategies || []);
|
||||
setLastUpdated(new Date());
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const interval = setInterval(fetchData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchData]);
|
||||
|
||||
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">
|
||||
{/* Header */}
|
||||
<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>
|
||||
{lastUpdated && (
|
||||
<div className="text-[10px] text-slate-400 flex items-center gap-1">
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||
{lastUpdated.toLocaleTimeString("zh-CN", { timeZone: "Asia/Shanghai", hour12: false })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Strategy Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{strategies.map((s) => (
|
||||
<StrategyCardComponent key={s.id} s={s} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{strategies.length === 0 && (
|
||||
<div className="text-center text-slate-400 text-sm py-16">暂无策略数据</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -7,14 +7,14 @@ import { useAuth } from "@/lib/auth";
|
||||
import {
|
||||
LayoutDashboard, Info,
|
||||
Menu, X, Zap, LogIn, UserPlus,
|
||||
ChevronLeft, ChevronRight, Activity, LogOut, Crosshair, Monitor, LineChart
|
||||
ChevronLeft, ChevronRight, Activity, LogOut, Monitor, LineChart, Bolt
|
||||
} from "lucide-react";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/", label: "仪表盘", icon: LayoutDashboard },
|
||||
{ href: "/trades", label: "成交流", icon: Activity },
|
||||
{ href: "/signals", label: "信号引擎 V5.1", icon: Crosshair },
|
||||
{ href: "/paper", label: "模拟盘", icon: LineChart },
|
||||
{ href: "/live", label: "⚡ 实盘交易", icon: Bolt, section: "── 实盘 ──" },
|
||||
{ href: "/strategy-plaza", label: "策略广场", icon: Zap, section: "── 策略 ──" },
|
||||
{ href: "/server", label: "服务器", icon: Monitor },
|
||||
{ href: "/about", label: "说明", icon: Info },
|
||||
];
|
||||
@ -37,17 +37,26 @@ export default function Sidebar() {
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 py-4 space-y-1 px-2">
|
||||
{navItems.map(({ href, label, icon: Icon }) => {
|
||||
{navItems.map(({ href, label, icon: Icon, section }, idx) => {
|
||||
const active = pathname === href;
|
||||
return (
|
||||
<Link key={href} href={href}
|
||||
<div key={href}>
|
||||
{section && (
|
||||
<div className={`px-3 pt-3 pb-1 text-[10px] font-semibold text-slate-400 tracking-wider ${idx > 0 ? "mt-2 border-t border-slate-100 pt-4" : ""}`}>
|
||||
{section}
|
||||
</div>
|
||||
)}
|
||||
<Link href={href}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors
|
||||
${active ? "bg-blue-50 text-blue-700 font-medium" : "text-slate-600 hover:bg-slate-100 hover:text-slate-900"}
|
||||
${collapsed && !mobile ? "justify-center" : ""}`}>
|
||||
<Icon className={`shrink-0 ${active ? "text-blue-600" : "text-slate-400"}`} size={18} />
|
||||
{(!collapsed || mobile) && <span>{label}</span>}
|
||||
{(!collapsed || mobile) && (
|
||||
<span className="flex items-center gap-1.5">{label}</span>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* 手机端:登录/登出放在菜单里 */}
|
||||
|
||||
16
frontend/package-lock.json
generated
16
frontend/package-lock.json
generated
@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lightweight-charts": "^5.0.0",
|
||||
"lucide-react": "^0.575.0",
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.3",
|
||||
@ -3765,6 +3766,12 @@
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fancy-canvas": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fancy-canvas/-/fancy-canvas-2.1.0.tgz",
|
||||
"integrity": "sha512-nifxXJ95JNLFR2NgRV4/MxVP45G9909wJTEKz5fg/TZS20JJZA6hfgRVh/bC9bwl2zBtBNcYPjiBE4njQHVBwQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@ -5127,6 +5134,15 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightweight-charts": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/lightweight-charts/-/lightweight-charts-5.1.0.tgz",
|
||||
"integrity": "sha512-jEAYR4ODYeyNZcWUigsoLTl52rbPmgXnvd5FLIv/ZoA/2sSDw63YKnef8n4yhzum7W926yHeFwlm7ididKb7YQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"fancy-canvas": "2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
|
||||
245
scripts/label_backfill.py
Normal file
245
scripts/label_backfill.py
Normal file
@ -0,0 +1,245 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
label_backfill.py — V5.3 信号标签回填脚本
|
||||
|
||||
功能:
|
||||
- 遍历 signal_feature_events 中有 side 的历史记录
|
||||
- 根据 side 时点后 15/30/60 分钟的 agg_trades 价格计算标签
|
||||
- 写入 signal_label_events 表(event_id 复用 signal_feature_events.event_id)
|
||||
|
||||
标签定义(严格按 Mark Price + 时间序列方向):
|
||||
y_binary_60m = 1 if price_60m_later > price_at_signal (LONG)
|
||||
= 1 if price_60m_later < price_at_signal (SHORT)
|
||||
= 0 otherwise
|
||||
y_return_Xm = (price_Xm_later - price_at_signal) / price_at_signal (方向不翻转,LONG正SHORT正)
|
||||
mfe_r_60m = max favorable excursion / atr_value (需 atr_value 不为0)
|
||||
mae_r_60m = max adverse excursion / atr_value
|
||||
|
||||
运行方式:
|
||||
python3 scripts/label_backfill.py
|
||||
python3 scripts/label_backfill.py --symbol BTCUSDT
|
||||
python3 scripts/label_backfill.py --since 1709000000000 # ms timestamp
|
||||
python3 scripts/label_backfill.py --dry-run # 只打印不写入
|
||||
|
||||
依赖:
|
||||
PG_PASS / PG_HOST 环境变量(同其他脚本)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "backend"))
|
||||
from db import get_sync_conn, init_schema
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("label_backfill")
|
||||
|
||||
HORIZONS_MS = {
|
||||
"15m": 15 * 60 * 1000,
|
||||
"30m": 30 * 60 * 1000,
|
||||
"60m": 60 * 60 * 1000,
|
||||
}
|
||||
BATCH_SIZE = 200
|
||||
LABEL_TABLE = "signal_label_events"
|
||||
|
||||
|
||||
def ensure_label_table(conn):
|
||||
"""label_events 表由 db.py init_schema 创建,此处仅确认存在"""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_name=%s)",
|
||||
(LABEL_TABLE,),
|
||||
)
|
||||
if not cur.fetchone()[0]:
|
||||
raise RuntimeError(f"表 {LABEL_TABLE} 不存在,请先运行 init_schema()")
|
||||
|
||||
|
||||
def fetch_unlabeled_signals(conn, symbol=None, since_ms=None, limit=BATCH_SIZE):
|
||||
"""取尚未回填标签的 signal_feature_events(有 side 且 60m 已过期)"""
|
||||
cutoff_ms = int(time.time() * 1000) - HORIZONS_MS["60m"] - 60_000 # 多留1分钟缓冲
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
|
||||
params = []
|
||||
conds = [
|
||||
"sfe.side IS NOT NULL",
|
||||
"sfe.side != ''",
|
||||
"sfe.ts < %s",
|
||||
"sle.event_id IS NULL", # 尚未回填
|
||||
]
|
||||
params.append(cutoff_ms)
|
||||
if symbol:
|
||||
conds.append("sfe.symbol = %s")
|
||||
params.append(symbol.upper())
|
||||
if since_ms:
|
||||
conds.append("sfe.ts >= %s")
|
||||
params.append(since_ms)
|
||||
where = " AND ".join(conds)
|
||||
params.append(limit)
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT sfe.event_id AS id, sfe.ts, sfe.symbol, sfe.side AS signal,
|
||||
sfe.price, sfe.atr_value
|
||||
FROM signal_feature_events sfe
|
||||
LEFT JOIN {LABEL_TABLE} sle ON sle.event_id = sfe.event_id
|
||||
WHERE {where}
|
||||
ORDER BY sfe.ts ASC
|
||||
LIMIT %s
|
||||
""",
|
||||
params,
|
||||
)
|
||||
return cur.fetchall()
|
||||
|
||||
|
||||
def fetch_price_at(conn, symbol: str, ts_ms: int) -> float | None:
|
||||
"""取 ts_ms 之后最近一笔 agg_trades 成交价"""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT price FROM agg_trades WHERE symbol=%s AND time_ms >= %s ORDER BY time_ms ASC LIMIT 1",
|
||||
(symbol, ts_ms),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return float(row[0]) if row else None
|
||||
|
||||
|
||||
def fetch_price_range(conn, symbol: str, from_ms: int, to_ms: int):
|
||||
"""取区间内最高价和最低价(用于 MFE/MAE 计算)"""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT MAX(price), MIN(price) FROM agg_trades WHERE symbol=%s AND time_ms BETWEEN %s AND %s",
|
||||
(symbol, from_ms, to_ms),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row and row[0] is not None:
|
||||
return float(row[0]), float(row[1])
|
||||
return None, None
|
||||
|
||||
|
||||
def compute_label(signal: str, entry_price: float, future_price: float) -> int:
|
||||
"""方向感知二值标签(1=预测正确,0=预测错误)"""
|
||||
if signal == "LONG":
|
||||
return 1 if future_price > entry_price else 0
|
||||
elif signal == "SHORT":
|
||||
return 1 if future_price < entry_price else 0
|
||||
return 0
|
||||
|
||||
|
||||
def compute_return(signal: str, entry_price: float, future_price: float) -> float:
|
||||
"""方向感知收益率(正值=有利)"""
|
||||
if entry_price == 0:
|
||||
return 0.0
|
||||
raw = (future_price - entry_price) / entry_price
|
||||
return raw if signal == "LONG" else -raw
|
||||
|
||||
|
||||
def compute_mfe_mae(signal: str, entry_price: float, high: float, low: float, atr: float):
|
||||
"""MFE/MAE(以R为单位)"""
|
||||
if atr is None or atr <= 0:
|
||||
return None, None
|
||||
if signal == "LONG":
|
||||
mfe = (high - entry_price) / atr
|
||||
mae = (entry_price - low) / atr
|
||||
else:
|
||||
mfe = (entry_price - low) / atr
|
||||
mae = (high - entry_price) / atr
|
||||
return round(mfe, 4), round(mae, 4)
|
||||
|
||||
|
||||
def backfill_batch(conn, rows: list, dry_run: bool) -> int:
|
||||
"""处理一批信号,返回成功回填数"""
|
||||
if not rows:
|
||||
return 0
|
||||
|
||||
records = []
|
||||
for row in rows:
|
||||
event_id = row["id"]
|
||||
ts = row["ts"]
|
||||
symbol = row["symbol"]
|
||||
signal = row["signal"]
|
||||
entry_price = row["price"] or 0.0
|
||||
atr_value = row["atr_value"]
|
||||
|
||||
labels = {}
|
||||
for horizon, delta_ms in HORIZONS_MS.items():
|
||||
future_ms = ts + delta_ms
|
||||
fp = fetch_price_at(conn, symbol, future_ms)
|
||||
if fp is None:
|
||||
labels[horizon] = None
|
||||
labels[f"ret_{horizon}"] = None
|
||||
else:
|
||||
labels[horizon] = compute_label(signal, entry_price, fp)
|
||||
labels[f"ret_{horizon}"] = round(compute_return(signal, entry_price, fp), 6)
|
||||
|
||||
# MFE/MAE 在 60m 窗口内
|
||||
high, low = fetch_price_range(conn, symbol, ts, ts + HORIZONS_MS["60m"])
|
||||
mfe, mae = compute_mfe_mae(signal, entry_price, high, low, atr_value) if high else (None, None)
|
||||
|
||||
records.append((
|
||||
event_id,
|
||||
labels.get("15m"), labels.get("30m"), labels.get("60m"),
|
||||
labels.get("ret_15m"), labels.get("ret_30m"), labels.get("ret_60m"),
|
||||
mfe, mae,
|
||||
int(time.time() * 1000),
|
||||
))
|
||||
|
||||
if dry_run:
|
||||
logger.info(f"[dry-run] 准备写入 {len(records)} 条标签")
|
||||
for r in records[:5]:
|
||||
logger.info(f" event_id={r[0]} y15m={r[1]} y30m={r[2]} y60m={r[3]} ret60m={r[6]} mfe={r[7]} mae={r[8]}")
|
||||
return len(records)
|
||||
|
||||
with conn.cursor() as cur:
|
||||
psycopg2.extras.execute_values(
|
||||
cur,
|
||||
f"""
|
||||
INSERT INTO {LABEL_TABLE}
|
||||
(event_id, y_binary_15m, y_binary_30m, y_binary_60m,
|
||||
y_return_15m, y_return_30m, y_return_60m, mfe_r_60m, mae_r_60m, label_ts)
|
||||
VALUES %s
|
||||
ON CONFLICT (event_id) DO NOTHING
|
||||
""",
|
||||
records,
|
||||
)
|
||||
conn.commit()
|
||||
return len(records)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="V5.3 信号标签回填")
|
||||
parser.add_argument("--symbol", help="只回填指定品种(BTCUSDT/ETHUSDT/...)")
|
||||
parser.add_argument("--since", type=int, help="起始时间戳(ms)")
|
||||
parser.add_argument("--dry-run", action="store_true", help="只打印,不写入")
|
||||
parser.add_argument("--loop", action="store_true", help="持续运行,每5分钟跑一次")
|
||||
args = parser.parse_args()
|
||||
|
||||
init_schema()
|
||||
|
||||
total = 0
|
||||
while True:
|
||||
with get_sync_conn() as conn:
|
||||
ensure_label_table(conn)
|
||||
rows = fetch_unlabeled_signals(conn, symbol=args.symbol, since_ms=args.since)
|
||||
if not rows:
|
||||
logger.info("没有待回填的信号,结束。")
|
||||
break
|
||||
n = backfill_batch(conn, rows, dry_run=args.dry_run)
|
||||
total += n
|
||||
logger.info(f"本批回填 {n} 条,累计 {total} 条")
|
||||
|
||||
time.sleep(1) # 限速:每批间隔1秒,减轻DB压力
|
||||
if not args.loop:
|
||||
break
|
||||
time.sleep(300) # 5分钟
|
||||
|
||||
logger.info(f"回填完成,总计 {total} 条。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
321
scripts/replay_paper_trades.py
Normal file
321
scripts/replay_paper_trades.py
Normal file
@ -0,0 +1,321 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
全量重放脚本 v2:用真实成交价(agg_trades) + 原始信号重算所有paper_trades的结果
|
||||
|
||||
修复点(xiaofan审阅后):
|
||||
1. 事件判定按时间戳最先发生,不是固定优先级
|
||||
2. TP1后进入半仓状态机,pnl_r不重复计算全仓
|
||||
3. flip与价格触发冲突:谁时间早用谁,同时间优先价格触发
|
||||
4. 保本价显式常量,区分净保本/毛保本
|
||||
"""
|
||||
import psycopg2
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
BJ = timezone(timedelta(hours=8))
|
||||
TIMEOUT_MS = 60 * 60 * 1000 # 60分钟
|
||||
|
||||
# 保本价偏移(毛保本,不含手续费,仅防止SL=entry被1tick打掉)
|
||||
BE_OFFSET_LONG = 1.0005 # LONG: SL移到entry*1.0005
|
||||
BE_OFFSET_SHORT = 0.9995 # SHORT: SL移到entry*0.9995
|
||||
|
||||
STRATEGY_CONFIG = {
|
||||
'v52_8signals': {'sl': 2.1, 'tp1': 1.4, 'tp2': 3.15},
|
||||
'v51_baseline': {'sl': 1.4, 'tp1': 1.05, 'tp2': 2.1},
|
||||
}
|
||||
|
||||
def ts_bj(ts_ms):
|
||||
return datetime.fromtimestamp(ts_ms / 1000, BJ).strftime('%m-%d %H:%M:%S')
|
||||
|
||||
def replay_trade(cur, tid, symbol, direction, strategy, entry_ts, atr):
|
||||
cfg = STRATEGY_CONFIG.get(strategy)
|
||||
if not cfg:
|
||||
return None, f"未知策略: {strategy}"
|
||||
|
||||
# 1. 真实入场价(entry_ts时刻最新成交价)
|
||||
cur.execute("""
|
||||
SELECT price FROM agg_trades
|
||||
WHERE symbol=%s AND time_ms <= %s
|
||||
ORDER BY time_ms DESC LIMIT 1
|
||||
""", (symbol, entry_ts))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None, "找不到entry时刻的agg_trade"
|
||||
entry = row[0]
|
||||
|
||||
# 2. 计算原始TP/SL
|
||||
rd = cfg['sl'] * atr
|
||||
if rd <= 0:
|
||||
return None, f"risk_distance={rd},ATR={atr}无效"
|
||||
|
||||
if direction == 'LONG':
|
||||
sl_orig = entry - rd
|
||||
tp1 = entry + cfg['tp1'] * atr
|
||||
tp2 = entry + cfg['tp2'] * atr
|
||||
else:
|
||||
sl_orig = entry + rd
|
||||
tp1 = entry - cfg['tp1'] * atr
|
||||
tp2 = entry - cfg['tp2'] * atr
|
||||
|
||||
timeout_ts = entry_ts + TIMEOUT_MS
|
||||
|
||||
# 3. 加载该时段内所有agg_trades价格(按时间顺序)
|
||||
cur.execute("""
|
||||
SELECT time_ms, price FROM agg_trades
|
||||
WHERE symbol=%s AND time_ms > %s AND time_ms <= %s
|
||||
ORDER BY time_ms ASC
|
||||
""", (symbol, entry_ts, timeout_ts))
|
||||
price_rows = cur.fetchall()
|
||||
|
||||
# 4. 加载该时段内第一个反向信号(signal_flip检测)
|
||||
cur.execute("""
|
||||
SELECT ts FROM signal_indicators
|
||||
WHERE symbol=%s AND strategy=%s AND ts > %s AND ts <= %s
|
||||
AND signal IS NOT NULL AND signal != ''
|
||||
AND signal != %s
|
||||
ORDER BY ts ASC
|
||||
LIMIT 1
|
||||
""", (symbol, strategy, entry_ts, timeout_ts, direction))
|
||||
flip_row = cur.fetchone()
|
||||
flip_ts = flip_row[0] if flip_row else None
|
||||
|
||||
# 5. 状态机:按时间顺序处理事件
|
||||
sl_current = sl_orig # 当前有效SL(可能移到保本价)
|
||||
tp1_hit = False
|
||||
tp1_hit_ts = None
|
||||
tp1_r = abs(tp1 - entry) / rd # 预计算,TP1触发后固定
|
||||
|
||||
result_status = None
|
||||
result_exit_ts = None
|
||||
result_exit_price = None
|
||||
result_pnl_r = None
|
||||
|
||||
for time_ms, price in price_rows:
|
||||
# 关键:先检查flip_ts是否比当前tick更早(tie-break:同时间优先价格触发)
|
||||
if flip_ts and flip_ts < time_ms:
|
||||
# flip发生在这笔tick之前,flip优先
|
||||
cur.execute("""
|
||||
SELECT price FROM agg_trades
|
||||
WHERE symbol=%s AND time_ms <= %s
|
||||
ORDER BY time_ms DESC LIMIT 1
|
||||
""", (symbol, flip_ts))
|
||||
fp = cur.fetchone()
|
||||
flip_price = fp[0] if fp else price
|
||||
|
||||
if direction == 'LONG':
|
||||
flip_pnl_half = (flip_price - entry) / rd
|
||||
else:
|
||||
flip_pnl_half = (entry - flip_price) / rd
|
||||
|
||||
if tp1_hit:
|
||||
# 已TP1:半仓已在tp1出,剩余半仓在flip_price出
|
||||
result_pnl_r = 0.5 * tp1_r + 0.5 * flip_pnl_half
|
||||
else:
|
||||
result_pnl_r = flip_pnl_half
|
||||
|
||||
result_status = 'signal_flip'
|
||||
result_exit_ts = flip_ts
|
||||
result_exit_price = flip_price
|
||||
break
|
||||
|
||||
if direction == 'LONG':
|
||||
if not tp1_hit:
|
||||
if price <= sl_current:
|
||||
result_status = 'sl'
|
||||
result_exit_ts = time_ms
|
||||
result_exit_price = sl_orig # 按挂单价成交
|
||||
result_pnl_r = -1.0
|
||||
break
|
||||
if price >= tp1:
|
||||
# TP1触发:半仓止盈,SL移保本
|
||||
tp1_hit = True
|
||||
tp1_hit_ts = time_ms
|
||||
sl_current = entry * BE_OFFSET_LONG
|
||||
else:
|
||||
# 半仓状态机:只剩50%仓位
|
||||
if price <= sl_current:
|
||||
# 保本SL触发
|
||||
result_status = 'sl_be'
|
||||
result_exit_ts = time_ms
|
||||
result_exit_price = sl_current
|
||||
result_pnl_r = 0.5 * tp1_r # 半仓TP1已实现
|
||||
break
|
||||
if price >= tp2:
|
||||
# TP2触发
|
||||
tp2_r = (tp2 - entry) / rd
|
||||
result_status = 'tp'
|
||||
result_exit_ts = time_ms
|
||||
result_exit_price = tp2
|
||||
result_pnl_r = 0.5 * tp1_r + 0.5 * tp2_r
|
||||
break
|
||||
else: # SHORT
|
||||
if not tp1_hit:
|
||||
if price >= sl_current:
|
||||
result_status = 'sl'
|
||||
result_exit_ts = time_ms
|
||||
result_exit_price = sl_orig
|
||||
result_pnl_r = -1.0
|
||||
break
|
||||
if price <= tp1:
|
||||
tp1_hit = True
|
||||
tp1_hit_ts = time_ms
|
||||
sl_current = entry * BE_OFFSET_SHORT
|
||||
else:
|
||||
if price >= sl_current:
|
||||
result_status = 'sl_be'
|
||||
result_exit_ts = time_ms
|
||||
result_exit_price = sl_current
|
||||
result_pnl_r = 0.5 * tp1_r
|
||||
break
|
||||
if price <= tp2:
|
||||
tp2_r = (entry - tp2) / rd
|
||||
result_status = 'tp'
|
||||
result_exit_ts = time_ms
|
||||
result_exit_price = tp2
|
||||
result_pnl_r = 0.5 * tp1_r + 0.5 * tp2_r
|
||||
break
|
||||
|
||||
# Timeout或循环结束未触发
|
||||
if not result_status:
|
||||
# 检查flip_ts是否在timeout范围内但没被price_rows覆盖到
|
||||
if flip_ts and flip_ts <= timeout_ts:
|
||||
cur.execute("""
|
||||
SELECT price FROM agg_trades
|
||||
WHERE symbol=%s AND time_ms <= %s
|
||||
ORDER BY time_ms DESC LIMIT 1
|
||||
""", (symbol, flip_ts))
|
||||
fp = cur.fetchone()
|
||||
flip_price = fp[0] if fp else entry
|
||||
if direction == 'LONG':
|
||||
flip_pnl_half = (flip_price - entry) / rd
|
||||
else:
|
||||
flip_pnl_half = (entry - flip_price) / rd
|
||||
if tp1_hit:
|
||||
result_pnl_r = 0.5 * tp1_r + 0.5 * flip_pnl_half
|
||||
else:
|
||||
result_pnl_r = flip_pnl_half
|
||||
result_status = 'signal_flip'
|
||||
result_exit_ts = flip_ts
|
||||
result_exit_price = flip_price
|
||||
else:
|
||||
# 真正的timeout
|
||||
cur.execute("""
|
||||
SELECT price FROM agg_trades
|
||||
WHERE symbol=%s AND time_ms <= %s
|
||||
ORDER BY time_ms DESC LIMIT 1
|
||||
""", (symbol, timeout_ts))
|
||||
lp = cur.fetchone()
|
||||
exit_price = lp[0] if lp else entry
|
||||
if direction == 'LONG':
|
||||
timeout_half_pnl = (exit_price - entry) / rd
|
||||
else:
|
||||
timeout_half_pnl = (entry - exit_price) / rd
|
||||
if tp1_hit:
|
||||
result_pnl_r = 0.5 * tp1_r + 0.5 * timeout_half_pnl
|
||||
else:
|
||||
result_pnl_r = timeout_half_pnl
|
||||
result_status = 'timeout'
|
||||
result_exit_ts = timeout_ts
|
||||
result_exit_price = exit_price
|
||||
|
||||
# 扣手续费: fee_r = 2 * taker_rate * entry / rd(开仓+平仓各一次)
|
||||
PAPER_FEE_RATE = 0.0005
|
||||
fee_r = 2 * PAPER_FEE_RATE * entry / rd if rd > 0 else 0
|
||||
result_pnl_r -= fee_r
|
||||
|
||||
return {
|
||||
'id': tid,
|
||||
'entry': entry,
|
||||
'rd': rd,
|
||||
'tp1': tp1,
|
||||
'tp2': tp2,
|
||||
'sl_orig': sl_orig,
|
||||
'tp1_hit': tp1_hit,
|
||||
'tp1_hit_ts': tp1_hit_ts,
|
||||
'status': result_status,
|
||||
'exit_ts': result_exit_ts,
|
||||
'exit_price': result_exit_price,
|
||||
'pnl_r': round(result_pnl_r, 4),
|
||||
}, None
|
||||
|
||||
|
||||
def main(dry_run=False):
|
||||
conn = psycopg2.connect(host='10.106.0.3', dbname='arb_engine', user='arb', password='arb_engine_2026')
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("""
|
||||
SELECT id, symbol, direction, strategy, entry_ts, atr_at_entry
|
||||
FROM paper_trades
|
||||
WHERE atr_at_entry > 0
|
||||
AND status NOT IN ('active', 'tp1_hit')
|
||||
AND COALESCE(calc_version, 0) < 2
|
||||
ORDER BY id ASC
|
||||
""")
|
||||
trades = cur.fetchall()
|
||||
print(f"总计 {len(trades)} 笔待重放")
|
||||
|
||||
results = []
|
||||
errors = []
|
||||
for tid, symbol, direction, strategy, entry_ts, atr in trades:
|
||||
res, err = replay_trade(cur, tid, symbol, direction, strategy, entry_ts, atr)
|
||||
if err:
|
||||
errors.append((tid, err))
|
||||
else:
|
||||
results.append((res, strategy))
|
||||
|
||||
print(f"成功重放: {len(results)}, 错误: {len(errors)}")
|
||||
for e in errors[:5]:
|
||||
print(f" 错误: {e}")
|
||||
|
||||
if not dry_run and results:
|
||||
for res, _ in results:
|
||||
r = res
|
||||
cur.execute("""
|
||||
UPDATE paper_trades SET
|
||||
entry_price = %s,
|
||||
tp1_price = %s,
|
||||
tp2_price = %s,
|
||||
sl_price = %s,
|
||||
tp1_hit = %s,
|
||||
status = %s,
|
||||
exit_price = %s,
|
||||
exit_ts = %s,
|
||||
pnl_r = %s,
|
||||
risk_distance = %s,
|
||||
price_source = 'last_trade',
|
||||
calc_version = 2
|
||||
WHERE id = %s
|
||||
""", (
|
||||
r['entry'], r['tp1'], r['tp2'], r['sl_orig'],
|
||||
r['tp1_hit'], r['status'], r['exit_price'], r['exit_ts'],
|
||||
r['pnl_r'], r['rd'], r['id']
|
||||
))
|
||||
conn.commit()
|
||||
print(f"已写入 {len(results)} 笔")
|
||||
|
||||
# 统计
|
||||
by_strategy = {}
|
||||
for res, strat in results:
|
||||
if strat not in by_strategy:
|
||||
by_strategy[strat] = {'n': 0, 'wins': 0, 'total_r': 0.0, 'status': {}}
|
||||
by_strategy[strat]['n'] += 1
|
||||
by_strategy[strat]['total_r'] += res['pnl_r']
|
||||
if res['pnl_r'] > 0:
|
||||
by_strategy[strat]['wins'] += 1
|
||||
s = res['status']
|
||||
by_strategy[strat]['status'][s] = by_strategy[strat]['status'].get(s, 0) + 1
|
||||
|
||||
print("\n===== 重放统计(真实价口径)=====")
|
||||
for strat, s in sorted(by_strategy.items()):
|
||||
win_pct = s['wins'] / s['n'] * 100 if s['n'] > 0 else 0
|
||||
print(f" {strat}: {s['n']}笔, 胜率{win_pct:.1f}%, 总R={s['total_r']:+.2f}")
|
||||
for st, c in sorted(s['status'].items()):
|
||||
print(f" {st}: {c}")
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
dry = '--dry-run' in sys.argv
|
||||
print(f"模式: {'DRY RUN(不写入)' if dry else '正式写入'}")
|
||||
main(dry_run=dry)
|
||||
196
signal-engine.log
Normal file
196
signal-engine.log
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user