Compare commits

..

No commits in common. "main" and "v5.2-ab-start" have entirely different histories.

73 changed files with 382 additions and 15644 deletions

1
.gitignore vendored
View File

@ -1,2 +1 @@
__pycache__/ __pycache__/
logs/*.log

View File

@ -1 +0,0 @@
*.csv

View File

@ -1,19 +0,0 @@
# 模拟盘历史归档 - 2026-03-03
归档时间2026-03-03V5.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需要同一起点开始跑才能做公平对照。

View File

@ -7,7 +7,6 @@ agg_trades_collector.py — aggTrades全量采集守护进程PostgreSQL版
- 每分钟巡检校验agg_id连续性发现断档自动补洞 - 每分钟巡检校验agg_id连续性发现断档自动补洞
- 批量写入攒200条或1秒flush一次 - 批量写入攒200条或1秒flush一次
- PG分区表按月自动分区MVCC并发无锁冲突 - PG分区表按月自动分区MVCC并发无锁冲突
- 统一写入 Cloud SQL双写机制已移除
""" """
import asyncio import asyncio
@ -23,7 +22,7 @@ import psycopg2
import psycopg2.extras import psycopg2.extras
import websockets import websockets
from db import get_sync_conn, get_sync_pool, ensure_partitions, PG_HOST 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
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
@ -70,10 +69,11 @@ def update_meta(conn, symbol: str, last_agg_id: int, last_time_ms: int):
def flush_buffer(symbol: str, trades: list) -> int: def flush_buffer(symbol: str, trades: list) -> int:
"""写入一批trades到Cloud SQL返回实际写入条数""" """写入一批trades到PG本地+Cloud SQL双写),返回实际写入条数"""
if not trades: if not trades:
return 0 return 0
try: try:
# 确保分区存在
ensure_partitions() ensure_partitions()
values = [] values = []
@ -98,6 +98,7 @@ def flush_buffer(symbol: str, trades: list) -> int:
ON CONFLICT (time_ms, symbol, agg_id) DO NOTHING""" ON CONFLICT (time_ms, symbol, agg_id) DO NOTHING"""
insert_template = "(%s, %s, %s, %s, %s, %s)" insert_template = "(%s, %s, %s, %s, %s, %s)"
# 写本地PG
inserted = 0 inserted = 0
with get_sync_conn() as conn: with get_sync_conn() as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
@ -110,6 +111,22 @@ def flush_buffer(symbol: str, trades: list) -> int:
update_meta(conn, symbol, last_agg_id, last_time_ms) update_meta(conn, symbol, last_agg_id, last_time_ms)
conn.commit() 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 return inserted
except Exception as e: except Exception as e:
logger.error(f"flush_buffer [{symbol}] error: {e}") logger.error(f"flush_buffer [{symbol}] error: {e}")

View File

@ -12,11 +12,7 @@ from pydantic import BaseModel, EmailStr
from db import get_sync_conn from db import get_sync_conn
_TRADE_ENV = os.getenv("TRADE_ENV", "testnet") JWT_SECRET = os.getenv("JWT_SECRET", "arb-engine-jwt-secret-v2-2026")
_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 ACCESS_TOKEN_HOURS = 24
REFRESH_TOKEN_DAYS = 7 REFRESH_TOKEN_DAYS = 7

View File

@ -34,7 +34,7 @@ logging.basicConfig(
) )
logger = logging.getLogger("backtest") logger = logging.getLogger("backtest")
PG_HOST = os.getenv("PG_HOST", "10.106.0.3") PG_HOST = os.getenv("PG_HOST", "127.0.0.1")
PG_PORT = int(os.getenv("PG_PORT", "5432")) PG_PORT = int(os.getenv("PG_PORT", "5432"))
PG_DB = os.getenv("PG_DB", "arb_engine") PG_DB = os.getenv("PG_DB", "arb_engine")
PG_USER = os.getenv("PG_USER", "arb") PG_USER = os.getenv("PG_USER", "arb")

View File

@ -1,6 +1,5 @@
""" """
db.py PostgreSQL 数据库连接层 db.py PostgreSQL 数据库连接层
统一连接到 Cloud SQLPG_HOST 默认 10.106.0.3
同步连接池psycopg2供脚本类使用 同步连接池psycopg2供脚本类使用
异步连接池asyncpg供FastAPI使用 异步连接池asyncpg供FastAPI使用
""" """
@ -12,17 +11,23 @@ import psycopg2
import psycopg2.pool import psycopg2.pool
from contextlib import contextmanager from contextlib import contextmanager
# PG连接参数统一连接 Cloud SQL # PG连接参数本地
PG_HOST = os.getenv("PG_HOST", "10.106.0.3") PG_HOST = os.getenv("PG_HOST", "127.0.0.1")
PG_PORT = int(os.getenv("PG_PORT", 5432)) PG_PORT = int(os.getenv("PG_PORT", 5432))
PG_DB = os.getenv("PG_DB", "arb_engine") PG_DB = os.getenv("PG_DB", "arb_engine")
PG_USER = os.getenv("PG_USER", "arb") PG_USER = os.getenv("PG_USER", "arb")
PG_PASS = os.getenv("PG_PASS") PG_PASS = os.getenv("PG_PASS", "arb_engine_2026")
if not PG_PASS:
raise RuntimeError("PG_PASS 未设置,请在 .env 或环境变量中注入数据库密码")
PG_DSN = f"postgresql://{PG_USER}:{PG_PASS}@{PG_HOST}:{PG_PORT}/{PG_DB}" 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───────────────────────────────────── # ─── 同步连接池psycopg2─────────────────────────────────────
_sync_pool = None _sync_pool = None
@ -31,7 +36,7 @@ def get_sync_pool() -> psycopg2.pool.ThreadedConnectionPool:
global _sync_pool global _sync_pool
if _sync_pool is None: if _sync_pool is None:
_sync_pool = psycopg2.pool.ThreadedConnectionPool( _sync_pool = psycopg2.pool.ThreadedConnectionPool(
minconn=2, maxconn=20, minconn=1, maxconn=5,
host=PG_HOST, port=PG_PORT, host=PG_HOST, port=PG_PORT,
dbname=PG_DB, user=PG_USER, password=PG_PASS, dbname=PG_DB, user=PG_USER, password=PG_PASS,
) )
@ -68,6 +73,51 @@ def sync_executemany(sql: str, params_list: list):
conn.commit() 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───────────────────────────────────── # ─── 异步连接池asyncpg─────────────────────────────────────
_async_pool: asyncpg.Pool | None = None _async_pool: asyncpg.Pool | None = None
@ -156,7 +206,6 @@ CREATE TABLE IF NOT EXISTS signal_indicators (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
ts BIGINT NOT NULL, ts BIGINT NOT NULL,
symbol TEXT NOT NULL, symbol TEXT NOT NULL,
strategy TEXT,
cvd_fast DOUBLE PRECISION, cvd_fast DOUBLE PRECISION,
cvd_mid DOUBLE PRECISION, cvd_mid DOUBLE PRECISION,
cvd_day DOUBLE PRECISION, cvd_day DOUBLE PRECISION,
@ -170,12 +219,10 @@ CREATE TABLE IF NOT EXISTS signal_indicators (
buy_vol_1m DOUBLE PRECISION, buy_vol_1m DOUBLE PRECISION,
sell_vol_1m DOUBLE PRECISION, sell_vol_1m DOUBLE PRECISION,
score INTEGER, 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_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_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 ( CREATE TABLE IF NOT EXISTS signal_indicators_1m (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
@ -209,7 +256,7 @@ CREATE TABLE IF NOT EXISTS signal_trades (
status TEXT DEFAULT 'open' status TEXT DEFAULT 'open'
); );
-- 信号日志旧表兼容保留不再写入新数据 -- 信号日志旧表兼容
CREATE TABLE IF NOT EXISTS signal_logs ( CREATE TABLE IF NOT EXISTS signal_logs (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
symbol TEXT, symbol TEXT,
@ -219,32 +266,7 @@ CREATE TABLE IF NOT EXISTS signal_logs (
message TEXT message TEXT
); );
-- 市场指标 market_data_collector 写入 -- 用户表auth
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 ( CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL, email TEXT UNIQUE NOT NULL,
@ -260,13 +282,6 @@ CREATE TABLE IF NOT EXISTS invite_codes (
created_at TIMESTAMP DEFAULT NOW() 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 ( CREATE TABLE IF NOT EXISTS paper_trades (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
@ -285,115 +300,9 @@ CREATE TABLE IF NOT EXISTS paper_trades (
status TEXT DEFAULT 'active', status TEXT DEFAULT 'active',
pnl_r DOUBLE PRECISION DEFAULT 0, pnl_r DOUBLE PRECISION DEFAULT 0,
atr_at_entry DOUBLE PRECISION DEFAULT 0, atr_at_entry DOUBLE PRECISION DEFAULT 0,
risk_distance DOUBLE PRECISION,
score_factors JSONB, score_factors JSONB,
created_at TIMESTAMP DEFAULT NOW() 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()
);
""" """
@ -413,6 +322,7 @@ def ensure_partitions():
for m in set(months): for m in set(months):
year = int(m[:4]) year = int(m[:4])
month = int(m[4:]) month = int(m[4:])
# 计算分区范围UTC毫秒时间戳
start = datetime.datetime(year, month, 1, tzinfo=datetime.timezone.utc) start = datetime.datetime(year, month, 1, tzinfo=datetime.timezone.utc)
if month == 12: if month == 12:
end = datetime.datetime(year + 1, 1, 1, tzinfo=datetime.timezone.utc) end = datetime.datetime(year + 1, 1, 1, tzinfo=datetime.timezone.utc)
@ -441,23 +351,13 @@ def init_schema():
if stmt: if stmt:
try: try:
cur.execute(stmt) cur.execute(stmt)
except Exception: except Exception as e:
conn.rollback() conn.rollback()
# 忽略已存在错误
continue continue
# 补全字段(向前兼容旧部署) cur.execute(
migrations = [ "ALTER TABLE paper_trades "
"ALTER TABLE paper_trades ADD COLUMN IF NOT EXISTS strategy VARCHAR(32) DEFAULT 'v51_baseline'", "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() conn.commit()
ensure_partitions() ensure_partitions()

View File

@ -1,709 +0,0 @@
#!/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())

View File

File diff suppressed because it is too large Load Diff

View File

@ -12,13 +12,11 @@ from psycopg2.extras import Json
SYMBOLS = ["BTCUSDT", "ETHUSDT", "XRPUSDT", "SOLUSDT"] SYMBOLS = ["BTCUSDT", "ETHUSDT", "XRPUSDT", "SOLUSDT"]
INTERVAL_SECONDS = 300 INTERVAL_SECONDS = 300
PG_HOST = os.getenv("PG_HOST", "10.106.0.3") PG_HOST = os.getenv("PG_HOST", "127.0.0.1")
PG_PORT = int(os.getenv("PG_PORT", "5432")) PG_PORT = int(os.getenv("PG_PORT", "5432"))
PG_DB = os.getenv("PG_DB", "arb_engine") PG_DB = os.getenv("PG_DB", "arb_engine")
PG_USER = os.getenv("PG_USER", "arb") PG_USER = os.getenv("PG_USER", "arb")
PG_PASS = os.getenv("PG_PASS") PG_PASS = os.getenv("PG_PASS", "arb_engine_2026")
if not PG_PASS:
raise RuntimeError("PG_PASS 未设置,请在 .env 或环境变量中注入数据库密码")
TABLE_SQL = """ TABLE_SQL = """
CREATE TABLE IF NOT EXISTS market_indicators ( CREATE TABLE IF NOT EXISTS market_indicators (
@ -115,9 +113,7 @@ class MarketDataCollector:
"BTCUSDT": "BTC-USD", "BTCUSDT": "BTC-USD",
"ETHUSDT": "ETH-USD", "ETHUSDT": "ETH-USD",
} }
coinbase_pair = pair_map.get(symbol) coinbase_pair = pair_map[symbol]
if not coinbase_pair:
return # XRP/SOL无Coinbase数据跳过
binance_url = "https://api.binance.com/api/v3/ticker/price" binance_url = "https://api.binance.com/api/v3/ticker/price"
coinbase_url = f"https://api.coinbase.com/v2/prices/{coinbase_pair}/spot" coinbase_url = f"https://api.coinbase.com/v2/prices/{coinbase_pair}/spot"
@ -140,140 +136,13 @@ class MarketDataCollector:
self.save_indicator(symbol, "coinbase_premium", ts, payload) self.save_indicator(symbol, "coinbase_premium", ts, payload)
async def collect_funding_rate(self, session: aiohttp.ClientSession, symbol: str) -> None: async def collect_funding_rate(self, session: aiohttp.ClientSession, symbol: str) -> None:
endpoint = "https://fapi.binance.com/fapi/v1/premiumIndex" endpoint = "https://fapi.binance.com/fapi/v1/fundingRate"
data = await self.fetch_json(session, endpoint, {"symbol": symbol}) data = await self.fetch_json(session, endpoint, {"symbol": symbol, "limit": 1})
if not data: if not data:
raise RuntimeError("empty response") raise RuntimeError("empty response")
# premiumIndex returns a single object (not array) item = data[0]
item = data if isinstance(data, dict) else data[0] ts = int(item.get("fundingTime") or int(time.time() * 1000))
# Use current time as timestamp so every 5-min poll stores a new row self.save_indicator(symbol, "funding_rate", ts, item)
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: async def collect_symbol(self, session: aiohttp.ClientSession, symbol: str) -> None:
tasks = [ tasks = [
@ -282,9 +151,6 @@ class MarketDataCollector:
("open_interest_hist", self.collect_open_interest_hist(session, symbol)), ("open_interest_hist", self.collect_open_interest_hist(session, symbol)),
("coinbase_premium", self.collect_coinbase_premium(session, symbol)), ("coinbase_premium", self.collect_coinbase_premium(session, symbol)),
("funding_rate", self.collect_funding_rate(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) results = await asyncio.gather(*(t[1] for t in tasks), return_exceptions=True)

View File

@ -1,10 +1,6 @@
{ {
"enabled": true, "enabled": true,
"enabled_strategies": [ "enabled_strategies": ["v52_8signals"],
"v53",
"v53_fast",
"v53_middle"
],
"initial_balance": 10000, "initial_balance": 10000,
"risk_per_trade": 0.02, "risk_per_trade": 0.02,
"max_positions": 4, "max_positions": 4,

View File

@ -49,20 +49,20 @@ def check_and_close(symbol_upper: str, price: float):
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute( cur.execute(
"SELECT id, direction, entry_price, tp1_price, tp2_price, sl_price, " "SELECT id, direction, entry_price, tp1_price, tp2_price, sl_price, "
"tp1_hit, entry_ts, atr_at_entry, risk_distance " "tp1_hit, entry_ts, atr_at_entry "
"FROM paper_trades WHERE symbol=%s AND status IN ('active','tp1_hit')", "FROM paper_trades WHERE symbol=%s AND status IN ('active','tp1_hit')",
(symbol_upper,) (symbol_upper,)
) )
positions = cur.fetchall() positions = cur.fetchall()
for pos in positions: for pos in positions:
pid, direction, entry_price, tp1, tp2, sl, tp1_hit, entry_ts, atr_entry, rd_db = pos pid, direction, entry_price, tp1, tp2, sl, tp1_hit, entry_ts, atr_entry = pos
closed = False closed = False
new_status = None new_status = None
pnl_r = 0.0 pnl_r = 0.0
# 从DB读risk_distancefallback用|entry-sl| # 统一计算risk_distance (1R基准距离)
risk_distance = rd_db if rd_db and rd_db > 0 else abs(entry_price - sl) risk_distance = 2.0 * 0.7 * atr_entry if atr_entry > 0 else 1
# === 实盘模拟TP/SL视为限价单以挂单价成交非市价 === # === 实盘模拟TP/SL视为限价单以挂单价成交非市价 ===
if direction == "LONG": if direction == "LONG":
@ -128,6 +128,7 @@ def check_and_close(symbol_upper: str, price: float):
if closed: 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 fee_r = (2 * FEE_RATE * entry_price) / risk_distance if risk_distance > 0 else 0
pnl_r -= fee_r pnl_r -= fee_r

View File

@ -1,686 +0,0 @@
#!/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())

View File

@ -1,645 +0,0 @@
#!/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())

View File

@ -19,14 +19,10 @@ import logging
import os import os
import time import time
import json import json
import threading
import asyncio
from collections import deque from collections import deque
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Optional from typing import Any, Optional
import websockets
from db import get_sync_conn, init_schema from db import get_sync_conn, init_schema
logging.basicConfig( logging.basicConfig(
@ -42,7 +38,7 @@ logger = logging.getLogger("signal-engine")
SYMBOLS = ["BTCUSDT", "ETHUSDT", "XRPUSDT", "SOLUSDT"] SYMBOLS = ["BTCUSDT", "ETHUSDT", "XRPUSDT", "SOLUSDT"]
LOOP_INTERVAL = 15 # 秒从5改15CPU降60%,信号质量无影响) LOOP_INTERVAL = 15 # 秒从5改15CPU降60%,信号质量无影响)
STRATEGY_DIR = os.path.join(os.path.dirname(__file__), "strategies") STRATEGY_DIR = os.path.join(os.path.dirname(__file__), "strategies")
DEFAULT_STRATEGY_FILES = ["v51_baseline.json", "v52_8signals.json", "v53.json", "v53_fast.json", "v53_middle.json"] DEFAULT_STRATEGY_FILES = ["v51_baseline.json", "v52_8signals.json"]
def load_strategy_configs() -> list[dict]: def load_strategy_configs() -> list[dict]:
@ -130,12 +126,7 @@ def fetch_market_indicators(symbol: str) -> dict:
with get_sync_conn() as conn: with get_sync_conn() as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
indicators = {} indicators = {}
ind_types = [ for ind_type in ["long_short_ratio", "top_trader_position", "open_interest_hist", "coinbase_premium", "funding_rate"]:
"long_short_ratio", "top_trader_position", "open_interest_hist",
"coinbase_premium", "funding_rate",
"obi_depth_10", "spot_perp_divergence", "tiered_cvd_whale", # Phase 2
]
for ind_type in ind_types:
cur.execute( cur.execute(
"SELECT value FROM market_indicators WHERE symbol=%s AND indicator_type=%s ORDER BY timestamp_ms DESC LIMIT 1", "SELECT value FROM market_indicators WHERE symbol=%s AND indicator_type=%s ORDER BY timestamp_ms DESC LIMIT 1",
(symbol, ind_type), (symbol, ind_type),
@ -144,6 +135,7 @@ def fetch_market_indicators(symbol: str) -> dict:
if not row or row[0] is None: if not row or row[0] is None:
indicators[ind_type] = None indicators[ind_type] = None
continue continue
# value可能是JSON字符串或已解析的dict
val = row[0] val = row[0]
if isinstance(val, str): if isinstance(val, str):
try: try:
@ -159,18 +151,9 @@ def fetch_market_indicators(symbol: str) -> dict:
elif ind_type == "open_interest_hist": elif ind_type == "open_interest_hist":
indicators[ind_type] = float(val.get("sumOpenInterestValue", 0)) indicators[ind_type] = float(val.get("sumOpenInterestValue", 0))
elif ind_type == "coinbase_premium": elif ind_type == "coinbase_premium":
indicators[ind_type] = float(val.get("premium_pct", 0)) / 100.0 indicators[ind_type] = float(val.get("premium_pct", 0))
elif ind_type == "funding_rate": elif ind_type == "funding_rate":
indicators[ind_type] = float(val.get("fundingRate", val.get("lastFundingRate", 0))) indicators[ind_type] = float(val.get("fundingRate", val.get("lastFundingRate", 0)))
elif ind_type == "obi_depth_10":
# obi范围[-1,1],正值=买压,负值=卖压
indicators[ind_type] = float(val.get("obi", 0))
elif ind_type == "spot_perp_divergence":
# divergence = (spot - mark) / mark
indicators[ind_type] = float(val.get("divergence", 0))
elif ind_type == "tiered_cvd_whale":
# 巨鲸净CVD比率[-1,1],正值=净买入
indicators[ind_type] = float(val.get("whale_cvd_ratio", 0))
return indicators return indicators
@ -307,7 +290,6 @@ class SymbolState:
self.win_vwap = TradeWindow(WINDOW_VWAP) self.win_vwap = TradeWindow(WINDOW_VWAP)
self.atr_calc = ATRCalculator() self.atr_calc = ATRCalculator()
self.last_processed_id = 0 self.last_processed_id = 0
self.last_trade_price = 0.0
self.warmup = True self.warmup = True
self.prev_cvd_fast = 0.0 self.prev_cvd_fast = 0.0
self.prev_cvd_fast_slope = 0.0 self.prev_cvd_fast_slope = 0.0
@ -316,12 +298,6 @@ class SymbolState:
self.last_signal_ts: dict[str, int] = {} self.last_signal_ts: dict[str, int] = {}
self.last_signal_dir: dict[str, str] = {} self.last_signal_dir: dict[str, str] = {}
self.recent_large_trades: deque = deque() self.recent_large_trades: deque = deque()
# ── Phase 2 实时内存字段由后台WebSocket协程更新──────────
self.rt_obi: float = 0.0 # 订单簿失衡[-1,1]实时WebSocket所有symbol
self.rt_spot_perp_div: float = 0.0 # 期现背离spot-mark)/mark实时WebSocket所有symbol
# tiered_cvd_whale按成交额分档实时累计最近15分钟窗口
self._whale_trades: deque = deque() # (time_ms, usd_val, is_sell)
self.WHALE_WINDOW_MS: int = 15 * 60 * 1000 # 15分钟
def process_trade(self, agg_id: int, time_ms: int, price: float, qty: float, is_buyer_maker: int): def process_trade(self, agg_id: int, time_ms: int, price: float, qty: float, is_buyer_maker: int):
now_ms = time_ms now_ms = time_ms
@ -335,24 +311,6 @@ class SymbolState:
self.win_day.trim(now_ms) self.win_day.trim(now_ms)
self.win_vwap.trim(now_ms) self.win_vwap.trim(now_ms)
self.last_processed_id = agg_id self.last_processed_id = agg_id
self.last_trade_price = price # 最新成交价用于entry_price
# tiered_cvd_whale 实时累计(>$100k 为巨鲸)
usd_val = price * qty
if usd_val >= 100_000:
self._whale_trades.append((time_ms, usd_val, bool(is_buyer_maker)))
# 修剪15分钟窗口
cutoff = now_ms - self.WHALE_WINDOW_MS
while self._whale_trades and self._whale_trades[0][0] < cutoff:
self._whale_trades.popleft()
@property
def whale_cvd_ratio(self) -> float:
"""巨鲸净CVD比率[-1,1]基于最近15分钟>$100k成交"""
buy_usd = sum(t[1] for t in self._whale_trades if not t[2])
sell_usd = sum(t[1] for t in self._whale_trades if t[2])
total = buy_usd + sell_usd
return (buy_usd - sell_usd) / total if total > 0 else 0.0
def compute_p95_p99(self) -> tuple: def compute_p95_p99(self) -> tuple:
if len(self.win_day.trades) < 100: if len(self.win_day.trades) < 100:
@ -387,7 +345,7 @@ class SymbolState:
atr_pct = self.atr_calc.atr_percentile atr_pct = self.atr_calc.atr_percentile
p95, p99 = self.compute_p95_p99() p95, p99 = self.compute_p95_p99()
self.update_large_trades(now_ms, p99) self.update_large_trades(now_ms, p99)
price = self.last_trade_price if self.last_trade_price > 0 else vwap # 用最新成交价非VWAP price = vwap if vwap > 0 else 0
cvd_fast_slope = cvd_fast - self.prev_cvd_fast cvd_fast_slope = cvd_fast - self.prev_cvd_fast
cvd_fast_accel = cvd_fast_slope - self.prev_cvd_fast_slope cvd_fast_accel = cvd_fast_slope - self.prev_cvd_fast_slope
self.prev_cvd_fast = cvd_fast self.prev_cvd_fast = cvd_fast
@ -414,7 +372,6 @@ class SymbolState:
"cvd_day": cvd_day, "cvd_day": cvd_day,
"vwap": vwap, "vwap": vwap,
"atr": atr, "atr": atr,
"atr_value": atr, # V5.3: ATR绝对值快照用于feature_events落库
"atr_pct": atr_pct, "atr_pct": atr_pct,
"p95": p95, "p95": p95,
"p99": p99, "p99": p99,
@ -448,21 +405,6 @@ class SymbolState:
return None return None
def evaluate_signal(self, now_ms: int, strategy_cfg: Optional[dict] = None, snapshot: Optional[dict] = None) -> dict: def evaluate_signal(self, now_ms: int, strategy_cfg: Optional[dict] = None, snapshot: Optional[dict] = None) -> dict:
strategy_cfg = strategy_cfg or {}
strategy_name = strategy_cfg.get("name", "v51_baseline")
track = strategy_cfg.get("track", "ALT")
# ─── Track Router ───────────────────────────────────────────
# v53 → 统一评分BTC/ETH/XRP/SOL
# v53_alt / v53_btc → 兼容旧策略名,转发到 _evaluate_v53()
# v51/v52 → 原有代码路径(兼容,不修改)
if strategy_name.startswith("v53"):
allowed_symbols = strategy_cfg.get("symbols", [])
if allowed_symbols and self.symbol not in allowed_symbols:
snap = snapshot or self.build_evaluation_snapshot(now_ms)
return self._empty_result(strategy_name, snap)
return self._evaluate_v53(now_ms, strategy_cfg, snapshot)
# ─── 原有V5.1/V5.2评分逻辑(保持不变)────────────────────────
strategy_cfg = strategy_cfg or {} strategy_cfg = strategy_cfg or {}
strategy_name = strategy_cfg.get("name", "v51_baseline") strategy_name = strategy_cfg.get("name", "v51_baseline")
strategy_threshold = int(strategy_cfg.get("threshold", 75)) strategy_threshold = int(strategy_cfg.get("threshold", 75))
@ -705,283 +647,7 @@ class SymbolState:
self.last_signal_dir[strategy_name] = direction self.last_signal_dir[strategy_name] = direction
return result return result
def _empty_result(self, strategy_name: str, snap: dict) -> dict:
"""返回空评分结果symbol不匹配track时使用"""
return {
"strategy": strategy_name,
"cvd_fast": snap["cvd_fast"], "cvd_mid": snap["cvd_mid"],
"cvd_day": snap["cvd_day"], "cvd_fast_slope": snap["cvd_fast_slope"],
"atr": snap["atr"], "atr_value": snap.get("atr_value", snap["atr"]),
"atr_pct": snap["atr_pct"], "vwap": snap["vwap"], "price": snap["price"],
"p95": snap["p95"], "p99": snap["p99"],
"signal": None, "direction": None, "score": 0, "tier": None, "factors": {},
}
def _evaluate_v53(self, now_ms: int, strategy_cfg: dict, snapshot: Optional[dict] = None) -> dict:
"""
V5.3 统一评分BTC/ETH/XRP/SOL
架构四层评分 55/25/15/5 + per-symbol 四门控制
- 门1波动率下限atr/price
- 门2CVD共振fast+mid同向
- 门3OBI否决实时WebSocketfallback DB
- 门4期现背离否决实时WebSocketfallback DB
BTC额外whale_cvd_ratio>$100k巨鲸CVD
"""
strategy_name = strategy_cfg.get("name", "v53")
strategy_threshold = int(strategy_cfg.get("threshold", 75))
flip_threshold = int(strategy_cfg.get("flip_threshold", 85))
is_fast = strategy_name.endswith("_fast")
snap = snapshot or self.build_evaluation_snapshot(now_ms)
# v53_fast: 用自定义短窗口重算 cvd_fast / cvd_mid
if is_fast:
fast_ms = int(strategy_cfg.get("cvd_window_fast_ms", 5 * 60 * 1000))
mid_ms = int(strategy_cfg.get("cvd_window_mid_ms", 30 * 60 * 1000))
cutoff_fast = now_ms - fast_ms
cutoff_mid = now_ms - mid_ms
buy_f = sell_f = buy_m = sell_m = 0.0
for t_ms, qty, _price, ibm in self.win_fast.trades:
if t_ms >= cutoff_fast:
if ibm == 0: buy_f += qty
else: sell_f += qty
# mid 从 win_mid 中读win_mid 窗口是4h包含30min内数据
for t_ms, qty, _price, ibm in self.win_mid.trades:
if t_ms >= cutoff_mid:
if ibm == 0: buy_m += qty
else: sell_m += qty
cvd_fast = buy_f - sell_f
cvd_mid = buy_m - sell_m
else:
cvd_fast = snap["cvd_fast"]
cvd_mid = snap["cvd_mid"]
price = snap["price"]
atr = snap["atr"]
atr_value = snap.get("atr_value", atr)
cvd_fast_accel = snap["cvd_fast_accel"]
environment_score_raw = snap["environment_score"]
result = self._empty_result(strategy_name, snap)
if self.warmup or price == 0 or atr == 0:
return result
last_signal_ts = self.last_signal_ts.get(strategy_name, 0)
in_cooldown = now_ms - last_signal_ts < COOLDOWN_MS
# ── Per-symbol 四门参数 ────────────────────────────────────
symbol_gates = (strategy_cfg.get("symbol_gates") or {}).get(self.symbol, {})
min_vol = float(symbol_gates.get("min_vol_threshold", 0.002))
whale_usd = float(symbol_gates.get("whale_threshold_usd", 50000))
obi_veto = float(symbol_gates.get("obi_veto_threshold", 0.35))
spd_veto = float(symbol_gates.get("spot_perp_divergence_veto", 0.005))
gate_block = None
# 门1波动率下限
atr_pct_price = atr / price if price > 0 else 0
if atr_pct_price < min_vol:
gate_block = f"low_vol({atr_pct_price:.4f}<{min_vol})"
# 门2CVD共振方向门
if cvd_fast > 0 and cvd_mid > 0:
direction = "LONG"
cvd_resonance = 30
no_direction = False
elif cvd_fast < 0 and cvd_mid < 0:
direction = "SHORT"
cvd_resonance = 30
no_direction = False
else:
direction = "LONG" if cvd_fast > 0 else "SHORT"
cvd_resonance = 0
no_direction = True
if not gate_block:
gate_block = "no_direction_consensus"
# 门3鲸鱼否决BTC用whale_cvd_ratioALT用大单对立
if not gate_block and not no_direction:
if self.symbol == "BTCUSDT":
# BTC巨鲸CVD净方向与信号方向冲突时否决
whale_cvd = self.whale_cvd_ratio if self._whale_trades else to_float(self.market_indicators.get("tiered_cvd_whale")) or 0.0
whale_threshold_pct = float(symbol_gates.get("whale_flow_threshold_pct", 0.5)) / 100
if (direction == "LONG" and whale_cvd < -whale_threshold_pct) or \
(direction == "SHORT" and whale_cvd > whale_threshold_pct):
gate_block = f"whale_cvd_veto({whale_cvd:.3f})"
else:
# ALTrecent_large_trades 里有对立大单则否决
whale_adverse = any(
(direction == "LONG" and lt[2] == 1 and lt[1] * price >= whale_usd) or
(direction == "SHORT" and lt[2] == 0 and lt[1] * price >= whale_usd)
for lt in self.recent_large_trades
)
whale_aligned = any(
(direction == "LONG" and lt[2] == 0 and lt[1] * price >= whale_usd) or
(direction == "SHORT" and lt[2] == 1 and lt[1] * price >= whale_usd)
for lt in self.recent_large_trades
)
if whale_adverse and not whale_aligned:
gate_block = f"whale_adverse(>${whale_usd/1000:.0f}k)"
# 门4OBI否决实时WS优先fallback DB
obi_raw = self.rt_obi if self.rt_obi != 0.0 else to_float(self.market_indicators.get("obi_depth_10"))
if not gate_block and not no_direction and obi_raw is not None:
if direction == "LONG" and obi_raw < -obi_veto:
gate_block = f"obi_veto({obi_raw:.3f}<-{obi_veto})"
elif direction == "SHORT" and obi_raw > obi_veto:
gate_block = f"obi_veto({obi_raw:.3f}>{obi_veto})"
# 门5期现背离否决实时WS优先fallback DB
spot_perp_div = self.rt_spot_perp_div if self.rt_spot_perp_div != 0.0 else to_float(self.market_indicators.get("spot_perp_divergence"))
if not gate_block and not no_direction and spot_perp_div is not None:
if (direction == "LONG" and spot_perp_div < -spd_veto) or \
(direction == "SHORT" and spot_perp_div > spd_veto):
gate_block = f"spd_veto({spot_perp_div:.4f})"
gate_passed = gate_block is None
# ── Direction Layer55分─────────────────────────────────
has_adverse_p99 = any(
(direction == "LONG" and lt[2] == 1) or (direction == "SHORT" and lt[2] == 0)
for lt in self.recent_large_trades
)
has_aligned_p99 = any(
(direction == "LONG" and lt[2] == 0) or (direction == "SHORT" and lt[2] == 1)
for lt in self.recent_large_trades
)
p99_flow = 20 if has_aligned_p99 else (10 if not has_adverse_p99 else 0)
accel_bonus = 5 if (
(direction == "LONG" and cvd_fast_accel > 0) or
(direction == "SHORT" and cvd_fast_accel < 0)
) else 0
# v53_fastaccel 独立触发路径(不要求 cvd 双线同向)
accel_independent_score = 0
if is_fast and not no_direction:
accel_cfg = strategy_cfg.get("accel_independent", {})
if accel_cfg.get("enabled", False):
# accel 足够大 + p99 大单同向 → 独立给分
accel_strong = (
(direction == "LONG" and cvd_fast_accel > 0 and has_aligned_p99) or
(direction == "SHORT" and cvd_fast_accel < 0 and has_aligned_p99)
)
if accel_strong:
accel_independent_score = int(accel_cfg.get("min_direction_score", 35))
direction_score = max(min(cvd_resonance + p99_flow + accel_bonus, 55), accel_independent_score)
# ── Crowding Layer25分─────────────────────────────────
long_short_ratio = to_float(self.market_indicators.get("long_short_ratio"))
if long_short_ratio is None:
ls_score = 7
elif (direction == "SHORT" and long_short_ratio > 2.0) or (direction == "LONG" and long_short_ratio < 0.5):
ls_score = 15
elif (direction == "SHORT" and long_short_ratio > 1.5) or (direction == "LONG" and long_short_ratio < 0.7):
ls_score = 10
elif (direction == "SHORT" and long_short_ratio > 1.0) or (direction == "LONG" and long_short_ratio < 1.0):
ls_score = 7
else:
ls_score = 0
top_trader_position = to_float(self.market_indicators.get("top_trader_position"))
if top_trader_position is None:
top_trader_score = 5
else:
if direction == "LONG":
top_trader_score = 10 if top_trader_position >= 0.55 else (0 if top_trader_position <= 0.45 else 5)
else:
top_trader_score = 10 if top_trader_position <= 0.45 else (0 if top_trader_position >= 0.55 else 5)
crowding_score = min(ls_score + top_trader_score, 25)
# ── Environment Layer15分──────────────────────────────
# OI变化率基础分v53: 0~15
oi_base_score = round(environment_score_raw / 15 * 10) # 缩到10分
# v53_fastOBI 正向加分5分放入 environment 层)
obi_bonus = 0
if is_fast and obi_raw is not None:
obi_cfg = strategy_cfg.get("obi_scoring", {})
strong_thr = float(obi_cfg.get("strong_threshold", 0.30))
weak_thr = float(obi_cfg.get("weak_threshold", 0.15))
strong_sc = int(obi_cfg.get("strong_score", 5))
weak_sc = int(obi_cfg.get("weak_score", 3))
obi_aligned = (direction == "LONG" and obi_raw > 0) or (direction == "SHORT" and obi_raw < 0)
obi_abs = abs(obi_raw)
if obi_aligned:
if obi_abs >= strong_thr:
obi_bonus = strong_sc
elif obi_abs >= weak_thr:
obi_bonus = weak_sc
environment_score = min(oi_base_score + obi_bonus, 15) if is_fast else round(environment_score_raw / 15 * 15)
# ── Auxiliary Layer5分────────────────────────────────
coinbase_premium = to_float(self.market_indicators.get("coinbase_premium"))
if coinbase_premium is None:
aux_score = 2
elif (direction == "LONG" and coinbase_premium > 0.0005) or (direction == "SHORT" and coinbase_premium < -0.0005):
aux_score = 5
elif abs(coinbase_premium) <= 0.0005:
aux_score = 2
else:
aux_score = 0
total_score = min(direction_score + crowding_score + environment_score + aux_score, 100)
total_score = max(0, round(total_score, 1))
if not gate_passed:
total_score = 0
# whale_cvd for BTC display
whale_cvd_display = (self.whale_cvd_ratio if self._whale_trades else to_float(self.market_indicators.get("tiered_cvd_whale"))) if self.symbol == "BTCUSDT" else None
result.update({
"score": total_score,
"direction": direction if (not no_direction and gate_passed) else None,
"atr_value": atr_value,
"cvd_fast_5m": cvd_fast if is_fast else None, # v53_fast: 存5m实算CVD
"factors": {
"track": "BTC" if self.symbol == "BTCUSDT" else "ALT",
"gate_passed": gate_passed,
"gate_block": gate_block,
"atr_pct_price": round(atr_pct_price, 5),
"obi_raw": obi_raw,
"spot_perp_div": spot_perp_div,
"whale_cvd_ratio": whale_cvd_display,
"direction": {
"score": direction_score, "max": 55,
"cvd_resonance": cvd_resonance, "p99_flow": p99_flow, "accel_bonus": accel_bonus,
},
"crowding": {
"score": crowding_score, "max": 25,
"lsr_contrarian": ls_score, "top_trader_position": top_trader_score,
},
"environment": {"score": environment_score, "max": 15, "oi_base": oi_base_score if is_fast else environment_score, "obi_bonus": obi_bonus},
"auxiliary": {"score": aux_score, "max": 5, "coinbase_premium": coinbase_premium},
},
})
if not no_direction and gate_passed and not in_cooldown:
if total_score >= flip_threshold:
result["signal"] = direction
result["tier"] = "heavy"
elif total_score >= strategy_threshold:
result["signal"] = direction
result["tier"] = "standard"
if result["signal"]:
self.last_signal_ts[strategy_name] = now_ms
self.last_signal_dir[strategy_name] = direction
return result
def _evaluate_v53_alt(self, now_ms: int, strategy_cfg: dict, snapshot: Optional[dict] = None) -> dict:
"""已废弃,由 _evaluate_v53() 统一处理,保留供兼容"""
return self._evaluate_v53(now_ms, strategy_cfg, snapshot)
def _evaluate_v53_btc(self, now_ms: int, strategy_cfg: dict, snapshot: Optional[dict] = None) -> dict:
"""已废弃,由 _evaluate_v53() 统一处理,保留供兼容"""
return self._evaluate_v53(now_ms, strategy_cfg, snapshot)
# ─── PG DB操作 ───────────────────────────────────────────────────
# ─── PG DB操作 ─────────────────────────────────────────────────── # ─── PG DB操作 ───────────────────────────────────────────────────
def load_historical(state: SymbolState, window_ms: int): def load_historical(state: SymbolState, window_ms: int):
@ -1023,74 +689,13 @@ def save_indicator(ts: int, symbol: str, result: dict, strategy: str = "v52_8sig
with conn.cursor() as cur: with conn.cursor() as cur:
import json as _json3 import json as _json3
factors_json = _json3.dumps(result.get("factors")) if result.get("factors") else None factors_json = _json3.dumps(result.get("factors")) if result.get("factors") else None
cvd_fast_5m = result.get("cvd_fast_5m") # v53_fast 专用5m窗口CVD其他策略为None
cur.execute( cur.execute(
"INSERT INTO signal_indicators " "INSERT INTO signal_indicators "
"(ts,symbol,strategy,cvd_fast,cvd_mid,cvd_day,cvd_fast_slope,atr_5m,atr_percentile,atr_value,vwap_30m,price,p95_qty,p99_qty,score,signal,factors,cvd_fast_5m) " "(ts,symbol,strategy,cvd_fast,cvd_mid,cvd_day,cvd_fast_slope,atr_5m,atr_percentile,vwap_30m,price,p95_qty,p99_qty,score,signal,factors) "
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)",
(ts, symbol, strategy, result["cvd_fast"], result["cvd_mid"], result["cvd_day"], result["cvd_fast_slope"], (ts, symbol, strategy, result["cvd_fast"], result["cvd_mid"], result["cvd_day"], result["cvd_fast_slope"],
result["atr"], result["atr_pct"], result.get("atr_value", result["atr"]), result["atr"], result["atr_pct"], result["vwap"], result["price"],
result["vwap"], result["price"], result["p95"], result["p99"], result["score"], result.get("signal"), factors_json)
result["p95"], result["p99"], result["score"], result.get("signal"), factors_json,
cvd_fast_5m)
)
# 有信号时通知live_executor
if result.get("signal"):
cur.execute("NOTIFY new_signal, %s", (f"{symbol}:{strategy}:{result['signal']}:{result['score']}",))
conn.commit()
def save_feature_event(ts: int, symbol: str, result: dict, strategy: str):
"""
V5.3 专用每次评分后把 raw features + score 层写入 signal_feature_events
只对 v53_alt / v53_btc 调用其他策略跳过
"""
if not strategy.startswith("v53"):
return
f = result.get("factors") or {}
track = f.get("track", "ALT")
side = result.get("direction") or ("LONG" if result.get("score", 0) >= 0 else "SHORT")
score_direction = (f.get("direction") or {}).get("score", 0) if track == "ALT" else (f.get("direction") or {}).get("score", 0)
score_crowding = (f.get("crowding") or {}).get("score", 0)
score_env = (f.get("environment") or {}).get("score", 0)
score_aux = (f.get("auxiliary") or {}).get("score", 0)
gate_passed = f.get("gate_passed", True)
block_reason = f.get("gate_block") or f.get("block_reason")
with get_sync_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO signal_feature_events
(ts, symbol, track, side, strategy, strategy_version,
cvd_fast_raw, cvd_mid_raw, cvd_day_raw, cvd_fast_slope_raw,
p95_qty_raw, p99_qty_raw,
atr_value, atr_percentile,
oi_delta_raw, ls_ratio_raw, top_pos_raw, coinbase_premium_raw,
obi_raw, tiered_cvd_whale_raw,
score_direction, score_crowding, score_environment, score_aux, score_total,
gate_passed, block_reason, price)
VALUES
(%s,%s,%s,%s,%s,%s,
%s,%s,%s,%s,
%s,%s,
%s,%s,
%s,%s,%s,%s,
%s,%s,
%s,%s,%s,%s,%s,
%s,%s,%s)
""",
(
ts, symbol, track, side, strategy, "v5.3",
result.get("cvd_fast"), result.get("cvd_mid"), result.get("cvd_day"), result.get("cvd_fast_slope"),
result.get("p95"), result.get("p99"),
result.get("atr_value", result.get("atr")), result.get("atr_pct"),
result.get("oi_delta"), result.get("ls_ratio"), result.get("top_trader_position"),
(f.get("auxiliary") or {}).get("coinbase_premium"),
f.get("obi_raw"), f.get("whale_cvd_ratio"),
score_direction, score_crowding, score_env, score_aux, result.get("score", 0),
gate_passed, block_reason, result.get("price"),
)
) )
conn.commit() conn.commit()
@ -1131,35 +736,26 @@ def paper_open_trade(
): ):
"""模拟开仓""" """模拟开仓"""
import json as _json3 import json as _json3
if atr <= 0: risk_atr = 0.7 * atr
if risk_atr <= 0:
return return
sl_multiplier = float((tp_sl or {}).get("sl_multiplier", 2.0)) sl_multiplier = float((tp_sl or {}).get("sl_multiplier", 2.0))
tp1_multiplier = float((tp_sl or {}).get("tp1_multiplier", 1.5)) tp1_multiplier = float((tp_sl or {}).get("tp1_multiplier", 1.5))
tp2_multiplier = float((tp_sl or {}).get("tp2_multiplier", 3.0)) tp2_multiplier = float((tp_sl or {}).get("tp2_multiplier", 3.0))
risk_distance = sl_multiplier * atr # 1R = SL距离 = sl_multiplier × ATR
if direction == "LONG": if direction == "LONG":
sl = price - sl_multiplier * atr sl = price - sl_multiplier * risk_atr
tp1 = price + tp1_multiplier * atr tp1 = price + tp1_multiplier * risk_atr
tp2 = price + tp2_multiplier * atr tp2 = price + tp2_multiplier * risk_atr
else: else:
sl = price + sl_multiplier * atr sl = price + sl_multiplier * risk_atr
tp1 = price - tp1_multiplier * atr tp1 = price - tp1_multiplier * risk_atr
tp2 = price - tp2_multiplier * atr tp2 = price - tp2_multiplier * risk_atr
# SL 合理性校验:实际距离必须在 risk_distance 的 80%~120% 范围内
actual_sl_dist = abs(sl - price)
if actual_sl_dist < risk_distance * 0.8 or actual_sl_dist > risk_distance * 1.2:
logger.error(
f"[{symbol}] ⚠️ SL校验失败拒绝开仓: direction={direction} price={price:.4f} "
f"sl={sl:.4f} actual_dist={actual_sl_dist:.4f} expected={risk_distance:.4f} atr={atr:.4f}"
)
return
with get_sync_conn() as conn: with get_sync_conn() as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute( cur.execute(
"INSERT INTO paper_trades (symbol,direction,score,tier,entry_price,entry_ts,tp1_price,tp2_price,sl_price,atr_at_entry,score_factors,strategy,risk_distance) " "INSERT INTO paper_trades (symbol,direction,score,tier,entry_price,entry_ts,tp1_price,tp2_price,sl_price,atr_at_entry,score_factors,strategy) "
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)",
( (
symbol, symbol,
direction, direction,
@ -1173,7 +769,6 @@ def paper_open_trade(
atr, atr,
_json3.dumps(factors) if factors else None, _json3.dumps(factors) if factors else None,
strategy, strategy,
risk_distance,
), ),
) )
conn.commit() conn.commit()
@ -1183,6 +778,100 @@ def paper_open_trade(
) )
def paper_check_positions(symbol: str, current_price: float, now_ms: int):
"""检查模拟盘持仓的止盈止损"""
with get_sync_conn() as conn:
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 "
"FROM paper_trades WHERE symbol=%s AND status IN ('active','tp1_hit') ORDER BY id",
(symbol,)
)
positions = cur.fetchall()
for pos in positions:
pid, direction, entry_price, tp1, tp2, sl, tp1_hit, entry_ts, atr_entry = pos
closed = False
new_status = None
pnl_r = 0.0
risk_distance = 2.0 * 0.7 * atr_entry if atr_entry > 0 else 1
# === 实盘模拟TP/SL视为限价单以挂单价成交 ===
if direction == "LONG":
if current_price <= sl:
closed = True
exit_price = sl
if tp1_hit:
new_status = "sl_be"
tp1_r = (tp1 - entry_price) / risk_distance if risk_distance > 0 else 0
pnl_r = 0.5 * tp1_r
else:
new_status = "sl"
pnl_r = (exit_price - entry_price) / risk_distance if risk_distance > 0 else -1.0
elif not tp1_hit and current_price >= tp1:
# 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}] 📝 TP1触发 LONG @ {current_price:.2f}, SL移至成本{new_sl:.2f}")
elif tp1_hit and current_price >= tp2:
closed = True
exit_price = tp2
new_status = "tp"
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 current_price >= sl:
closed = True
exit_price = sl
if tp1_hit:
new_status = "sl_be"
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 = (entry_price - exit_price) / risk_distance if risk_distance > 0 else -1.0
elif not tp1_hit and current_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}] 📝 TP1触发 SHORT @ {current_price:.2f}, SL移至成本{new_sl:.2f}")
elif tp1_hit and current_price <= tp2:
closed = True
exit_price = tp2
new_status = "tp"
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分钟市价平仓
if not closed and (now_ms - entry_ts > 60 * 60 * 1000):
closed = True
exit_price = current_price
new_status = "timeout"
if direction == "LONG":
move = current_price - entry_price
else:
move = entry_price - current_price
pnl_r = move / risk_distance if risk_distance > 0 else 0
if tp1_hit:
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:
# 扣除手续费(开仓+平仓各Taker 0.05%
risk_distance = 2.0 * 0.7 * atr_entry if atr_entry > 0 else 1
fee_r = (2 * PAPER_FEE_RATE * entry_price) / risk_distance if risk_distance > 0 else 0
pnl_r -= fee_r
cur.execute(
"UPDATE paper_trades SET status=%s, exit_price=%s, exit_ts=%s, pnl_r=%s WHERE id=%s",
(new_status, exit_price, now_ms, round(pnl_r, 4), pid)
)
logger.info(f"[{symbol}] 📝 模拟平仓: {direction} @ {exit_price:.2f} status={new_status} pnl={pnl_r:+.2f}R (fee={fee_r:.3f}R)")
conn.commit()
def paper_has_active_position(symbol: str, strategy: Optional[str] = None) -> bool: def paper_has_active_position(symbol: str, strategy: Optional[str] = None) -> bool:
"""检查该币种是否有活跃持仓""" """检查该币种是否有活跃持仓"""
with get_sync_conn() as conn: with get_sync_conn() as conn:
@ -1221,20 +910,20 @@ def paper_close_by_signal(symbol: str, current_price: float, now_ms: int, strate
with conn.cursor() as cur: with conn.cursor() as cur:
if strategy: if strategy:
cur.execute( cur.execute(
"SELECT id, direction, entry_price, tp1_hit, atr_at_entry, risk_distance " "SELECT id, direction, entry_price, tp1_hit, atr_at_entry "
"FROM paper_trades WHERE symbol=%s AND strategy=%s AND status IN ('active','tp1_hit')", "FROM paper_trades WHERE symbol=%s AND strategy=%s AND status IN ('active','tp1_hit')",
(symbol, strategy), (symbol, strategy),
) )
else: else:
cur.execute( cur.execute(
"SELECT id, direction, entry_price, tp1_hit, atr_at_entry, risk_distance " "SELECT id, direction, entry_price, tp1_hit, atr_at_entry "
"FROM paper_trades WHERE symbol=%s AND status IN ('active','tp1_hit')", "FROM paper_trades WHERE symbol=%s AND status IN ('active','tp1_hit')",
(symbol,), (symbol,),
) )
positions = cur.fetchall() positions = cur.fetchall()
for pos in positions: for pos in positions:
pid, direction, entry_price, tp1_hit, atr_entry, rd_db = pos pid, direction, entry_price, tp1_hit, atr_entry = pos
risk_distance = rd_db if rd_db and rd_db > 0 else abs(entry_price * 0.01) risk_distance = 2.0 * 0.7 * atr_entry if atr_entry > 0 else 1
if direction == "LONG": if direction == "LONG":
pnl_r = (current_price - entry_price) / risk_distance if risk_distance > 0 else 0 pnl_r = (current_price - entry_price) / risk_distance if risk_distance > 0 else 0
else: else:
@ -1264,108 +953,6 @@ def paper_active_count(strategy: Optional[str] = None) -> int:
return cur.fetchone()[0] return cur.fetchone()[0]
# ─── 实时 WebSocket 数据OBI + 期现背离)────────────────────────
_REALTIME_STATES: dict = {} # symbol -> SymbolState在main()里注入
async def _ws_obi_stream(symbol: str, state):
"""订阅期货盘口深度流,实时更新 state.rt_obi"""
stream = f"{symbol.lower()}@depth10@100ms"
url = f"wss://fstream.binance.com/stream?streams={stream}"
while True:
try:
async with websockets.connect(url, ping_interval=20, ping_timeout=10) as ws:
logger.info(f"[RT-OBI] {symbol} WebSocket connected")
async for raw in ws:
data = json.loads(raw)
book = data.get("data", data)
bids = book.get("b", [])
asks = book.get("a", [])
bid_vol = sum(float(b[1]) for b in bids)
ask_vol = sum(float(a[1]) for a in asks)
total = bid_vol + ask_vol
state.rt_obi = (bid_vol - ask_vol) / total if total > 0 else 0.0
except Exception as e:
logger.warning(f"[RT-OBI] {symbol} 断线重连: {e}")
await asyncio.sleep(3)
async def _ws_spot_perp_stream(symbol: str, state):
"""
订阅现货 bookTicker最优买卖价+ 期货 markPrice计算期现背离
spot_mid = (best_bid + best_ask) / 2
divergence = (spot_mid - mark_price) / mark_price
"""
spot_stream = f"{symbol.lower()}@bookTicker"
perp_stream = f"{symbol.lower()}@markPrice@1s"
spot_url = f"wss://stream.binance.com:9443/stream?streams={spot_stream}"
perp_url = f"wss://fstream.binance.com/stream?streams={perp_stream}"
spot_mid = [0.0] # mutable container for closure
mark_price = [0.0]
async def read_spot():
while True:
try:
async with websockets.connect(spot_url, ping_interval=20, ping_timeout=10) as ws:
logger.info(f"[RT-SPD] {symbol} spot WebSocket connected")
async for raw in ws:
d = json.loads(raw).get("data", json.loads(raw))
bid = float(d.get("b", 0) or 0)
ask = float(d.get("a", 0) or 0)
if bid > 0 and ask > 0:
spot_mid[0] = (bid + ask) / 2
if mark_price[0] > 0:
state.rt_spot_perp_div = (spot_mid[0] - mark_price[0]) / mark_price[0]
except Exception as e:
logger.warning(f"[RT-SPD] {symbol} spot 断线: {e}")
await asyncio.sleep(3)
async def read_perp():
while True:
try:
async with websockets.connect(perp_url, ping_interval=20, ping_timeout=10) as ws:
logger.info(f"[RT-SPD] {symbol} perp WebSocket connected")
async for raw in ws:
d = json.loads(raw).get("data", json.loads(raw))
mp = float(d.get("p", 0) or d.get("markPrice", 0) or 0)
if mp > 0:
mark_price[0] = mp
if spot_mid[0] > 0:
state.rt_spot_perp_div = (spot_mid[0] - mark_price[0]) / mark_price[0]
except Exception as e:
logger.warning(f"[RT-SPD] {symbol} perp 断线: {e}")
await asyncio.sleep(3)
await asyncio.gather(read_spot(), read_perp())
async def _realtime_ws_runner(states: dict):
"""统一启动所有实时WebSocket协程BTC + ALT (ETH/XRP/SOL)"""
coros = []
for sym, state in states.items():
# OBI流所有symbol都接perp depth10
coros.append(_ws_obi_stream(sym, state))
# 期现背离流:只有有现货+合约的币种BTC/ETH/XRP/SOL都有
coros.append(_ws_spot_perp_stream(sym, state))
if coros:
await asyncio.gather(*coros)
def start_realtime_ws(states: dict):
"""在独立线程里跑asyncio event loop驱动实时WebSocket采集"""
def _run():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(_realtime_ws_runner(states))
except Exception as e:
logger.error(f"[RT-WS] event loop 异常: {e}")
finally:
loop.close()
t = threading.Thread(target=_run, daemon=True, name="realtime-ws")
t.start()
logger.info("[RT-WS] 实时WebSocket后台线程已启动BTC OBI + 期现背离)")
# ─── 主循环 ────────────────────────────────────────────────────── # ─── 主循环 ──────────────────────────────────────────────────────
def main(): def main():
@ -1382,9 +969,6 @@ def main():
logger.info("=== Signal Engine (PG) 启动完成 ===") logger.info("=== Signal Engine (PG) 启动完成 ===")
# 启动实时WebSocket后台线程BTC OBI + 期现背离)
start_realtime_ws(states)
last_1m_save = {} last_1m_save = {}
cycle = 0 cycle = 0
warmup_cycles = 3 # 启动后跳过前3轮45秒避免冷启动信号开仓 warmup_cycles = 3 # 启动后跳过前3轮45秒避免冷启动信号开仓
@ -1410,7 +994,6 @@ def main():
for strategy_cfg, strategy_result in strategy_results: for strategy_cfg, strategy_result in strategy_results:
sname = strategy_cfg.get("name", "v51_baseline") sname = strategy_cfg.get("name", "v51_baseline")
save_indicator(now_ms, sym, strategy_result, strategy=sname) save_indicator(now_ms, sym, strategy_result, strategy=sname)
save_feature_event(now_ms, sym, strategy_result, strategy=sname)
# 1m表仍用primary图表用 # 1m表仍用primary图表用
primary_result = strategy_results[0][1] primary_result = strategy_results[0][1]
@ -1472,15 +1055,7 @@ def main():
warmup_cycles -= 1 warmup_cycles -= 1
if warmup_cycles == 0: if warmup_cycles == 0:
logger.info("冷启动保护期结束,模拟盘开仓已启用") logger.info("冷启动保护期结束,模拟盘开仓已启用")
# 每10轮(约2-3分钟)热加载配置,不需要重启 if cycle % 60 == 0:
if cycle % 10 == 0:
old_strategies = list(PAPER_ENABLED_STRATEGIES)
load_paper_config()
strategy_configs = load_strategy_configs() # A1: 热重载权重/阈值/TP/SL
strategy_names = [cfg.get("name", "unknown") for cfg in strategy_configs]
primary_strategy_name = "v52_8signals" if any(cfg.get("name") == "v52_8signals" for cfg in strategy_configs) else strategy_names[0]
if list(PAPER_ENABLED_STRATEGIES) != old_strategies:
logger.info(f"📋 配置热加载: enabled_strategies={PAPER_ENABLED_STRATEGIES}")
for sym, state in states.items(): for sym, state in states.items():
logger.info( logger.info(
f"[{sym}] 状态: CVD_fast={state.win_fast.cvd:.1f} " f"[{sym}] 状态: CVD_fast={state.win_fast.cvd:.1f} "

View File

@ -6,9 +6,7 @@ import httpx
DB_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "arb.db") DB_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "arb.db")
BINANCE_FAPI = "https://fapi.binance.com/fapi/v1" BINANCE_FAPI = "https://fapi.binance.com/fapi/v1"
SYMBOLS = ["BTCUSDT", "ETHUSDT"] SYMBOLS = ["BTCUSDT", "ETHUSDT"]
DISCORD_TOKEN = os.getenv("DISCORD_BOT_TOKEN") DISCORD_TOKEN = os.getenv("DISCORD_BOT_TOKEN", "MTQ3Mjk4NzY1NjczNTU1OTg0Mg.GgeYh5.NYSbivZKBUc5S2iKXeB-hnC33w3SUUPzDDdviM")
if not DISCORD_TOKEN:
raise RuntimeError("DISCORD_BOT_TOKEN 未设置,请从 GCP Secret Manager 注入")
DISCORD_CHANNEL = os.getenv("DISCORD_SIGNAL_CHANNEL", "1472986545635197033") DISCORD_CHANNEL = os.getenv("DISCORD_SIGNAL_CHANNEL", "1472986545635197033")
BINANCE_HEADERS = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"} BINANCE_HEADERS = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}

View File

@ -11,9 +11,9 @@
}, },
"accel_bonus": 5, "accel_bonus": 5,
"tp_sl": { "tp_sl": {
"sl_multiplier": 1.4, "sl_multiplier": 2.0,
"tp1_multiplier": 1.05, "tp1_multiplier": 1.5,
"tp2_multiplier": 2.1 "tp2_multiplier": 3.0
}, },
"signals": ["cvd", "p99", "accel", "ls_ratio", "oi", "coinbase_premium"] "signals": ["cvd", "p99", "accel", "ls_ratio", "oi", "coinbase_premium"]
} }

View File

@ -13,9 +13,9 @@
}, },
"accel_bonus": 0, "accel_bonus": 0,
"tp_sl": { "tp_sl": {
"sl_multiplier": 2.1, "sl_multiplier": 3.0,
"tp1_multiplier": 1.4, "tp1_multiplier": 2.0,
"tp2_multiplier": 3.15 "tp2_multiplier": 4.5
}, },
"signals": ["cvd", "p99", "accel", "ls_ratio", "oi", "coinbase_premium", "funding_rate", "liquidation"] "signals": ["cvd", "p99", "accel", "ls_ratio", "oi", "coinbase_premium", "funding_rate", "liquidation"]
} }

View File

@ -1,47 +0,0 @@
{
"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
}
}
}

View File

@ -1,60 +0,0 @@
{
"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
}
}
}

View File

@ -1,49 +0,0 @@
{
"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
}
}
}

View File

@ -1,14 +0,0 @@
"""交易配置常量 — 所有实盘模块共用"""
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()}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,218 +0,0 @@
---
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)

View File

@ -1,242 +0,0 @@
---
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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,6 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { authFetch, useAuth } from "@/lib/auth";
interface UserInfo { interface UserInfo {
id: number; id: number;
@ -13,46 +12,36 @@ interface UserInfo {
export default function DashboardPage() { export default function DashboardPage() {
const router = useRouter(); const router = useRouter();
const { isLoggedIn, loading, logout } = useAuth();
const [user, setUser] = useState<UserInfo | null>(null); const [user, setUser] = useState<UserInfo | null>(null);
const [discordId, setDiscordId] = useState(""); const [discordId, setDiscordId] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState(""); const [msg, setMsg] = useState("");
useEffect(() => { useEffect(() => {
if (loading) return; const token = localStorage.getItem("arb_token");
if (!isLoggedIn) { router.push("/login"); return; } if (!token) { router.push("/login"); return; }
authFetch("/api/auth/me") fetch("/api/user/me", { headers: { Authorization: `Bearer ${token}` } })
.then(r => r.ok ? r.json() : Promise.reject()) .then(r => { if (!r.ok) { router.push("/login"); return null; } return r.json(); })
.then(d => { .then(d => { if (d) { setUser(d); setDiscordId(d.discord_id || ""); } });
setUser({ }, [router]);
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 () => { const bindDiscord = async () => {
setSaving(true); setMsg(""); setSaving(true); setMsg("");
try { const token = localStorage.getItem("arb_token");
const r = await authFetch("/api/user/bind-discord", { const r = await fetch("/api/user/bind-discord", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ discord_id: discordId }), body: JSON.stringify({ discord_id: discordId }),
}); });
const d = await r.json(); const d = await r.json();
setMsg(r.ok ? "\u2705 绑定成功" : d.detail || "绑定失败"); setMsg(r.ok ? "✅ 绑定成功" : d.detail || "绑定失败");
if (r.ok && user) setUser({ ...user, discord_id: discordId });
} catch { setMsg("绑定失败"); }
setSaving(false); setSaving(false);
if (r.ok && user) setUser({ ...user, discord_id: discordId });
}; };
if (loading || !user) return <div className="text-slate-500 p-8">...</div>; const logout = () => { localStorage.removeItem("arb_token"); router.push("/"); };
if (!user) return <div className="text-slate-500 p-8">...</div>;
const tierLabel: Record<string, string> = { free: "免费版", pro: "Pro", premium: "Premium" }; const tierLabel: Record<string, string> = { free: "免费版", pro: "Pro", premium: "Premium" };
@ -91,7 +80,7 @@ export default function DashboardPage() {
{saving ? "保存中..." : "绑定"} {saving ? "保存中..." : "绑定"}
</button> </button>
</div> </div>
{msg && <p className={`text-sm ${msg.startsWith("\u2705") ? "text-emerald-400" : "text-red-400"}`}>{msg}</p>} {msg && <p className={`text-sm ${msg.startsWith("") ? "text-emerald-400" : "text-red-400"}`}>{msg}</p>}
<p className="text-slate-400 text-xs">Discord ID ID</p> <p className="text-slate-400 text-xs">Discord ID ID</p>
</div> </div>

View File

@ -1,760 +1,130 @@
"use client"; "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) { import { useEffect, useState, useCallback } from "react";
const d = new Date(ms + 8 * 3600 * 1000); import { api } from "@/lib/api";
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")}`; import {
} LineChart, Line, XAxis, YAxis, Tooltip, Legend,
function fmtPrice(p: number) { return p < 100 ? p.toFixed(4) : p.toLocaleString("en-US", { minimumFractionDigits: 1, maximumFractionDigits: 1 }); } ResponsiveContainer, ReferenceLine, CartesianGrid
function fmtMs(ms: number) { return ms > 999 ? `${(ms/1000).toFixed(1)}s` : `${ms}ms`; } } from "recharts";
const LIVE_STRATEGY = "v52_8signals"; interface ChartPoint {
time: string;
// ═══════════════════════════════════════════════════════════════ btcRate: number;
// L0: 顶部固定风险条sticky永远可见 ethRate: number;
// ═══════════════════════════════════════════════════════════════ btcPrice: number;
function L0_RiskBar() { ethPrice: number;
const [risk, setRisk] = useState<any>(null);
const [recon, setRecon] = useState<any>(null);
const [account, setAccount] = useState<any>(null);
useEffect(() => {
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="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>
);
} }
// ═══════════════════════════════════════════════════════════════ export default function LivePage() {
// L1: 一键止血区 const [data, setData] = useState<ChartPoint[]>([]);
// ═══════════════════════════════════════════════════════════════ const [count, setCount] = useState(0);
function L1_EmergencyPanel() { const [loading, setLoading] = useState(true);
const [confirming, setConfirming] = useState<string|null>(null); const [hours, setHours] = useState(2);
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>
);
}
// ═══════════════════════════════════════════════════════════════ const fetchSnapshots = useCallback(async () => {
// 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 { try {
const r = await authFetch("/api/live/config"); const json = await api.snapshots(hours, 3600);
if (r.ok) { const d = await r.json(); setConfig(d); } const rows = json.data || [];
} catch {} setCount(json.count || 0);
}; // 降采样每30条取1条避免图表过密
f(); const step = Math.max(1, Math.floor(rows.length / 300));
}, []); const sampled = rows.filter((_, i) => i % step === 0);
setData(sampled.map(row => ({
const configOrder = ["risk_per_trade_usd", "initial_capital", "risk_pct", "max_positions", "leverage", "enabled_strategies", "trade_env"]; time: new Date(row.ts * 1000).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" }),
const configIcons: Record<string, string> = { btcRate: parseFloat((row.btc_rate * 100).toFixed(5)),
risk_per_trade_usd: "🎯", initial_capital: "💰", risk_pct: "📊", ethRate: parseFloat((row.eth_rate * 100).toFixed(5)),
max_positions: "📦", leverage: "⚡", enabled_strategies: "🧠", trade_env: "🌐" btcPrice: row.btc_price,
}; ethPrice: row.eth_price,
})));
const startEdit = () => { } catch { /* ignore */ } finally {
const d: Record<string, string> = {}; setLoading(false);
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); } }, [hours]);
};
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(() => { useEffect(() => {
const f = async () => { fetchSnapshots();
try { const r = await authFetch(`/api/live/positions?strategy=${LIVE_STRATEGY}`); if (r.ok) { const j = await r.json(); setPositions(j.data||[]); } } catch {} const iv = setInterval(fetchSnapshots, 10_000);
try { const r = await authFetch("/api/live/reconciliation"); if (r.ok) setRecon(await r.json()); } catch {} return () => clearInterval(iv);
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 {} }, [fetchSnapshots]);
};
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 ( return (
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden"> <div className="space-y-6">
<div className="px-3 py-2 border-b border-slate-100"> <div className="flex items-center justify-between flex-wrap gap-3">
<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">SO {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">OF {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>
</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> <div>
<p className="text-slate-400 mb-1"> ({data.local_positions?.length || 0})</p> <h1 className="text-2xl font-bold text-slate-900"></h1>
{(data.local_positions || []).map((p: any) => ( <p className="text-slate-500 text-sm mt-1">
<div key={p.id} className="font-mono">{p.symbol?.replace("USDT","")} {p.direction} @ {fmtPrice(p.entry_price)}</div> 8线 · <span className="text-blue-600 font-medium">{count.toLocaleString()}</span>
))} </p>
{!data.local_positions?.length && <div className="text-slate-300"></div>}
</div> </div>
<div> <div className="flex gap-2 text-sm">
<p className="text-slate-400 mb-1"> ({data.exchange_positions?.length || 0})</p> {[1, 2, 6, 12, 24].map(h => (
{(data.exchange_positions || []).map((p: any, i: number) => ( <button
<div key={i} className="font-mono">{p.symbol?.replace("USDT","")} {p.direction} qty={p.amount} liq={fmtPrice(p.liquidation_price)}</div> key={h}
))} onClick={() => setHours(h)}
{!data.exchange_positions?.length && <div className="text-slate-300"></div>} 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"}`}
</div> >
</div> {h}h
<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> </button>
))} ))}
</div> </div>
</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>
);
}
// ═══════════════════════════════════════════════════════════════ {loading ? (
// L8: 实盘 vs 模拟盘对照 <div className="text-slate-400 py-12 text-center">...</div>
// ═══════════════════════════════════════════════════════════════ ) : data.length === 0 ? (
function L8_PaperComparison() { <div className="rounded-xl border border-slate-200 bg-slate-50 p-12 text-center text-slate-400">
const [data, setData] = useState<any>(null); 2
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>
<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> <div className="rounded-xl border border-slate-200 bg-white shadow-sm p-6">
<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> <h2 className="text-slate-700 font-semibold mb-1"></h2>
<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> <p className="text-slate-400 text-xs mb-4">===</p>
</tr></thead> <ResponsiveContainer width="100%" height={220}>
<tbody className="divide-y divide-slate-50"> <LineChart data={data} margin={{ top: 4, right: 8, bottom: 4, left: 8 }}>
{data.data.map((r: any, i: number) => ( <CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
<tr key={i} className="hover:bg-slate-50"> <XAxis dataKey="time" tick={{ fill: "#94a3b8", fontSize: 10 }} tickLine={false} interval="preserveStartEnd" />
<td className="px-2 py-1 font-mono">{r.symbol?.replace("USDT","")}</td> <YAxis tick={{ fill: "#94a3b8", fontSize: 10 }} tickLine={false} axisLine={false}
<td className={`px-2 py-1 text-center font-bold ${r.direction==="LONG"?"text-emerald-600":"text-red-500"}`}>{r.direction}</td> tickFormatter={v => `${v.toFixed(3)}%`} width={60} />
<td className="px-2 py-1 text-right font-mono">{r.live_entry ? fmtPrice(r.live_entry) : "-"}</td> <Tooltip formatter={(v) => [`${Number(v).toFixed(5)}%`]}
<td className="px-2 py-1 text-right font-mono">{r.paper_entry ? fmtPrice(r.paper_entry) : "-"}</td> contentStyle={{ background: "#fff", border: "1px solid #e2e8f0", borderRadius: 8, fontSize: 12 }} />
<td className="px-2 py-1 text-right font-mono">{r.entry_diff_bps || "-"}</td> <Legend wrapperStyle={{ fontSize: 12 }} />
<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> <ReferenceLine y={0} stroke="#94a3b8" strokeDasharray="4 2" />
<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> <Line type="monotone" dataKey="btcRate" name="BTC费率" stroke="#2563eb" strokeWidth={1.5} dot={false} connectNulls />
<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> <Line type="monotone" dataKey="ethRate" name="ETH费率" stroke="#7c3aed" strokeWidth={1.5} dot={false} connectNulls />
</tr> </LineChart>
))}
</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> </ResponsiveContainer>
</div> </div>
</div>
);
}
// ═══════════════════════════════════════════════════════════════ {/* 价格图 */}
// L10: 历史交易表 <div className="rounded-xl border border-slate-200 bg-white shadow-sm p-6">
// ═══════════════════════════════════════════════════════════════ <h2 className="text-slate-700 font-semibold mb-1"></h2>
type FS = "all"|"BTC"|"ETH"|"XRP"|"SOL"; <p className="text-slate-400 text-xs mb-4"></p>
type FR = "all"|"win"|"loss"; <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>
function L10_TradeHistory() { {/* 说明 */}
const [trades, setTrades] = useState<any[]>([]); <div className="rounded-lg border border-blue-100 bg-blue-50 px-5 py-3 text-sm text-slate-600">
const [symbol, setSymbol] = useState<FS>("all"); <span className="text-blue-600 font-medium"></span>
const [result, setResult] = useState<FR>("all"); 2Binance拉取实时溢价指数8
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> </>
<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>
</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>
); );
} }

View File

@ -233,12 +233,6 @@ function LatestSignals() {
// ─── 当前持仓 ──────────────────────────────────────────────────── // ─── 当前持仓 ────────────────────────────────────────────────────
function ActivePositions({ strategy }: { strategy: StrategyFilter }) { 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 [positions, setPositions] = useState<any[]>([]);
const [wsPrices, setWsPrices] = useState<Record<string, number>>({}); const [wsPrices, setWsPrices] = useState<Record<string, number>>({});
@ -296,11 +290,12 @@ function ActivePositions({ strategy }: { strategy: StrategyFilter }) {
const frScore = factors?.funding_rate?.score ?? 0; const frScore = factors?.funding_rate?.score ?? 0;
const liqScore = factors?.liquidation?.score ?? 0; const liqScore = factors?.liquidation?.score ?? 0;
const entry = p.entry_price || 0; const entry = p.entry_price || 0;
const riskDist = p.risk_distance || Math.abs(entry - (p.sl_price || entry)) || 1; const atr = p.atr_at_entry || 1;
const riskDist = 2.0 * 0.7 * atr;
const fullR = riskDist > 0 ? (p.direction === "LONG" ? (currentPrice - entry) / riskDist : (entry - currentPrice) / riskDist) : 0; 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 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 unrealR = p.tp1_hit ? 0.5 * tp1R + 0.5 * fullR : fullR;
const unrealUsdt = unrealR * paperRiskUsd; const unrealUsdt = unrealR * 200;
return ( return (
<div key={p.id} className="px-3 py-2 bg-emerald-50/60"> <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 justify-between gap-2">
@ -327,7 +322,6 @@ function ActivePositions({ strategy }: { strategy: StrategyFilter }) {
</div> </div>
<div className="flex gap-3 mt-1 text-[10px] font-mono text-slate-600 flex-wrap"> <div className="flex gap-3 mt-1 text-[10px] font-mono text-slate-600 flex-wrap">
<span>入场: ${fmtPrice(p.entry_price)}</span> <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-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">TP1: ${fmtPrice(p.tp1_price)}{p.tp1_hit ? " ✅" : ""}</span>
<span className="text-emerald-600">TP2: ${fmtPrice(p.tp2_price)}</span> <span className="text-emerald-600">TP2: ${fmtPrice(p.tp2_price)}</span>

View File

@ -1,396 +0,0 @@
"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>
);
}

View File

@ -1,405 +0,0 @@
"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>
);
}

View File

@ -1,405 +0,0 @@
"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>
);
}

View File

@ -183,12 +183,6 @@ function LatestSignals() {
// ─── 当前持仓 ──────────────────────────────────────────────────── // ─── 当前持仓 ────────────────────────────────────────────────────
function ActivePositions() { 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 [positions, setPositions] = useState<any[]>([]);
const [wsPrices, setWsPrices] = useState<Record<string, number>>({}); const [wsPrices, setWsPrices] = useState<Record<string, number>>({});
@ -232,12 +226,13 @@ function ActivePositions() {
const holdMin = Math.round((Date.now() - p.entry_ts) / 60000); const holdMin = Math.round((Date.now() - p.entry_ts) / 60000);
const currentPrice = wsPrices[p.symbol] || p.current_price || 0; const currentPrice = wsPrices[p.symbol] || p.current_price || 0;
const entry = p.entry_price || 0; const entry = p.entry_price || 0;
const riskDist = p.risk_distance || Math.abs(entry - (p.sl_price || entry)) || 1; const atr = p.atr_at_entry || 1;
const riskDist = 2.0 * 0.7 * atr;
// TP1触发后只剩半仓0.5×TP1锁定 + 0.5×当前浮盈 // TP1触发后只剩半仓0.5×TP1锁定 + 0.5×当前浮盈
const fullR = riskDist > 0 ? (p.direction === "LONG" ? (currentPrice - entry) / riskDist : (entry - currentPrice) / riskDist) : 0; 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 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 unrealR = p.tp1_hit ? 0.5 * tp1R + 0.5 * fullR : fullR;
const unrealUsdt = unrealR * paperRiskUsd; const unrealUsdt = unrealR * 200;
return ( return (
<div key={p.id} className="px-3 py-2"> <div key={p.id} className="px-3 py-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -257,9 +252,8 @@ function ActivePositions() {
<span className="text-[10px] text-slate-400">{holdMin}m</span> <span className="text-[10px] text-slate-400">{holdMin}m</span>
</div> </div>
</div> </div>
<div className="flex gap-3 mt-1 text-[10px] font-mono text-slate-600 flex-wrap"> <div className="flex gap-3 mt-1 text-[10px] font-mono text-slate-600">
<span>入场: ${fmtPrice(p.entry_price)}</span> <span>入场: ${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-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">TP1: ${fmtPrice(p.tp1_price)}{p.tp1_hit ? " ✅" : ""}</span>
<span className="text-emerald-600">TP2: ${fmtPrice(p.tp2_price)}</span> <span className="text-emerald-600">TP2: ${fmtPrice(p.tp2_price)}</span>

View File

@ -1,565 +0,0 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { authFetch } from "@/lib/auth";
import { useAuth } from "@/lib/auth";
import Link from "next/link";
import {
ComposedChart, Area, Line, XAxis, YAxis, Tooltip, ResponsiveContainer,
ReferenceLine, CartesianGrid, Legend
} from "recharts";
type Symbol = "BTC" | "ETH" | "XRP" | "SOL";
interface IndicatorRow {
ts: number;
cvd_fast: number;
cvd_mid: number;
cvd_day: number;
atr_5m: number;
vwap_30m: number;
price: number;
score: number;
signal: string | null;
}
interface LatestIndicator {
ts: number;
cvd_fast: number;
cvd_mid: number;
cvd_day: number;
cvd_fast_slope: number;
atr_5m: number;
atr_percentile: number;
vwap_30m: number;
price: number;
p95_qty: number;
p99_qty: number;
score: number;
display_score?: number; // 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">&gt;$100k</p>
</div>
</div>
{factors.block_reason && (
<p className="text-[10px] text-red-600 mt-1.5 bg-red-50 rounded px-2 py-1">
: <span className="font-mono">{factors.block_reason}</span>
</p>
)}
</div>
);
}
// ─── 实时指标卡片 ────────────────────────────────────────────────
function IndicatorCards({ symbol }: { 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分 + 5CVD双重计分问题</p>
<p><span className="font-semibold">2 25</span> LSR反向拥挤15分=+ 10</p>
<p><span className="font-semibold">3 15</span> OI变化率vs撤离</p>
<p><span className="font-semibold">4 5</span> Coinbase Premium</p>
</div>
</div>
<div className="p-2 bg-amber-50 rounded-lg border border-amber-100">
<span className="font-bold text-amber-800">🔵 BTC轨 Gate-Control逻辑</span>
<div className="mt-1 space-y-1">
<p><span className="font-semibold"></span>ATR/Price 0.2%</p>
<p><span className="font-semibold">OBI否决</span>簿100ms</p>
<p><span className="font-semibold"></span>spot与perp价差超阈值时否决1s</p>
<p><span className="font-semibold">CVD</span>&gt;$100k成交额净CVD15</p>
</div>
</div>
<div className="pt-1 border-t border-slate-100">
<span className="text-blue-600 font-medium"></span>&lt;75 · 75-84 · 85 · 10
</div>
</div>
</div>
</div>
);
}

View File

@ -1,580 +0,0 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { authFetch } from "@/lib/auth";
import { useAuth } from "@/lib/auth";
import Link from "next/link";
import {
ComposedChart, Area, Line, XAxis, YAxis, Tooltip, ResponsiveContainer,
ReferenceLine, CartesianGrid, Legend
} from "recharts";
type Symbol = "BTC" | "ETH" | "XRP" | "SOL";
interface IndicatorRow {
ts: number;
cvd_fast: number;
cvd_mid: number;
cvd_day: number;
atr_5m: number;
vwap_30m: number;
price: number;
score: number;
signal: string | null;
}
interface LatestIndicator {
ts: number;
cvd_fast: number;
cvd_mid: number;
cvd_day: number;
cvd_fast_slope: number;
atr_5m: number;
atr_percentile: number;
vwap_30m: number;
price: number;
p95_qty: number;
p99_qty: number;
score: number;
display_score?: number; // 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">&gt;$100k</p>
</div>
</div>
{factors.block_reason && (
<p className="text-[10px] text-red-600 mt-1.5 bg-red-50 rounded px-2 py-1">
: <span className="font-mono">{factors.block_reason}</span>
</p>
)}
</div>
);
}
// ─── 实时指标卡片 ────────────────────────────────────────────────
function IndicatorCards({ symbol }: { 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分 + 5CVD双重计分问题</p>
<p><span className="font-semibold">2 25</span> LSR反向拥挤15分=+ 10</p>
<p><span className="font-semibold">3 15</span> OI变化率vs撤离</p>
<p><span className="font-semibold">4 5</span> Coinbase Premium</p>
</div>
</div>
<div className="p-2 bg-amber-50 rounded-lg border border-amber-100">
<span className="font-bold text-amber-800">🔵 BTC轨 Gate-Control逻辑</span>
<div className="mt-1 space-y-1">
<p><span className="font-semibold"></span>ATR/Price 0.2%</p>
<p><span className="font-semibold">OBI否决</span>簿100ms</p>
<p><span className="font-semibold"></span>spot与perp价差超阈值时否决1s</p>
<p><span className="font-semibold">CVD</span>&gt;$100k成交额净CVD15</p>
</div>
</div>
<div className="pt-1 border-t border-slate-100">
<span className="text-blue-600 font-medium"></span>&lt;75 · 75-84 · 85 · 10
</div>
</div>
</div>
</div>
);
}

View File

@ -1,580 +0,0 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { authFetch } from "@/lib/auth";
import { useAuth } from "@/lib/auth";
import Link from "next/link";
import {
ComposedChart, Area, Line, XAxis, YAxis, Tooltip, ResponsiveContainer,
ReferenceLine, CartesianGrid, Legend
} from "recharts";
type Symbol = "BTC" | "ETH" | "XRP" | "SOL";
interface IndicatorRow {
ts: number;
cvd_fast: number;
cvd_mid: number;
cvd_day: number;
atr_5m: number;
vwap_30m: number;
price: number;
score: number;
signal: string | null;
}
interface LatestIndicator {
ts: number;
cvd_fast: number;
cvd_mid: number;
cvd_day: number;
cvd_fast_slope: number;
atr_5m: number;
atr_percentile: number;
vwap_30m: number;
price: number;
p95_qty: number;
p99_qty: number;
score: number;
display_score?: number; // 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">&gt;$100k</p>
</div>
</div>
{factors.block_reason && (
<p className="text-[10px] text-red-600 mt-1.5 bg-red-50 rounded px-2 py-1">
: <span className="font-mono">{factors.block_reason}</span>
</p>
)}
</div>
);
}
// ─── 实时指标卡片 ────────────────────────────────────────────────
function IndicatorCards({ symbol }: { 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分 + 5CVD双重计分问题</p>
<p><span className="font-semibold">2 25</span> LSR反向拥挤15分=+ 10</p>
<p><span className="font-semibold">3 15</span> OI变化率vs撤离</p>
<p><span className="font-semibold">4 5</span> Coinbase Premium</p>
</div>
</div>
<div className="p-2 bg-amber-50 rounded-lg border border-amber-100">
<span className="font-bold text-amber-800">🔵 BTC轨 Gate-Control逻辑</span>
<div className="mt-1 space-y-1">
<p><span className="font-semibold"></span>ATR/Price 0.2%</p>
<p><span className="font-semibold">OBI否决</span>簿100ms</p>
<p><span className="font-semibold"></span>spot与perp价差超阈值时否决1s</p>
<p><span className="font-semibold">CVD</span>&gt;$100k成交额净CVD15</p>
</div>
</div>
<div className="pt-1 border-t border-slate-100">
<span className="text-blue-600 font-medium"></span>&lt;75 · 75-84 · 85 · 10
</div>
</div>
</div>
</div>
);
}

View File

@ -1,176 +0,0 @@
"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>
);
}

View File

@ -1,259 +0,0 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { authFetch } from "@/lib/auth";
import { useAuth } from "@/lib/auth";
import Link from "next/link";
import {
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>
);
}

View File

@ -7,14 +7,16 @@ import { useAuth } from "@/lib/auth";
import { import {
LayoutDashboard, Info, LayoutDashboard, Info,
Menu, X, Zap, LogIn, UserPlus, Menu, X, Zap, LogIn, UserPlus,
ChevronLeft, ChevronRight, Activity, LogOut, Monitor, LineChart, Bolt ChevronLeft, ChevronRight, Activity, LogOut, Crosshair, Monitor, LineChart, Sparkles
} from "lucide-react"; } from "lucide-react";
const navItems = [ const navItems = [
{ href: "/", label: "仪表盘", icon: LayoutDashboard }, { href: "/", label: "仪表盘", icon: LayoutDashboard },
{ href: "/trades", label: "成交流", icon: Activity }, { href: "/trades", label: "成交流", icon: Activity },
{ href: "/live", label: "⚡ 实盘交易", icon: Bolt, section: "── 实盘 ──" }, { href: "/signals", label: "V5.1 信号引擎", icon: Crosshair, section: "── V5.1 ──" },
{ href: "/strategy-plaza", label: "策略广场", icon: Zap, section: "── 策略 ──" }, { href: "/paper", label: "V5.1 模拟盘", icon: LineChart },
{ href: "/signals-v52", label: "V5.2 信号引擎", icon: Sparkles, section: "── V5.2 ──" },
{ href: "/paper-v52", label: "V5.2 模拟盘", icon: LineChart },
{ href: "/server", label: "服务器", icon: Monitor }, { href: "/server", label: "服务器", icon: Monitor },
{ href: "/about", label: "说明", icon: Info }, { href: "/about", label: "说明", icon: Info },
]; ];

View File

@ -1,245 +0,0 @@
#!/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()

View File

@ -1,321 +0,0 @@
#!/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)