This commit is contained in:
aaron 2026-05-27 07:02:37 +08:00
parent d227ab126c
commit 9ba8340050
26 changed files with 1918 additions and 31 deletions

View File

@ -102,6 +102,8 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组
### 4.1.1 因子评分与复盘进化
- 核心认知:因子不等于策略。一个因子可以是先决条件、触发、确认、入场、风控或归因,但不能因为单个因子表现好就直接升级成完整策略。
- 完整策略必须至少包含:适用市场环境、交易宇宙、先决条件、核心触发、辅助确认、入场规则、止盈止损、失效条件、仓位/杠杆约束和独立复盘口径。
- `app/core/factor_scoring.py` 是确认层因子评分中心。新增确认加减分不要继续散落写死 `score += N`,应优先通过 `FactorScorer.delta(factor_code, base_delta, evidence=...)` 计算。
- 稳定因子代码来自 `app/core/signal_taxonomy.py`,例如 `vp_fly_1h_current`、`volume_consecutive_1h`、`ignition_d1_current`、`sector_rotation`、`sentiment_resonance`、`top_trader_long`、`risk_reward_bad`。
- `signal_performance` 是复盘后动态权重来源;`review_engine.py` 更新信号绩效后,`config_loader.get_signal_weights()` 会让下一轮筛选/确认读取生效权重。
@ -113,8 +115,18 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组
- `market_context.decision_log` / `entry_plan.decision_log` 是结构化决策解释paper trading 开仓事件也会记录当时 `market_regime`、`global_risk` 和 `score_components`
- NodeReal 链上因子通过 `app/db/onchain_db.py#get_onchain_factor_context()` 进入确认层,正向事件如 `whale_accumulation`、`smart_money_buying`、`exchange_outflow` 会加分,风险事件如 `exchange_inflow_risk`、`liquidity_remove_risk`、`holder_concentration_risk` 会扣分;这些因子同样受 `signal_performance.weight` 复盘权重约束。
- 后续新增链上、资金、事件、舆情等非 K 线因子时,必须给出稳定 `factor_code`、默认基准权重、证据字段和复盘归因口径,避免只做展示标签而不参与策略进化。
- `box_breakout_pullback_4h` 是 4H 箱体突破回踩强结构因子,不是完整策略;它可以作为 `box_retest_4h_v1` 这类策略的核心触发,但仍必须经过市场环境、交易宇宙、确认、入场、风控和失效条件。
### 4.1.2 链上数据源
### 4.1.2 多策略架构方向
- 多策略改造计划记录在 `docs/MULTI_STRATEGY_ARCHITECTURE.md`。后续做策略级改造前必须先阅读并更新该文档。
- 目标架构是:统一交易宇宙 -> 多个独立策略并行扫描 -> 标准策略信号 -> 冲突/重复仲裁 -> 推荐/观察/挂单 -> paper trading 保留策略血缘 -> 按策略独立复盘。
- `strategy_version` 只表示版本,不应替代策略身份;后续推荐、挂单和交易账本都应补充 `strategy_code`、`strategy_signal_id`、`strategy_snapshot_json` 和 `factor_roles_json`
- 现有综合确认链路在迁移期应标记为 `main_composite_v1`,避免无策略来源的推荐继续进入 paper trading。
- 第一个建议拆出的独立策略是 `box_retest_4h_v1`:核心触发来自 `box_breakout_pullback_4h`但策略成立还需要市场环境、交易量、回踩距离、15m/1H 承接、盈亏比、失效条件和账户风控。
- 新增策略必须先 observe-only 或 paper-only 积累样本,再进入灰度/发布;不能因为某个因子短期表现好就直接同步真实交易。
### 4.1.3 链上数据源
- 当前链上主数据源支持 NodeReal 与 Alchemy入口分别是 `app/services/nodereal_client.py`、`app/services/alchemy_client.py` 和 `app/services/onchain_monitor.py`
- 默认仍可跑 `ALPHAX_ONCHAIN_PROVIDER=nodereal`;如果 NodeReal 额度受限,可切到 `ALPHAX_ONCHAIN_PROVIDER=alchemy` 并设置 `ALPHAX_ALCHEMY_API_KEY`;并行模式可用 `ALPHAX_ONCHAIN_PROVIDER=nodereal,alchemy`

100
app/core/factor_roles.py Normal file
View File

@ -0,0 +1,100 @@
"""Explicit factor roles for multi-strategy attribution.
Factors are evidence. They only become strategy behavior after a strategy
contract assigns them a role in a complete trading playbook.
"""
from __future__ import annotations
from collections.abc import Iterable
PREREQUISITE = "prerequisite"
TRIGGER = "trigger"
CONFIRMATION = "confirmation"
ENTRY = "entry"
RISK = "risk"
ATTRIBUTION = "attribution"
UNKNOWN = "unknown"
VALID_FACTOR_ROLES = {
PREREQUISITE,
TRIGGER,
CONFIRMATION,
ENTRY,
RISK,
ATTRIBUTION,
UNKNOWN,
}
DEFAULT_FACTOR_ROLES: dict[str, str] = {
"box_breakout_pullback_4h": TRIGGER,
"vp_fly_1h_current": TRIGGER,
"volume_consecutive_1h": CONFIRMATION,
"volume_divergence_1h": RISK,
"short_tf_15m_ignition": TRIGGER,
"short_tf_5m_ignition": PREREQUISITE,
"short_tf_resonance": CONFIRMATION,
"static_accum_4h": CONFIRMATION,
"higher_lows_4h": CONFIRMATION,
"compression_surge_4h": CONFIRMATION,
"ignition_1h_current": TRIGGER,
"ignition_4h_current": CONFIRMATION,
"ignition_d1_current": CONFIRMATION,
"ignition_stale": ATTRIBUTION,
"dynamic_k_1h_bull": CONFIRMATION,
"dynamic_k_d1_bull": CONFIRMATION,
"breakout_pullback_d1": CONFIRMATION,
"breakout_pullback_w1": CONFIRMATION,
"breakout_15m_current": ENTRY,
"pullback_15m_confirm": ENTRY,
"strong_resonance_bypass": CONFIRMATION,
"entry_quality_gate": RISK,
"top_trader_long": CONFIRMATION,
"sector_rotation": CONFIRMATION,
"sentiment_resonance": CONFIRMATION,
"dex_volume_spike": CONFIRMATION,
"liquidity_add": CONFIRMATION,
"liquidity_remove_risk": RISK,
"exchange_outflow": CONFIRMATION,
"exchange_inflow_risk": RISK,
"whale_accumulation": CONFIRMATION,
"holder_concentration_risk": RISK,
"smart_money_buying": CONFIRMATION,
"funding_extreme": RISK,
"trend_exhaustion": RISK,
"false_breakout": RISK,
"high_position_reject": RISK,
"risk_reward_bad": RISK,
"cex_top_gainer_24h": PREREQUISITE,
"unknown": UNKNOWN,
}
def factor_role(factor_code: str | None, overrides: dict[str, str] | None = None) -> str:
code = str(factor_code or "").strip() or "unknown"
mapping = {**DEFAULT_FACTOR_ROLES, **(overrides or {})}
role = str(mapping.get(code) or UNKNOWN).strip()
return role if role in VALID_FACTOR_ROLES else UNKNOWN
def factor_roles_for_codes(codes: Iterable[str] | None, overrides: dict[str, str] | None = None) -> dict[str, str]:
roles = {}
for code in codes or []:
text = str(code or "").strip()
if text and text not in roles:
roles[text] = factor_role(text, overrides=overrides)
return roles
def validate_factor_roles(roles: dict[str, str] | None) -> dict[str, str]:
"""Normalize role payloads without promoting unknown factors to triggers."""
normalized = {}
for code, role in (roles or {}).items():
key = str(code or "").strip()
value = str(role or "").strip()
if not key:
continue
normalized[key] = value if value in VALID_FACTOR_ROLES else UNKNOWN
return normalized

View File

@ -0,0 +1,140 @@
"""Stable strategy signal contract shared by scanners, recommendations and ledgers."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any
from app.config.config_loader import get_meta
from app.core.factor_roles import factor_roles_for_codes, validate_factor_roles
from app.core.strategy_registry import MAIN_COMPOSITE_STRATEGY, normalize_strategy_code, strategy_label
def _safe_dict(value: Any) -> dict:
return value if isinstance(value, dict) else {}
def _safe_float(value: Any, default: float = 0.0) -> float:
try:
if value is None or value == "":
return default
return float(value)
except Exception:
return default
@dataclass
class StrategySignal:
strategy_code: str
symbol: str
strategy_version: str = ""
direction: str = "long"
status: str = "candidate"
confidence: float = 0.0
score: float = 0.0
run_id: str = ""
trigger: dict[str, Any] = field(default_factory=dict)
factor_roles: dict[str, str] = field(default_factory=dict)
entry_plan: dict[str, Any] = field(default_factory=dict)
risk_plan: dict[str, Any] = field(default_factory=dict)
decision_log: dict[str, Any] = field(default_factory=dict)
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
def __post_init__(self):
self.strategy_code = normalize_strategy_code(self.strategy_code)
self.factor_roles = validate_factor_roles(self.factor_roles)
def to_json_dict(self) -> dict[str, Any]:
return {
"strategy_code": self.strategy_code,
"strategy_name": strategy_label(self.strategy_code),
"strategy_version": self.strategy_version or "",
"symbol": self.symbol,
"direction": self.direction or "long",
"status": self.status or "candidate",
"confidence": round(_safe_float(self.confidence), 6),
"score": round(_safe_float(self.score), 6),
"run_id": self.run_id or "",
"trigger": _safe_dict(self.trigger),
"factor_roles": validate_factor_roles(self.factor_roles),
"entry_plan": _safe_dict(self.entry_plan),
"risk_plan": _safe_dict(self.risk_plan),
"decision_log": _safe_dict(self.decision_log),
"created_at": self.created_at,
}
def current_strategy_version() -> str:
try:
return str(get_meta().get("strategy_version") or "").strip()
except Exception:
return ""
def default_main_composite_signal(
*,
symbol: str,
score: float = 0,
signal_codes: list[str] | None = None,
entry_plan: dict | None = None,
market_context: dict | None = None,
decision_log: dict | None = None,
) -> StrategySignal:
return StrategySignal(
strategy_code=MAIN_COMPOSITE_STRATEGY,
strategy_version=current_strategy_version(),
symbol=symbol,
status="candidate",
confidence=max(0.0, min(100.0, _safe_float(score))),
score=_safe_float(score),
trigger={
"source": MAIN_COMPOSITE_STRATEGY,
"signal_codes": signal_codes or [],
"market_context": _safe_dict(market_context),
},
factor_roles=factor_roles_for_codes(signal_codes or []),
entry_plan=_safe_dict(entry_plan),
risk_plan={"source": "entry_quality_and_global_risk"},
decision_log=_safe_dict(decision_log),
)
def strategy_context_payload(signal: StrategySignal | dict | None, *, fallback_symbol: str = "", fallback_score: float = 0, signal_codes: list[str] | None = None, entry_plan: dict | None = None, market_context: dict | None = None) -> dict[str, Any]:
if isinstance(signal, StrategySignal):
payload = signal.to_json_dict()
elif isinstance(signal, dict):
payload = dict(signal)
else:
payload = default_main_composite_signal(
symbol=fallback_symbol,
score=fallback_score,
signal_codes=signal_codes or [],
entry_plan=entry_plan,
market_context=market_context,
).to_json_dict()
payload["strategy_code"] = normalize_strategy_code(payload.get("strategy_code"))
payload.setdefault("strategy_name", strategy_label(payload["strategy_code"]))
payload.setdefault("strategy_version", current_strategy_version())
payload.setdefault("symbol", fallback_symbol or payload.get("symbol") or "")
payload.setdefault("score", fallback_score)
payload.setdefault("confidence", payload.get("score") or fallback_score)
payload["factor_roles"] = validate_factor_roles(payload.get("factor_roles") or factor_roles_for_codes(signal_codes or []))
payload.setdefault("entry_plan", _safe_dict(entry_plan))
payload.setdefault("trigger", {})
payload.setdefault("risk_plan", {})
payload.setdefault("decision_log", {})
payload.setdefault("created_at", datetime.now().isoformat())
return payload
def signal_to_recommendation_context(signal: StrategySignal | dict | None, **fallback) -> dict[str, Any]:
payload = strategy_context_payload(signal, **fallback)
return {
"strategy_code": payload["strategy_code"],
"strategy_version": payload.get("strategy_version") or current_strategy_version(),
"strategy_signal_id": int(payload.get("strategy_signal_id") or payload.get("id") or 0),
"strategy_snapshot": payload,
"factor_roles": payload.get("factor_roles") or {},
}

View File

@ -0,0 +1,76 @@
"""Central registry for strategy identity and display metadata."""
from __future__ import annotations
from dataclasses import dataclass
MAIN_COMPOSITE_STRATEGY = "main_composite_v1"
BOX_RETEST_4H_STRATEGY = "box_retest_4h_v1"
@dataclass(frozen=True)
class StrategyDefinition:
strategy_code: str
strategy_name: str
description: str = ""
mode: str = "paper_only"
status: str = "active"
STRATEGY_DEFINITIONS: dict[str, StrategyDefinition] = {
MAIN_COMPOSITE_STRATEGY: StrategyDefinition(
strategy_code=MAIN_COMPOSITE_STRATEGY,
strategy_name="综合确认主链路",
description="迁移期兼容主链路,承载现有综合筛选与确认逻辑。",
mode="paper_enabled",
),
BOX_RETEST_4H_STRATEGY: StrategyDefinition(
strategy_code=BOX_RETEST_4H_STRATEGY,
strategy_name="4H箱体突破回踩",
description="底部箱体突破后回踩箱体上沿或EMA承接的结构策略。",
mode="paper_only",
),
}
def normalize_strategy_code(strategy_code: str | None) -> str:
code = str(strategy_code or "").strip()
return code or MAIN_COMPOSITE_STRATEGY
def strategy_definition(strategy_code: str | None) -> StrategyDefinition:
code = normalize_strategy_code(strategy_code)
return STRATEGY_DEFINITIONS.get(
code,
StrategyDefinition(
strategy_code=code,
strategy_name=code,
description="未注册策略,请补充 strategy_registry。",
status="unknown",
),
)
def strategy_label(strategy_code: str | None) -> str:
return strategy_definition(strategy_code).strategy_name
def registered_strategy_codes() -> list[str]:
return list(STRATEGY_DEFINITIONS.keys())
def strategy_catalog_seed_rows(strategy_version: str = "") -> list[dict]:
rows = []
for item in STRATEGY_DEFINITIONS.values():
rows.append(
{
"strategy_code": item.strategy_code,
"strategy_name": item.strategy_name,
"strategy_version": strategy_version or "",
"status": item.status,
"mode": item.mode,
"description": item.description,
}
)
return rows

View File

@ -57,6 +57,7 @@ def init_db():
def _sync_command_compat_hooks():
"""Keep legacy altcoin_db monkeypatch hooks effective after command extraction."""
_recommendation_commands.get_meta = get_meta
_recommendation_commands.get_conn = get_conn
_recommendation_commands.datetime = datetime

View File

@ -10,6 +10,7 @@ from app.db.altcoin_db import (
_is_executed_trade,
)
from app.core.opportunity_funnel import screening_stage_meta, stage_label
from app.core.strategy_registry import normalize_strategy_code, strategy_label
from app.db.schema import get_conn
@ -1059,6 +1060,10 @@ def _recommendation_item(row):
rec_result, rec_result_label = _classify_recommendation_result(item)
item["recommendation_result"] = rec_result
item["recommendation_result_label"] = rec_result_label
item["strategy_code"] = normalize_strategy_code(item.get("strategy_code"))
item["strategy_name"] = strategy_label(item["strategy_code"])
item["strategy_snapshot"] = _loads_json(item.get("strategy_snapshot_json"), {})
item["factor_roles"] = _loads_json(item.get("factor_roles_json"), {})
_derive_execution_fields(item)
return item

View File

@ -0,0 +1,128 @@
ALTER TABLE recommendation
ADD COLUMN IF NOT EXISTS strategy_code TEXT DEFAULT '',
ADD COLUMN IF NOT EXISTS strategy_signal_id BIGINT DEFAULT 0,
ADD COLUMN IF NOT EXISTS strategy_snapshot_json TEXT DEFAULT '{}',
ADD COLUMN IF NOT EXISTS factor_roles_json TEXT DEFAULT '{}';
ALTER TABLE paper_trades
ADD COLUMN IF NOT EXISTS strategy_code TEXT DEFAULT '',
ADD COLUMN IF NOT EXISTS strategy_signal_id BIGINT DEFAULT 0,
ADD COLUMN IF NOT EXISTS strategy_snapshot_json TEXT DEFAULT '{}',
ADD COLUMN IF NOT EXISTS factor_roles_json TEXT DEFAULT '{}';
ALTER TABLE paper_orders
ADD COLUMN IF NOT EXISTS strategy_code TEXT DEFAULT '',
ADD COLUMN IF NOT EXISTS strategy_signal_id BIGINT DEFAULT 0,
ADD COLUMN IF NOT EXISTS strategy_snapshot_json TEXT DEFAULT '{}',
ADD COLUMN IF NOT EXISTS factor_roles_json TEXT DEFAULT '{}';
ALTER TABLE paper_trade_events
ADD COLUMN IF NOT EXISTS strategy_code TEXT DEFAULT '',
ADD COLUMN IF NOT EXISTS strategy_signal_id BIGINT DEFAULT 0;
CREATE TABLE IF NOT EXISTS strategy_catalog (
strategy_code TEXT PRIMARY KEY,
strategy_name TEXT NOT NULL,
strategy_version TEXT DEFAULT '',
status TEXT DEFAULT 'active',
mode TEXT DEFAULT 'paper_only',
description TEXT DEFAULT '',
config_json TEXT DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS strategy_signals (
id BIGSERIAL PRIMARY KEY,
run_id TEXT DEFAULT '',
strategy_code TEXT NOT NULL,
strategy_version TEXT DEFAULT '',
symbol TEXT NOT NULL,
direction TEXT DEFAULT 'long',
signal_status TEXT DEFAULT 'candidate',
confidence DOUBLE PRECISION DEFAULT 0,
score DOUBLE PRECISION DEFAULT 0,
market_regime TEXT DEFAULT '',
trigger_json TEXT DEFAULT '{}',
factor_roles_json TEXT DEFAULT '{}',
entry_plan_json TEXT DEFAULT '{}',
risk_plan_json TEXT DEFAULT '{}',
decision_log_json TEXT DEFAULT '{}',
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_strategy_signals_code_time
ON strategy_signals(strategy_code, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_strategy_signals_symbol_time
ON strategy_signals(symbol, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_rec_strategy_code_time
ON recommendation(strategy_code, rec_time DESC);
CREATE INDEX IF NOT EXISTS idx_paper_trades_strategy_code
ON paper_trades(strategy_code, opened_at DESC);
CREATE INDEX IF NOT EXISTS idx_paper_orders_strategy_code
ON paper_orders(strategy_code, created_at DESC);
INSERT INTO strategy_catalog (
strategy_code, strategy_name, strategy_version, status, mode, description, config_json, created_at, updated_at
) VALUES
('main_composite_v1', '综合确认主链路', '', 'active', 'paper_enabled', '迁移期兼容主链路:统一承载旧的综合筛选与确认逻辑。', '{}', NOW()::TEXT, NOW()::TEXT),
('box_retest_4h_v1', '4H箱体突破回踩', '', 'active', 'paper_only', '底部箱体突破后回踩箱体上沿或EMA承接的结构策略雏形。', '{}', NOW()::TEXT, NOW()::TEXT)
ON CONFLICT(strategy_code) DO UPDATE SET
strategy_name=EXCLUDED.strategy_name,
status=EXCLUDED.status,
mode=EXCLUDED.mode,
description=EXCLUDED.description,
updated_at=NOW()::TEXT;
UPDATE recommendation
SET strategy_code='main_composite_v1'
WHERE COALESCE(strategy_code, '') = '';
UPDATE paper_trades pt
SET strategy_code=COALESCE(NULLIF(r.strategy_code, ''), 'main_composite_v1'),
strategy_signal_id=COALESCE(NULLIF(pt.strategy_signal_id, 0), r.strategy_signal_id, 0),
strategy_snapshot_json=CASE
WHEN COALESCE(pt.strategy_snapshot_json, '{}') != '{}' THEN pt.strategy_snapshot_json
ELSE COALESCE(NULLIF(r.strategy_snapshot_json, ''), '{}')
END,
factor_roles_json=CASE
WHEN COALESCE(pt.factor_roles_json, '{}') != '{}' THEN pt.factor_roles_json
ELSE COALESCE(NULLIF(r.factor_roles_json, ''), '{}')
END
FROM recommendation r
WHERE pt.recommendation_id = r.id
AND COALESCE(pt.strategy_code, '') = '';
UPDATE paper_trades
SET strategy_code='main_composite_v1'
WHERE COALESCE(strategy_code, '') = '';
UPDATE paper_orders po
SET strategy_code=COALESCE(NULLIF(r.strategy_code, ''), 'main_composite_v1'),
strategy_signal_id=COALESCE(NULLIF(po.strategy_signal_id, 0), r.strategy_signal_id, 0),
strategy_snapshot_json=CASE
WHEN COALESCE(po.strategy_snapshot_json, '{}') != '{}' THEN po.strategy_snapshot_json
ELSE COALESCE(NULLIF(r.strategy_snapshot_json, ''), '{}')
END,
factor_roles_json=CASE
WHEN COALESCE(po.factor_roles_json, '{}') != '{}' THEN po.factor_roles_json
ELSE COALESCE(NULLIF(r.factor_roles_json, ''), '{}')
END
FROM recommendation r
WHERE po.recommendation_id = r.id
AND COALESCE(po.strategy_code, '') = '';
UPDATE paper_orders
SET strategy_code='main_composite_v1'
WHERE COALESCE(strategy_code, '') = '';
UPDATE paper_trade_events e
SET strategy_code=COALESCE(NULLIF(pt.strategy_code, ''), 'main_composite_v1'),
strategy_signal_id=COALESCE(pt.strategy_signal_id, 0)
FROM paper_trades pt
WHERE e.trade_id = pt.id
AND COALESCE(e.strategy_code, '') = '';
UPDATE paper_trade_events
SET strategy_code='main_composite_v1'
WHERE COALESCE(strategy_code, '') = '';

View File

@ -8,6 +8,7 @@ from datetime import datetime, timedelta
from app.config.system_config import paper_trading_config
from app.core.global_risk import evaluate_global_risk
from app.core.strategy_registry import normalize_strategy_code, strategy_label
from app.db.schema import get_conn
from app.db.system_logs import record_system_error
from app.integrations.feishu_push import push_card
@ -316,6 +317,45 @@ def _entry_plan(rec: dict) -> dict:
return _loads_json(rec.get("entry_plan_json"), {})
def _strategy_lineage_from_rec(rec: dict) -> dict:
code = normalize_strategy_code(rec.get("strategy_code"))
signal_id = _safe_int(rec.get("strategy_signal_id"))
snapshot = _loads_json(rec.get("strategy_snapshot_json"), {})
roles = _loads_json(rec.get("factor_roles_json"), {})
if not snapshot:
snapshot = {
"strategy_code": code,
"strategy_name": strategy_label(code),
"strategy_version": rec.get("strategy_version") or "",
"symbol": rec.get("symbol") or "",
"source": "recommendation_compat",
}
snapshot.setdefault("strategy_code", code)
snapshot.setdefault("strategy_name", strategy_label(code))
return {
"strategy_code": code,
"strategy_name": strategy_label(code),
"strategy_signal_id": signal_id,
"strategy_snapshot": snapshot,
"factor_roles": roles if isinstance(roles, dict) else {},
"strategy_snapshot_json": json.dumps(snapshot, ensure_ascii=False, default=str),
"factor_roles_json": json.dumps(roles if isinstance(roles, dict) else {}, ensure_ascii=False, default=str),
}
def _strategy_lineage_from_trade_or_order(item: dict) -> dict:
code = normalize_strategy_code(item.get("strategy_code"))
snapshot = _loads_json(item.get("strategy_snapshot_json"), {})
roles = _loads_json(item.get("factor_roles_json"), {})
return {
"strategy_code": code,
"strategy_name": strategy_label(code),
"strategy_signal_id": _safe_int(item.get("strategy_signal_id")),
"strategy_snapshot": snapshot if isinstance(snapshot, dict) else {},
"factor_roles": roles if isinstance(roles, dict) else {},
}
def _parse_time(value: str):
try:
return datetime.fromisoformat(str(value or ""))
@ -390,16 +430,34 @@ def _decorate_trade(trade: dict, config: dict | None = None) -> dict:
latest_market = _safe_float(item.get("latest_market_price"))
item["latest_price"] = latest_market if latest_market > 0 else _safe_float(item.get("current_price"))
item["latest_price_updated_at"] = item.get("latest_market_price_updated_at") or item.get("updated_at") or ""
item.update(_strategy_lineage_from_trade_or_order(item))
return item
def _record_event(conn, trade_id: int, rec_id: int, symbol: str, event_type: str, price: float, pnl_pct: float, message: str, detail=None, event_time: str = ""):
detail = dict(detail or {})
strategy_code = str(detail.get("strategy_code") or "").strip()
strategy_signal_id = _safe_int(detail.get("strategy_signal_id"))
if (not strategy_code or strategy_signal_id <= 0) and _safe_int(trade_id) > 0:
try:
row = conn.execute(
"SELECT strategy_code, strategy_signal_id FROM paper_trades WHERE id=%s",
(_safe_int(trade_id),),
).fetchone()
if row:
strategy_code = strategy_code or row.get("strategy_code") or ""
strategy_signal_id = strategy_signal_id or _safe_int(row.get("strategy_signal_id"))
except Exception:
pass
strategy_code = normalize_strategy_code(strategy_code)
detail.setdefault("strategy_code", strategy_code)
detail.setdefault("strategy_name", strategy_label(strategy_code))
conn.execute(
"""
INSERT INTO paper_trade_events (
trade_id, recommendation_id, symbol, event_type, event_time,
price, pnl_pct, message, detail_json
) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)
price, pnl_pct, message, detail_json, strategy_code, strategy_signal_id
) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
""",
(
trade_id,
@ -410,7 +468,9 @@ def _record_event(conn, trade_id: int, rec_id: int, symbol: str, event_type: str
price,
pnl_pct,
message,
json.dumps(detail or {}, ensure_ascii=False, default=str),
json.dumps(detail, ensure_ascii=False, default=str),
strategy_code,
strategy_signal_id,
),
)
@ -657,14 +717,16 @@ def _open_trade(conn, rec: dict, current_price: float, event_time: str, config:
tp2 = _safe_float(rec.get("tp2") or plan.get("tp2") or plan.get("take_profit_2"))
fee = round(notional * default_fee_rate(cfg), 8)
now = event_time or _now()
lineage = _strategy_lineage_from_rec(rec)
row = conn.execute(
"""
INSERT INTO paper_trades (
recommendation_id, symbol, side, status, opened_at,
entry_price, qty, notional_usdt, margin_usdt, leverage, stop_loss, tp1, tp2,
max_price, min_price, current_price, pnl_pct, fee_usdt,
source_status, source_action, strategy_version, created_at, updated_at
) VALUES (%s,%s,'long','open',%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,0,%s,%s,%s,%s,%s,%s)
source_status, source_action, strategy_version, strategy_code, strategy_signal_id,
strategy_snapshot_json, factor_roles_json, created_at, updated_at
) VALUES (%s,%s,'long','open',%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,0,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
ON CONFLICT(recommendation_id) DO NOTHING
RETURNING id
""",
@ -687,6 +749,10 @@ def _open_trade(conn, rec: dict, current_price: float, event_time: str, config:
rec.get("execution_status") or "",
rec.get("action_status") or "",
rec.get("strategy_version") or "",
lineage["strategy_code"],
lineage["strategy_signal_id"],
lineage["strategy_snapshot_json"],
lineage["factor_roles_json"],
now,
now,
),
@ -712,6 +778,11 @@ def _open_trade(conn, rec: dict, current_price: float, event_time: str, config:
"slippage_pct": default_slippage_pct(cfg),
"source_status": rec.get("execution_status") or "",
"source_action": rec.get("action_status") or "",
"strategy_code": lineage["strategy_code"],
"strategy_name": lineage["strategy_name"],
"strategy_signal_id": lineage["strategy_signal_id"],
"strategy_snapshot": lineage["strategy_snapshot"],
"factor_roles": lineage["factor_roles"],
"market_regime": global_detail.get("market_regime") or _entry_plan(rec).get("market_regime") or {},
"global_risk": global_detail,
"score_components": _entry_plan(rec).get("score_components") or {},
@ -939,6 +1010,7 @@ def _order_recommendation_cancel_reason(conn, rec: dict, order: dict) -> str:
def _order_payload_from_rec(rec: dict, current_price: float, event_time: str, config: dict | None = None) -> dict:
cfg = _paper_cfg(config)
plan = _entry_plan(rec)
lineage = _strategy_lineage_from_rec(rec)
return {
"recommendation_id": _safe_int(rec.get("id")),
"symbol": str(rec.get("symbol") or "").strip().upper(),
@ -954,6 +1026,11 @@ def _order_payload_from_rec(rec: dict, current_price: float, event_time: str, co
"tp1": _safe_float(rec.get("tp1") or plan.get("tp1") or plan.get("take_profit_1")),
"tp2": _safe_float(rec.get("tp2") or plan.get("tp2") or plan.get("take_profit_2")),
"strategy_version": str(rec.get("strategy_version") or ""),
"strategy_code": lineage["strategy_code"],
"strategy_name": lineage["strategy_name"],
"strategy_signal_id": lineage["strategy_signal_id"],
"strategy_snapshot_json": lineage["strategy_snapshot_json"],
"factor_roles_json": lineage["factor_roles_json"],
"entry_plan_snapshot_json": json.dumps(plan, ensure_ascii=False, default=str),
"created_at": event_time,
"updated_at": event_time,
@ -1078,9 +1155,10 @@ def _sync_wait_pullback_order(conn, rec: dict, current_price: float, event_time:
INSERT INTO paper_orders (
recommendation_id, symbol, side, order_type, status,
source_status, source_action, target_price, current_price_at_create,
notional_usdt, stop_loss, tp1, tp2, strategy_version,
notional_usdt, stop_loss, tp1, tp2, strategy_version, strategy_code,
strategy_signal_id, strategy_snapshot_json, factor_roles_json,
entry_plan_snapshot_json, created_at, updated_at, expires_at
) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
ON CONFLICT(recommendation_id) DO NOTHING
RETURNING id
""",
@ -1099,6 +1177,10 @@ def _sync_wait_pullback_order(conn, rec: dict, current_price: float, event_time:
payload["tp1"],
payload["tp2"],
payload["strategy_version"],
payload["strategy_code"],
payload["strategy_signal_id"],
payload["strategy_snapshot_json"],
payload["factor_roles_json"],
payload["entry_plan_snapshot_json"],
payload["created_at"],
payload["updated_at"],
@ -1423,6 +1505,10 @@ def sync_pending_paper_orders(limit: int = 100, event_time: str = "", config: di
r.tp1 AS rec_tp1,
r.tp2 AS rec_tp2,
r.strategy_version AS rec_strategy_version,
r.strategy_code AS rec_strategy_code,
r.strategy_signal_id AS rec_strategy_signal_id,
r.strategy_snapshot_json AS rec_strategy_snapshot_json,
r.factor_roles_json AS rec_factor_roles_json,
r.entry_plan_json,
r.market_context_json,
r.derivatives_context_json,
@ -1458,6 +1544,10 @@ def sync_pending_paper_orders(limit: int = 100, event_time: str = "", config: di
"tp1",
"tp2",
"strategy_version",
"strategy_code",
"strategy_signal_id",
"strategy_snapshot_json",
"factor_roles_json",
"entry_plan_snapshot_json",
"created_at",
"updated_at",
@ -1482,6 +1572,10 @@ def sync_pending_paper_orders(limit: int = 100, event_time: str = "", config: di
"tp1": item.get("rec_tp1") or item.get("tp1"),
"tp2": item.get("rec_tp2") or item.get("tp2"),
"strategy_version": item.get("rec_strategy_version") or item.get("strategy_version"),
"strategy_code": item.get("rec_strategy_code") or item.get("strategy_code"),
"strategy_signal_id": item.get("rec_strategy_signal_id") or item.get("strategy_signal_id"),
"strategy_snapshot_json": item.get("rec_strategy_snapshot_json") or item.get("strategy_snapshot_json"),
"factor_roles_json": item.get("rec_factor_roles_json") or item.get("factor_roles_json"),
"entry_plan_json": item.get("entry_plan_json") or item.get("entry_plan_snapshot_json"),
"market_context_json": item.get("market_context_json"),
"derivatives_context_json": item.get("derivatives_context_json"),
@ -1676,15 +1770,21 @@ def get_paper_trading_performance(days: int = 30) -> dict:
}
def list_paper_trades(limit: int = 50, offset: int = 0, status: str = "") -> dict:
def list_paper_trades(limit: int = 50, offset: int = 0, status: str = "", strategy_code: str = "") -> dict:
limit = max(1, min(_safe_int(limit, 50), 200))
offset = max(0, _safe_int(offset, 0))
status = str(status or "").strip()
where = ""
params = []
clauses = []
if status in {"open", "closed"}:
where = "WHERE status=%s"
clauses.append("status=%s")
params.append(status)
strategy_code = str(strategy_code or "").strip()
if strategy_code:
clauses.append("strategy_code=%s")
params.append(normalize_strategy_code(strategy_code))
where = "WHERE " + " AND ".join(clauses) if clauses else ""
conn = get_conn()
try:
total = conn.execute(f"SELECT COUNT(*) FROM paper_trades {where}", tuple(params)).fetchone()[0]
@ -1711,15 +1811,21 @@ def list_paper_trades(limit: int = 50, offset: int = 0, status: str = "") -> dic
}
def list_paper_orders(limit: int = 50, offset: int = 0, status: str = "") -> dict:
def list_paper_orders(limit: int = 50, offset: int = 0, status: str = "", strategy_code: str = "") -> dict:
limit = max(1, min(_safe_int(limit, 50), 200))
offset = max(0, _safe_int(offset, 0))
status = str(status or "").strip()
where = ""
params = []
clauses = []
if status in {"pending", "filled", "canceled", "expired", "rejected"}:
where = "WHERE status=%s"
clauses.append("status=%s")
params.append(status)
strategy_code = str(strategy_code or "").strip()
if strategy_code:
clauses.append("strategy_code=%s")
params.append(normalize_strategy_code(strategy_code))
where = "WHERE " + " AND ".join(clauses) if clauses else ""
conn = get_conn()
try:
total = conn.execute(f"SELECT COUNT(*) FROM paper_orders {where}", tuple(params)).fetchone()[0]
@ -1743,6 +1849,7 @@ def list_paper_orders(limit: int = 50, offset: int = 0, status: str = "") -> dic
latest_market = _safe_float(item.get("latest_market_price"))
item["latest_price"] = latest_market if latest_market > 0 else _safe_float(item.get("current_price_at_create"))
item["latest_price_updated_at"] = item.get("latest_market_price_updated_at") or item.get("updated_at") or ""
item.update(_strategy_lineage_from_trade_or_order(item))
target = _safe_float(item.get("target_price"))
latest = _safe_float(item.get("latest_price"))
item["distance_to_target_pct"] = round((latest / target - 1) * 100, 4) if target and latest else 0
@ -1788,7 +1895,9 @@ def list_paper_trade_events(limit: int = 80, offset: int = 0, symbol: str = "",
t.leverage,
t.exit_reason,
t.opened_at,
t.closed_at
t.closed_at,
t.strategy_code AS trade_strategy_code,
t.strategy_signal_id AS trade_strategy_signal_id
FROM paper_trade_events e
LEFT JOIN paper_trades t ON t.id = e.trade_id
{where_sql}
@ -1803,6 +1912,9 @@ def list_paper_trade_events(limit: int = 80, offset: int = 0, symbol: str = "",
for row in rows:
item = dict(row)
item["detail"] = _loads_json(item.pop("detail_json", "{}"), {})
item["strategy_code"] = normalize_strategy_code(item.get("strategy_code") or item.get("trade_strategy_code"))
item["strategy_name"] = strategy_label(item["strategy_code"])
item["strategy_signal_id"] = _safe_int(item.get("strategy_signal_id") or item.get("trade_strategy_signal_id"))
items.append(item)
return {
"items": items,

View File

@ -10,6 +10,8 @@ from app.core.opportunity_lifecycle import (
normalize_json_object,
)
from app.core.signal_taxonomy import signal_codes as build_signal_codes, signal_labels as build_signal_labels
from app.core.strategy_contract import signal_to_recommendation_context
from app.core.strategy_registry import normalize_strategy_code
from app.db.recommendation_state import (
derive_minimal_state_fields,
entry_window_policy,
@ -70,11 +72,33 @@ def create_recommendation(
market_context=None,
derivatives_context=None,
sector_context=None,
strategy_code="",
strategy_signal_id=0,
strategy_snapshot=None,
factor_roles=None,
):
"""Create or merge the current recommendation record for one symbol."""
raw_pct = round(rec_score * 100.0 / 30) if rec_score else 0
rec_score_pct = min(raw_pct, 100)
strategy_version = str(get_meta().get("strategy_version") or "").strip()
strategy_context = signal_to_recommendation_context(
strategy_snapshot if strategy_snapshot else {
"strategy_code": strategy_code,
"strategy_signal_id": strategy_signal_id,
"factor_roles": factor_roles or {},
},
fallback_symbol=symbol,
fallback_score=rec_score_pct,
signal_codes=build_signal_codes(build_signal_labels(signals if isinstance(signals, list) else normalize_signals(signals))),
entry_plan=entry_plan or {},
market_context=market_context or {},
)
strategy_code = normalize_strategy_code(strategy_context.get("strategy_code"))
strategy_signal_id = int(strategy_context.get("strategy_signal_id") or 0)
strategy_snapshot = strategy_context.get("strategy_snapshot") or {}
factor_roles = strategy_context.get("factor_roles") or {}
strategy_version = str(get_meta().get("strategy_version") or strategy_context.get("strategy_version") or "").strip()
if isinstance(strategy_snapshot, dict):
strategy_snapshot["strategy_version"] = strategy_version
now = datetime.now().isoformat()
conn = get_conn()
@ -103,6 +127,10 @@ def create_recommendation(
UPDATE recommendation
SET rec_state=%s, rec_score=%s, sector=COALESCE(NULLIF(%s, ''), sector),
signals=%s, signal_codes_json=%s, signal_labels_json=%s, is_meme=%s, direction=%s, strategy_version=%s,
strategy_code=COALESCE(NULLIF(%s, ''), NULLIF(strategy_code, ''), 'main_composite_v1'),
strategy_signal_id=CASE WHEN %s > 0 THEN %s ELSE COALESCE(strategy_signal_id, 0) END,
strategy_snapshot_json=CASE WHEN %s != '{}' THEN %s ELSE COALESCE(strategy_snapshot_json, '{}') END,
factor_roles_json=CASE WHEN %s != '{}' THEN %s ELSE COALESCE(factor_roles_json, '{}') END,
force_reason=COALESCE(NULLIF(%s, ''), force_reason),
base_state=COALESCE(NULLIF(%s, ''), base_state),
sector_signal_count=GREATEST(COALESCE(sector_signal_count,0), %s),
@ -131,6 +159,13 @@ def create_recommendation(
is_meme,
direction,
strategy_version,
strategy_code,
strategy_signal_id,
strategy_signal_id,
json.dumps(strategy_snapshot or {}, ensure_ascii=False),
json.dumps(strategy_snapshot or {}, ensure_ascii=False),
json.dumps(factor_roles or {}, ensure_ascii=False),
json.dumps(factor_roles or {}, ensure_ascii=False),
force_reason or "",
base_state or "",
int(sector_signal_count or 0),
@ -167,8 +202,8 @@ def create_recommendation(
market_context_json, derivatives_context_json, sector_context_json,
opportunity_level, opportunity_level_label, holding_horizon, entry_model, stop_model, tp_model,
action_status, execution_status, display_bucket, lifecycle_state, entry_triggered, state_reason,
strategy_version)
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, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
strategy_code, strategy_signal_id, strategy_snapshot_json, factor_roles_json, strategy_version)
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, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""",
(
@ -209,6 +244,10 @@ def create_recommendation(
incoming_lifecycle,
incoming_triggered,
incoming_reason,
strategy_code,
strategy_signal_id,
json.dumps(strategy_snapshot or {}, ensure_ascii=False),
json.dumps(factor_roles or {}, ensure_ascii=False),
strategy_version,
),
)

View File

@ -9,6 +9,7 @@ from app.db.recommendation_state import (
derive_execution_fields as _derive_execution_fields,
is_actionable_execution_status as _is_actionable_execution_status,
)
from app.core.strategy_registry import normalize_strategy_code, strategy_label
from app.db.push_queries import get_recommendation_for_push, log_push, should_push
from app.db.schema import get_conn
from app.db.tracking_queries import update_recommendation_tracking
@ -62,6 +63,9 @@ def _attach_paper_order(item: dict) -> dict:
"filled_at": item.get("paper_order_filled_at") or "",
"canceled_at": item.get("paper_order_canceled_at") or "",
"cancel_reason": item.get("paper_order_cancel_reason") or "",
"strategy_code": normalize_strategy_code(item.get("paper_order_strategy_code") or item.get("strategy_code")),
"strategy_name": strategy_label(item.get("paper_order_strategy_code") or item.get("strategy_code")),
"strategy_signal_id": _safe_int(item.get("paper_order_strategy_signal_id") or item.get("strategy_signal_id")),
}
item["paper_order"] = order
item["paper_order_status"] = order["status"]
@ -77,6 +81,10 @@ def _decorate_recommendation(item: dict) -> dict:
item["market_context"] = _loads_json(item.get("market_context_json"), {})
item["derivatives_context"] = _loads_json(item.get("derivatives_context_json"), {})
item["sector_context"] = _loads_json(item.get("sector_context_json"), {})
item["strategy_code"] = normalize_strategy_code(item.get("strategy_code"))
item["strategy_name"] = strategy_label(item["strategy_code"])
item["strategy_snapshot"] = _loads_json(item.get("strategy_snapshot_json"), {})
item["factor_roles"] = _loads_json(item.get("factor_roles_json"), {})
rec_result, rec_result_label = _classify_recommendation_result(item)
item["recommendation_result"] = rec_result
item["recommendation_result_label"] = rec_result_label
@ -242,7 +250,9 @@ def get_active_recommendations_deduped(
po.expires_at AS paper_order_expires_at,
po.filled_at AS paper_order_filled_at,
po.canceled_at AS paper_order_canceled_at,
po.cancel_reason AS paper_order_cancel_reason
po.cancel_reason AS paper_order_cancel_reason,
po.strategy_code AS paper_order_strategy_code,
po.strategy_signal_id AS paper_order_strategy_signal_id
FROM recommendation r
LEFT JOIN latest_price_cache lpc ON lpc.symbol = r.symbol
LEFT JOIN paper_orders po ON po.recommendation_id = r.id

View File

@ -10,6 +10,7 @@ from app.core.opportunity_lifecycle import (
is_executed_lifecycle,
normalize_action_status,
)
from app.core.strategy_registry import normalize_strategy_code, strategy_label
def state_fields_for_storage(status, action_status, execution_status="", reason=""):
@ -410,6 +411,17 @@ def derive_execution_fields(item):
item["sector_signal_count"] = sector_signal_count
item["strategy_version"] = strategy_version
item["strategy_version_label"] = f"策略版本 {strategy_version}" if strategy_version else ""
strategy_code = normalize_strategy_code(item.get("strategy_code"))
item["strategy_code"] = strategy_code
item["strategy_name"] = strategy_label(strategy_code)
try:
item["strategy_snapshot"] = json.loads(item.get("strategy_snapshot_json") or "{}")
except Exception:
item["strategy_snapshot"] = {}
try:
item["factor_roles"] = json.loads(item.get("factor_roles_json") or "{}")
except Exception:
item["factor_roles"] = {}
attach_discovery_trade_fields(item)
return item

View File

@ -3,6 +3,7 @@
import json
import re
from app.core.strategy_registry import normalize_strategy_code, strategy_label
from app.db.schema import get_conn
@ -47,12 +48,14 @@ def get_strategy_insights():
pt.side AS paper_side,
pt.source_status AS paper_source_status,
pt.source_action AS paper_source_action,
pt.strategy_code AS paper_strategy_code,
pt.realized_pnl_pct AS paper_realized_pnl_pct,
pt.realized_pnl_usdt AS paper_realized_pnl_usdt,
pt.pnl_pct AS paper_pnl_pct,
pt.exit_reason AS paper_exit_reason,
po.id AS paper_order_id,
po.status AS paper_order_status
po.status AS paper_order_status,
po.strategy_code AS paper_order_strategy_code
FROM recommendation r
LEFT JOIN paper_trades pt ON pt.recommendation_id = r.id
LEFT JOIN paper_orders po ON po.recommendation_id = r.id
@ -122,6 +125,8 @@ def get_strategy_insights():
trade_env_map = {}
trade_evidence_map = {}
trade_version_map = {}
strategy_code_map = {}
trade_strategy_code_map = {}
trade_factor_group_map = {}
trade_regime_map = {}
trade_score_band_map = {}
@ -153,6 +158,8 @@ def get_strategy_insights():
add_bucket(env_map, bucket, item)
if item.get("strategy_version"):
add_bucket(version_map, str(item.get("strategy_version")).strip(), item)
strategy_code = normalize_strategy_code(item.get("strategy_code") or item.get("paper_strategy_code") or item.get("paper_order_strategy_code"))
add_bucket(strategy_code_map, strategy_code, item)
if (item.get("execution_status") or "") in {"observe", "wait_pullback"} or (item.get("display_bucket") or "") == "watch_pool":
add_watch_bucket(watch_map, watch_bucket(item), item)
if item.get("paper_order_id"):
@ -182,6 +189,7 @@ def get_strategy_insights():
add_trade_bucket(trade_evidence_map, "链上:" + text, item)
if item.get("strategy_version"):
add_trade_bucket(trade_version_map, str(item.get("strategy_version")).strip(), item)
add_trade_bucket(trade_strategy_code_map, strategy_code, item)
return {
"overview": overview,
@ -195,6 +203,7 @@ def get_strategy_insights():
"market_environment": serialize_buckets("environment", env_map)[:20],
"evidence_attribution": serialize_buckets("evidence", evidence_map)[:20],
"version_performance": serialize_buckets("strategy_version", version_map, sort_by_version=True)[:20],
"strategy_performance": add_strategy_labels(serialize_buckets("strategy_code", strategy_code_map)[:20]),
"trade_attribution": {
"definition": "交易级归因只统计已平仓策略交易,用 realized_pnl_usdt / realized_pnl_pct 衡量因子、入场路径、退出原因和环境的真实账本表现。",
"factor": serialize_trade_buckets("factor", trade_factor_map)[:30],
@ -206,6 +215,7 @@ def get_strategy_insights():
"market_environment": serialize_trade_buckets("environment", trade_env_map)[:20],
"evidence": serialize_trade_buckets("evidence", trade_evidence_map)[:20],
"strategy_version": serialize_trade_buckets("strategy_version", trade_version_map, sort_by_version=True)[:20],
"strategy_code": add_strategy_labels(serialize_trade_buckets("strategy_code", trade_strategy_code_map)[:20]),
},
"watch_order_attribution": {
"definition": "观察池和挂单池只评价机会是否推进,不计入交易收益;用于判断没买/等回踩是否合理。",
@ -215,6 +225,14 @@ def get_strategy_insights():
}
def add_strategy_labels(rows):
for item in rows or []:
code = item.get("strategy_code") or item.get("name") or ""
item["strategy_code"] = normalize_strategy_code(code)
item["strategy_name"] = strategy_label(item["strategy_code"])
return rows
def add_trade_bucket(bucket_map, key, item):
if not key:
return

View File

@ -0,0 +1,102 @@
"""DB helpers for standard strategy signals."""
from __future__ import annotations
import json
from datetime import datetime
from app.core.strategy_contract import StrategySignal, strategy_context_payload
from app.core.strategy_registry import normalize_strategy_code
from app.db.schema import get_conn
def insert_strategy_signal(signal: StrategySignal | dict) -> dict:
payload = signal.to_json_dict() if isinstance(signal, StrategySignal) else strategy_context_payload(signal)
now = payload.get("created_at") or datetime.now().isoformat()
conn = get_conn()
try:
row = conn.execute(
"""
INSERT INTO strategy_signals (
run_id, strategy_code, strategy_version, symbol, direction, signal_status,
confidence, score, market_regime, trigger_json, factor_roles_json,
entry_plan_json, risk_plan_json, decision_log_json, created_at
) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING id
""",
(
payload.get("run_id") or "",
normalize_strategy_code(payload.get("strategy_code")),
payload.get("strategy_version") or "",
payload.get("symbol") or "",
payload.get("direction") or "long",
payload.get("status") or payload.get("signal_status") or "candidate",
float(payload.get("confidence") or 0),
float(payload.get("score") or 0),
(payload.get("trigger") or {}).get("market_regime") or "",
json.dumps(payload.get("trigger") or {}, ensure_ascii=False, default=str),
json.dumps(payload.get("factor_roles") or {}, ensure_ascii=False, default=str),
json.dumps(payload.get("entry_plan") or {}, ensure_ascii=False, default=str),
json.dumps(payload.get("risk_plan") or {}, ensure_ascii=False, default=str),
json.dumps(payload.get("decision_log") or {}, ensure_ascii=False, default=str),
now,
),
).fetchone()
signal_id = int(row["id"] if row else 0)
conn.commit()
payload["strategy_signal_id"] = signal_id
payload["id"] = signal_id
return payload
finally:
conn.close()
def list_recent_strategy_signals(strategy_code: str = "", symbol: str = "", limit: int = 50) -> list[dict]:
limit = max(1, min(int(limit or 50), 500))
where = []
params = []
if strategy_code:
where.append("strategy_code=%s")
params.append(normalize_strategy_code(strategy_code))
if symbol:
where.append("symbol=%s")
params.append(symbol)
where_sql = "WHERE " + " AND ".join(where) if where else ""
conn = get_conn()
try:
rows = conn.execute(
f"""
SELECT *
FROM strategy_signals
{where_sql}
ORDER BY created_at DESC, id DESC
LIMIT %s
""",
tuple(params + [limit]),
).fetchall()
return [dict(r) for r in rows]
finally:
conn.close()
def get_strategy_signal_summary(days: int = 7) -> list[dict]:
days = max(1, min(int(days or 7), 365))
conn = get_conn()
try:
rows = conn.execute(
"""
SELECT strategy_code,
COUNT(*) AS signal_count,
AVG(score) AS avg_score,
AVG(confidence) AS avg_confidence,
MAX(created_at) AS latest_at
FROM strategy_signals
WHERE created_at >= (NOW() - (%s || ' days')::interval)::TEXT
GROUP BY strategy_code
ORDER BY signal_count DESC, latest_at DESC
""",
(str(days),),
).fetchall()
return [dict(r) for r in rows]
finally:
conn.close()

View File

@ -51,7 +51,9 @@ from app.core.opportunity_funnel import build_screening_detail
from app.core.factor_scoring import FactorScorer
from app.core.market_regime import classify_market_regime
from app.db.onchain_db import get_onchain_factor_context
from app.db.strategy_signal_queries import insert_strategy_signal
from app.services.market_overview import get_crypto_market_overview
from app.strategies.box_retest_4h import build_box_retest_signal
from app.config.config_loader import _get_section as _get_cfg_section
from app.core.pa_engine import (
classify_candles, calc_atr, find_supply_demand_zones,
@ -63,6 +65,30 @@ exchange = ccxt.binance({"enableRateLimit": True})
REPO_ROOT = Path(__file__).resolve().parents[2]
def _strategy_context_for_recommendation(symbol: str, result: dict, entry_plan: dict) -> dict:
"""Build and persist a standard strategy signal when an independent strategy matches."""
bp_4h = result.get("box_breakout_pullback_4h") or (result.get("market_context") or {}).get("box_breakout_pullback_4h") or {}
if not bp_4h.get("detected"):
return {}
signal = build_box_retest_signal(
symbol=symbol,
current_price=result.get("price") or 0,
detection=bp_4h,
entry_plan=entry_plan or {},
market_regime=result.get("market_regime") or (result.get("market_context") or {}).get("market_regime") or {},
decision_log=result.get("decision_log") or {},
)
if not signal:
return {}
payload = insert_strategy_signal(signal)
return {
"strategy_code": payload.get("strategy_code"),
"strategy_signal_id": payload.get("strategy_signal_id") or payload.get("id") or 0,
"strategy_snapshot": payload,
"factor_roles": payload.get("factor_roles") or {},
}
def fetch_klines(symbol, timeframe, limit=200):
try:
ohlcv = exchange.fetch_ohlcv(symbol, timeframe, limit=limit)
@ -1873,6 +1899,7 @@ def main(compact: bool = False):
if plan_entry > 0 and (plan_stop >= plan_entry or (plan_tp1 > 0 and plan_tp1 <= plan_entry)):
rec_entry_price = result["price"]
previous_rec_id = _active_recommendation_id(symbol)
strategy_ctx = _strategy_context_for_recommendation(symbol, result, ep)
rec_id = create_recommendation(
symbol=symbol, rec_state="爆发", rec_score=result["score"],
entry_price=rec_entry_price,
@ -1885,6 +1912,7 @@ def main(compact: bool = False):
market_context=result.get("market_context"),
derivatives_context=result.get("derivatives_context"),
sector_context=result.get("sector_context"),
**strategy_ctx,
)
update_latest_price_cache(symbol, result["price"], updated_at=datetime.now().isoformat(), source="confirm")
result["rec_id"] = rec_id
@ -1927,6 +1955,7 @@ def main(compact: bool = False):
)
if _should_publish_watch_candidate(cand, result):
watch_plan = _watch_candidate_plan(symbol, result, cand_detail)
strategy_ctx = _strategy_context_for_recommendation(symbol, result, watch_plan)
rec_id = create_recommendation(
symbol=symbol,
rec_state="观察",
@ -1943,6 +1972,7 @@ def main(compact: bool = False):
market_context=result.get("market_context"),
derivatives_context=result.get("derivatives_context"),
sector_context=result.get("sector_context"),
**strategy_ctx,
)
update_latest_price_cache(symbol, result["price"], updated_at=datetime.now().isoformat(), source="confirm_watch")
result["rec_id"] = rec_id

View File

@ -0,0 +1,2 @@
"""Independent strategy modules."""

View File

@ -0,0 +1,101 @@
"""4H box breakout retest strategy candidate.
The detector factor is not the strategy. This module wraps that factor with
market, freshness, entry-distance and risk semantics to produce a standard
StrategySignal.
"""
from __future__ import annotations
from app.core.factor_roles import ENTRY, RISK, TRIGGER
from app.core.strategy_contract import StrategySignal, current_strategy_version
from app.core.strategy_registry import BOX_RETEST_4H_STRATEGY
def _safe_float(value, default=0.0) -> float:
try:
if value is None or value == "":
return default
return float(value)
except Exception:
return default
def build_box_retest_signal(
*,
symbol: str,
current_price: float,
detection: dict,
entry_plan: dict | None = None,
market_regime: dict | None = None,
decision_log: dict | None = None,
) -> StrategySignal | None:
if not (detection or {}).get("detected"):
return None
entry_plan = entry_plan or {}
market_regime = market_regime or {}
risk_level = str(market_regime.get("risk_level") or "medium").lower()
entry_zone = _safe_float(detection.get("entry_zone"))
current_price = _safe_float(current_price)
distance_pct = (current_price / entry_zone - 1) * 100 if entry_zone > 0 and current_price > 0 else 0
age = int(detection.get("pullback_age_bars") or 999)
quality = str(detection.get("quality") or "")
status = "candidate"
reasons = []
if risk_level == "critical":
status = "observe"
reasons.append("全局风险 critical仅观察")
if age > 4:
status = "observe"
reasons.append(f"回踩已过去 {age} 根4H时效偏旧")
if quality not in {"良好", "优质"}:
status = "observe"
reasons.append(f"形态质量 {quality or '未知'},不直接交易")
if distance_pct > 8:
status = "observe"
reasons.append(f"当前价离箱体上沿 {distance_pct:.1f}%,禁止追高")
score = _safe_float(detection.get("score"))
confidence = min(100.0, max(0.0, score * 8))
trigger = {
"factor_code": "box_breakout_pullback_4h",
"factor_label": "4H箱体突破回踩",
"box_high": detection.get("box_high"),
"box_low": detection.get("box_low"),
"entry_zone": detection.get("entry_zone"),
"stop_level": detection.get("stop_level"),
"pullback_kind": detection.get("pullback_kind"),
"pullback_age_bars": age,
"quality": quality,
"distance_to_entry_zone_pct": round(distance_pct, 4),
"market_regime": market_regime.get("regime") or "",
"risk_level": risk_level,
}
risk_plan = {
"invalid_if": ["跌回箱体", "放量冲高回落", "回踩过久", "全局风险升为critical"],
"stop_level": detection.get("stop_level"),
"risk_reasons": reasons,
}
return StrategySignal(
strategy_code=BOX_RETEST_4H_STRATEGY,
strategy_version=current_strategy_version(),
symbol=symbol,
direction="long",
status=status,
confidence=confidence,
score=score,
trigger=trigger,
factor_roles={
"box_breakout_pullback_4h": TRIGGER,
"pullback_15m_confirm": ENTRY,
"trend_exhaustion": RISK,
"false_breakout": RISK,
},
entry_plan=entry_plan,
risk_plan=risk_plan,
decision_log=decision_log or {
"module": "box_retest_4h_v1",
"decision": status,
"reasons": reasons,
},
)

View File

@ -0,0 +1,20 @@
"""Minimal strategy orchestration helpers for first-stage multi-strategy rollout."""
from __future__ import annotations
from app.core.strategy_contract import StrategySignal
def arbitrate_strategy_signals(signals: list[StrategySignal]) -> list[StrategySignal]:
"""Deduplicate same-symbol same-direction signals by confidence.
First stage deliberately avoids complex voting. Opposite directions are
left to future long/short architecture; current system is long-only.
"""
winners: dict[tuple[str, str], StrategySignal] = {}
for signal in signals or []:
key = (str(signal.symbol or "").upper(), str(signal.direction or "long").lower())
existing = winners.get(key)
if existing is None or float(signal.confidence or 0) > float(existing.confidence or 0):
winners[key] = signal
return list(winners.values())

View File

@ -34,10 +34,11 @@ async def api_paper_trading_trades(
limit: int = 50,
offset: int = 0,
status: str = "",
strategy_code: str = "",
altcoin_session: str = Cookie(default=""),
):
require_admin(altcoin_session)
return list_paper_trades(limit=limit, offset=offset, status=status)
return list_paper_trades(limit=limit, offset=offset, status=status, strategy_code=strategy_code)
@router.get("/api/paper-trading/orders")
@ -45,10 +46,11 @@ async def api_paper_trading_orders(
limit: int = 50,
offset: int = 0,
status: str = "",
strategy_code: str = "",
altcoin_session: str = Cookie(default=""),
):
require_admin(altcoin_session)
return list_paper_orders(limit=limit, offset=offset, status=status)
return list_paper_orders(limit=limit, offset=offset, status=status, strategy_code=strategy_code)
@router.get("/api/paper-trading/events")

View File

@ -0,0 +1,254 @@
# AlphaX 多策略改造计划
本文档定义 AlphaX 从“单一综合评分链路”升级为“多策略并行研究与交易验证平台”的改造计划。
核心原则:**因子不等于策略**。因子只能作为证据、条件或风控输入;策略必须是一套完整的交易剧本,包含适用环境、触发、确认、入场、退出、失效、仓位和复盘口径。
## 1. 为什么要改
当前系统已经有 `strategy_version`、`FactorScorer`、`factor_score_breakdown` 和 paper trading 归因,但主链路仍更像一个“大而全评分器”:
- 很多因子在同一个确认函数里叠加,容易把单根行情重复加分。
- 推荐和 paper trading 更容易知道“综合分高不高”,但不容易知道“到底是哪套策略赚了钱”。
- 复盘能看到因子胜率,但很难独立评估一个完整交易模型的胜率、回撤、盈亏比和适用行情。
- 新增强形态时容易误把一个因子升级为策略,例如 `box_breakout_pullback_4h`
改造目标是让系统支持多个独立策略同时运行,并让每个策略从发现到交易账本都有独立记录和评价体系。
## 0. 架构红线
这是基础设施重构,不是简单加几个字段。后续实现必须遵守:
- `strategy_code` 表示策略身份,`strategy_version` 表示版本,两者不能混用。
- 策略输出必须走统一契约,不能每个策略自定义一套不可比的 JSON。
- 推荐、挂单、持仓、事件日志、复盘都必须保留策略血缘。
- 因子角色必须显式声明,未知因子不能默认成为交易触发。
- 旧主链路可以兼容为 `main_composite_v1`,但新数据不允许没有策略来源。
- 策略中文名、描述、启用状态要集中维护,不能散落在页面和业务代码里。
- 多策略架构上线后,收益评价必须按策略拆分;总收益只能作为账户层结果,不能替代策略评价。
## 2. 基本概念
### 2.1 因子角色
每个因子必须先归类,不允许默认都作为策略核心触发。
- `prerequisite`:先决条件。决定能不能看,例如交易量、交易宇宙、市场环境、是否异常币。不能单独产生交易信号。
- `trigger`触发条件。让策略产生候选例如箱体突破回踩、1H 量价齐飞、短周期启动。
- `confirmation`确认条件。提高交易可信度例如板块共振、链上正向、大户偏多、1H 未衰竭。
- `entry`:入场条件。决定现在能不能买,例如 15m 承接、离箱体上沿距离、盈亏比。
- `risk`:风控条件。一票否决或降级,例如 risk_off、假突破、止损过宽、账户回撤过大。
- `attribution`:归因条件。用于复盘解释,不一定参与实时决策。
### 2.2 策略成立标准
一个策略至少要定义:
- `strategy_code`:稳定代码,例如 `box_retest_4h_v1`
- `strategy_version`:版本,例如 `v2026.05.26-r1`
- `market_regime`:适用市场环境。
- `universe_filter`:交易宇宙要求。
- `prerequisites`:先决条件。
- `trigger_model`:核心触发模型。
- `confirmation_model`:辅助确认模型。
- `entry_model`:入场模型。
- `exit_model`:止盈、止损、移动止盈、失效退出。
- `risk_model`:仓位、杠杆、账户级约束。
- `review_metrics`:胜率、收益率、最大回撤、盈亏比、持仓时长、机会转化率。
如果只有一个强因子,没有完整入场/退出/风控/复盘口径,只能叫“策略候选”或“因子”,不能叫正式策略。
## 3. 目标链路
```text
统一交易宇宙
-> 策略 A 独立扫描
-> 策略 B 独立扫描
-> 策略 C 独立扫描
-> 信号标准化
-> 冲突与重复仲裁
-> 推荐/观察/挂单
-> paper trading 保留策略血缘
-> 按策略独立复盘
-> 策略灰度/发布/淘汰
```
统一交易宇宙只负责把明显不适合交易的币剔除例如稳定币、封装币、inactive 交易对、异常交易对和长期流动性不合格的币。它不是策略,也不直接产生交易动作。
## 4. 初始策略池建议
### 4.1 `box_retest_4h_v1`
定位:底部箱体突破后,第一次或第二次回踩箱体上沿/EMA 承接。
- 先决条件:非 risk_off交易量合格非异常交易对箱体宽度合理。
- 核心触发:`box_breakout_pullback_4h`。
- 确认条件1H 不衰竭15m 有承接,板块或市场环境不拖后腿。
- 入场条件:当前价不能离箱体上沿太远,盈亏比合格。
- 失效条件:跌回箱体、放量冲高回落、突破后过久才回踩。
- 复盘口径:回踩入场后的 24h/72h 收益、最大回撤、止损触发率。
注意:`box_breakout_pullback_4h` 是该策略的核心触发因子,但不等于策略本身。
### 4.2 `momentum_acceleration_1h_v1`
定位1H 量价齐飞后的加速机会。
- 核心触发:`vp_fly_1h_current`。
- 风险重点:追高、假突破、短线衰竭。
- 入场要求:必须有 15m 承接或回踩,不允许单纯因为放量就直接买。
### 4.3 `short_tf_early_watch_v1`
定位5m/15m 早期启动观察,不直接交易。
- 核心触发:`short_tf_15m_ignition`、`short_tf_5m_ignition`。
- 默认动作:观察或加入候选,不直接进入 paper trading。
- 升级条件:后续 1H/4H 结构确认。
### 4.4 `onchain_tech_confirm_v1`
定位:链上重要资金行为 + 技术确认。
- 先决条件:链上事件必须可读、可映射、金额或置信度达到阈值。
- 核心触发:鲸鱼增持、聪明钱买入、交易所流出等。
- 入场要求:必须经过技术结构确认,不允许链上事件直接下单。
### 4.5 `top_gainer_second_wave_v1`
定位:强势榜币种第一波后,回踩承接走二波。
- 先决条件:不是纯追涨,涨幅后必须有回踩和承接。
- 核心触发:强势榜 + 回踩结构。
- 风险重点高位回落、流动性退潮、meme 过热。
## 5. 数据模型改造
### 5.1 第一阶段:兼容式加字段
优先在现有表增加策略血缘字段,避免一次性重构过大。
- `recommendation.strategy_code`
- `recommendation.strategy_signal_id`
- `recommendation.strategy_snapshot_json`
- `recommendation.factor_roles_json`
- `paper_trades.strategy_code`
- `paper_trades.strategy_signal_id`
- `paper_trades.strategy_snapshot_json`
- `paper_orders.strategy_code`
- `paper_orders.strategy_signal_id`
当前已有 `strategy_version`,后续它表示策略版本,不再替代策略身份。
### 5.2 第二阶段:新增策略信号表
新增 `strategy_signals`,作为策略输出的标准事实表:
- `id`
- `run_id`
- `strategy_code`
- `strategy_version`
- `symbol`
- `direction`
- `signal_status`
- `confidence`
- `market_regime`
- `trigger_json`
- `factor_roles_json`
- `entry_plan_json`
- `risk_plan_json`
- `decision_log_json`
- `created_at`
推荐、挂单、持仓都引用 `strategy_signal_id`,形成完整链路。
### 5.3 第三阶段:策略运行与评价表
新增:
- `strategy_catalog`:策略注册、启用状态、适用市场、版本。
- `strategy_run_log`:每次策略扫描的运行结果。
- `strategy_performance_daily`:按日聚合策略表现。
## 6. 执行模块改造
### P0先做策略规范和血缘记录
目标:不大改链路,先保证每个推荐和 paper trading 记录知道“来自哪套策略”。
- 建立 `app/core/strategy_contract.py`,定义策略输出结构。
- 建立 `app/core/factor_roles.py`,统一因子角色分类。
- 给 `recommendation` / `paper_trades` / `paper_orders``strategy_code``strategy_signal_id`
- 在确认层先把现有主链路标为 `main_composite_v1`
- 把 `box_breakout_pullback_4h` 标记为 `box_retest_4h_v1` 的核心触发候选,但仍通过完整策略条件判断。
### P1拆出第一个独立策略
目标:让 `box_retest_4h_v1` 独立运行,与原主链路并行。
- 新增 `app/strategies/box_retest_4h.py`
- 让它消费统一交易宇宙和 4H K线。
- 输出标准 `strategy_signal`
- 推荐层只消费标准信号,不关心策略内部细节。
- paper trading 保留 `strategy_code`
- 复盘页按策略展示表现。
### P2多策略编排与仲裁
目标:多个策略同时运行,但同一币种不要重复乱开仓。
- 新增 `strategy_orchestrator`
- 支持启用/禁用策略。
- 支持同币种冲突仲裁:同方向合并、不同方向拒绝或降级。
- 支持策略优先级和市场环境开关。
- 同一币种同一方向进入 paper trading 前必须检查账户集中度和累计杠杆。
### P3策略独立复盘与发布机制
目标:策略按独立样本晋级/淘汰,不再只看总评分。
- 每个策略独立统计:候选数、推荐数、成交数、胜率、平均收益、最大回撤、盈亏比、平均持仓时长。
- 每个策略按 market regime 拆分表现。
- 策略改动先进入灰度版本,不直接覆盖主版本。
- 连续低表现策略自动降级为 observe-only 或暂停。
## 7. 页面改造计划
### 策略交易页
- 持仓、挂单、已完成、日志增加 `策略` 列。
- 策略筛选器:全部 / 箱体突破回踩 / 量价加速 / 短周期观察 / 链上确认。
- 交易详情展示当时的 `strategy_snapshot`
### 机会总览页
- 列表只显示核心结论。
- 详情页展示策略来源、触发因子、确认因子、入场因子、风险因子。
### 复盘中心
- 增加“按策略”视图。
- 每个策略显示:样本数、转化率、胜率、收益、最大回撤、适用行情。
- 因子归因仍保留,但作为策略内部解释,不再替代策略评价。
## 8. 验收标准
第一阶段完成后,至少满足:
- 任意一条 recommendation 能看到 `strategy_code`
- 任意一笔 paper trade 能看到 `strategy_code`
- 任意一笔 paper order 能看到 `strategy_code`
- 任意一个策略信号能追溯到 `strategy_signals` 或标准 `StrategySignal` 快照。
- 复盘能按 `strategy_code` 聚合收益和胜率。
- `box_breakout_pullback_4h` 不会被文档或代码误称为完整策略。
- 新增策略必须通过策略成立标准检查。
- 新数据不得出现空 `strategy_code`
## 9. 开发纪律
- 不能因为一个因子表现好,就直接让它下单。
- 不能把先决条件当作策略触发。
- 不能把归因因子当作实时交易条件。
- 不能让 paper trading 丢失策略来源。
- 不能只看总收益,必须按策略看收益和回撤。
- 新策略必须先 observe-only 或 paper-only积累样本后再考虑真实同步。

View File

@ -0,0 +1,579 @@
# AlphaX 多策略改造实施计划
本文档是 `MULTI_STRATEGY_ARCHITECTURE.md` 的施工版,用于把多策略架构拆成可开发、可验证、可上线观察的任务包。
今晚目标不是一次性完成所有长期架构,而是交付一个能真实运行的第一阶段:
- 每条推荐、挂单、持仓都能追溯到策略。
- 现有主链路不被打断,统一标记为 `main_composite_v1`
- `box_retest_4h_v1` 进入独立策略雏形,不再只是综合确认层里的一个因子。
- paper trading 和复盘能按策略看表现。
- 前端至少能看到策略来源,明早可以观察跑出来的数据。
## 0. 基础设施重构原则
这次属于策略基础设施重构,不允许为了赶进度留下明显技术债。
必须遵守:
- DB schema 变化必须走 migration不允许业务函数里偷偷补字段。
- 策略身份必须用 `strategy_code`,版本必须用 `strategy_version`,不能继续混用。
- 新增策略输出必须走统一 `StrategySignal` 契约,不允许每个策略各写一套 JSON。
- 因子角色必须显式声明,未知因子默认只能作为 `attribution``unknown`,不能默认变成交易触发。
- paper trading、paper orders、recommendation 必须完整继承策略血缘,不能只在推荐层有策略,交易账本丢失。
- UI/API/复盘至少要有最小闭环,否则字段只是“存了但没人用”。
- 兼容旧数据可以回填为 `main_composite_v1`,但新数据不允许空策略来源。
- 不做全局散落的硬编码策略名,策略中文名、启用状态、描述应集中维护。
允许的兼容:
- 旧主链路继续运行,但必须标记为 `main_composite_v1`
- 第一版 `box_retest_4h_v1` 可以先 paper-only / observe-only。
- 复杂仲裁可以先做最小规则,但接口要为后续多策略扩展留好。
不允许的临时方案:
- 不允许把 `box_breakout_pullback_4h` 直接当 `strategy_code`
- 不允许只给 paper trades 加字段,却不改写入链路。
- 不允许只写文档不补测试。
- 不允许把策略表现继续只按 `strategy_version` 聚合。
- 不允许前端继续完全看不到策略来源。
## 1. 总体交付分层
### P0策略血缘打通
必须完成。目标是先让系统所有后续数据都带上策略来源。
- 新增策略标准结构。
- 新增因子角色定义。
- DB 增加策略血缘字段。
- 推荐写入时保存 `strategy_code`、`strategy_signal_id`、`strategy_snapshot_json`、`factor_roles_json`。
- paper orders / paper trades 继承推荐里的策略字段。
- API 和页面展示策略来源。
- 复盘统计支持按策略聚合。
- 测试覆盖 recommendation -> paper order -> paper trade 的策略血缘传递。
### P1第一个独立策略雏形
尽量完成。目标是让 `box_retest_4h_v1` 从因子变成独立策略候选。
- 新建 `app/strategies/box_retest_4h.py`
- 复用统一交易宇宙和 `detect_box_breakout_pullback_4h()`
- 输出标准策略信号。
- 当前先以 observe/paper-only 运行,避免直接影响真实同步。
### P2最小策略编排与仲裁
只做最小闭环。目标是不让多策略重复乱写推荐。
- 新建简单策略注册表/编排函数。
- 同一币种同方向保留最高置信度策略。
- 不同方向先降级观察,不做自动对冲。
- 先不做复杂策略权重投票。
### P3技术债清理与文档同步
必须完成最小项。目标是重构后代码结构清楚、后续可扩展。
- `AGENTS.md`、`OPTIMIZATION_TODO.md`、`MULTI_STRATEGY_ARCHITECTURE.md` 同步最终事实。
- 新增模块必须有单元测试。
- 任何未完成项必须写入 TODO不能藏在代码注释里。
- 运行命令和验收结果写入最终交付说明。
## 2. 数据库改造
### 2.1 新增 migration
新增文件:
```text
app/db/migrations/00xx_multi_strategy.sql
```
建议字段:
```sql
ALTER TABLE recommendation
ADD COLUMN IF NOT EXISTS strategy_code TEXT DEFAULT '',
ADD COLUMN IF NOT EXISTS strategy_signal_id BIGINT DEFAULT 0,
ADD COLUMN IF NOT EXISTS strategy_snapshot_json TEXT DEFAULT '{}',
ADD COLUMN IF NOT EXISTS factor_roles_json TEXT DEFAULT '{}';
ALTER TABLE paper_trades
ADD COLUMN IF NOT EXISTS strategy_code TEXT DEFAULT '',
ADD COLUMN IF NOT EXISTS strategy_signal_id BIGINT DEFAULT 0,
ADD COLUMN IF NOT EXISTS strategy_snapshot_json TEXT DEFAULT '{}',
ADD COLUMN IF NOT EXISTS factor_roles_json TEXT DEFAULT '{}';
ALTER TABLE paper_orders
ADD COLUMN IF NOT EXISTS strategy_code TEXT DEFAULT '',
ADD COLUMN IF NOT EXISTS strategy_signal_id BIGINT DEFAULT 0,
ADD COLUMN IF NOT EXISTS strategy_snapshot_json TEXT DEFAULT '{}',
ADD COLUMN IF NOT EXISTS factor_roles_json TEXT DEFAULT '{}';
```
新增标准策略信号表:
```sql
CREATE TABLE IF NOT EXISTS strategy_signals (
id BIGSERIAL PRIMARY KEY,
run_id TEXT DEFAULT '',
strategy_code TEXT NOT NULL,
strategy_version TEXT DEFAULT '',
symbol TEXT NOT NULL,
direction TEXT DEFAULT 'long',
signal_status TEXT DEFAULT 'candidate',
confidence DOUBLE PRECISION DEFAULT 0,
score DOUBLE PRECISION DEFAULT 0,
market_regime TEXT DEFAULT '',
trigger_json TEXT DEFAULT '{}',
factor_roles_json TEXT DEFAULT '{}',
entry_plan_json TEXT DEFAULT '{}',
risk_plan_json TEXT DEFAULT '{}',
decision_log_json TEXT DEFAULT '{}',
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_strategy_signals_code_time
ON strategy_signals(strategy_code, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_strategy_signals_symbol_time
ON strategy_signals(symbol, created_at DESC);
```
可选但建议同步新增策略目录表,避免策略中文名和启用状态散落在代码里:
```sql
CREATE TABLE IF NOT EXISTS strategy_catalog (
strategy_code TEXT PRIMARY KEY,
strategy_name TEXT NOT NULL,
strategy_version TEXT DEFAULT '',
status TEXT DEFAULT 'active',
mode TEXT DEFAULT 'paper_only',
description TEXT DEFAULT '',
config_json TEXT DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
```
如果今晚来不及完整管理页面,也应先提供 DB 表和默认种子。
### 2.2 兼容回填
旧数据没有策略来源时:
- `recommendation.strategy_code = 'main_composite_v1'`
- `paper_trades.strategy_code = COALESCE(recommendation.strategy_code, 'main_composite_v1')`
- `paper_orders.strategy_code = COALESCE(recommendation.strategy_code, 'main_composite_v1')`
回填只做一次,不改变收益数据。
回填后要加非空语义检查:
- 新推荐写入后 `strategy_code` 不得为空。
- 新 paper order 写入后 `strategy_code` 不得为空。
- 新 paper trade 写入后 `strategy_code` 不得为空。
### 2.3 migration 验收
必须验证:
```bash
docker compose run --rm alphax-web python scripts/postgres/run_migrations.py
docker compose exec alphax-web python - <<'PY'
from app.db.schema import get_conn
conn = get_conn()
for table in ["recommendation", "paper_trades", "paper_orders", "strategy_signals"]:
print(table, conn.execute("SELECT COUNT(*) FROM information_schema.columns WHERE table_name=%s", (table,)).fetchone()[0])
conn.close()
PY
```
## 3. 核心代码改造
### 3.1 新增 `app/core/factor_roles.py`
职责:
- 定义因子角色枚举。
- 维护默认因子角色映射。
- 提供 `factor_roles_for_codes(signal_codes)`
- 提供 `validate_factor_roles()`,防止未知因子默认变成交易触发。
初始角色建议:
```text
box_breakout_pullback_4h -> trigger
vp_fly_1h_current -> trigger / confirmation
short_tf_15m_ignition -> trigger但默认 observe-only
short_tf_5m_ignition -> prerequisite / early_watch
sector_rotation -> confirmation
sentiment_resonance -> confirmation
top_trader_long -> confirmation
exchange_outflow -> confirmation
false_breakout -> risk
trend_exhaustion -> risk
risk_reward_bad -> risk
entry_quality_gate -> risk
```
注意:同一因子在不同策略里可以有不同角色,但默认映射用于主链路兼容。
该模块必须保持纯函数,不依赖 DB不读运行时配置避免策略基础设施反向依赖业务层。
### 3.2 新增 `app/core/strategy_contract.py`
职责:
- 定义 `StrategySignal` 数据结构。
- 定义 `StrategyDecision` / `StrategyRiskPlan` / `StrategyEntryPlan` 的最小字段。
- 提供 `signal_to_recommendation_context()`,让推荐写入时能统一保存策略快照。
最小字段:
```python
strategy_code: str
strategy_version: str
symbol: str
direction: str
status: str
confidence: float
score: float
trigger: dict
factor_roles: dict
entry_plan: dict
risk_plan: dict
decision_log: dict
```
要求:
- 提供 `to_json_dict()`,统一序列化。
- 提供 `from_confirm_result()`,兼容现有确认链路。
- 提供 `default_main_composite_signal()`,给旧主链路生成标准策略快照。
- 不在这里调用数据库,不在这里拉行情。
### 3.2.1 新增 `app/db/strategy_signal_queries.py`
职责:
- 插入 `strategy_signals`
- 查询最近策略信号。
- 按 `strategy_code` 聚合信号数量。
不要塞进 `altcoin_db.py`
### 3.3 修改推荐写入
文件:
```text
app/db/recommendation_commands.py
```
改造点:
- `create_recommendation()` 增加参数:
- `strategy_code`
- `strategy_signal_id`
- `strategy_snapshot`
- `factor_roles`
- 未传时默认 `main_composite_v1`
- duplicate merge 时保留更强策略信号,或者暂时用最新策略覆盖空字段。
- INSERT / UPDATE 同步写入新字段。
- 写入前通过 `normalize_strategy_context()` 统一清洗,避免空字符串、坏 JSON、重复逻辑散落。
### 3.4 修改 paper trading
文件:
```text
app/db/paper_trading.py
```
改造点:
- `_open_trade()` 插入 `paper_trades` 时继承:
- `strategy_code`
- `strategy_signal_id`
- `strategy_snapshot_json`
- `factor_roles_json`
- `_order_payload_from_rec()` 和 paper order 插入时继承同样字段。
- `_record_event()` 的 detail_json 加入策略来源,方便操作日志直接解释。
- list API 返回策略字段,前端不需要二次查 recommendation。
### 3.5 修改推荐读模型
文件:
```text
app/db/recommendation_state.py
app/db/recommendation_queries.py
app/db/analytics.py
```
改造点:
- API 输出 `strategy_code`、`strategy_signal_id`、`strategy_snapshot`、`factor_roles`。
- 推荐详情页能展示策略来源。
- 归档/复盘列表支持 strategy 过滤。
### 3.6 策略注册中心
新增:
```text
app/core/strategy_registry.py
```
职责:
- 集中维护策略代码、中文名、描述、默认模式。
- 提供 `strategy_label(strategy_code)`
- 提供 `registered_strategy_codes()`
初始策略:
```text
main_composite_v1 -> 综合确认主链路
box_retest_4h_v1 -> 4H箱体突破回踩
```
前端展示中文名时优先用 registry/API 返回值,不硬编码。
## 4. 独立策略:`box_retest_4h_v1`
### 4.1 策略定位
不是“看到箱体突破回踩就买”,而是:
```text
底部箱体完成
-> 放量突破
-> 首次/二次回踩箱体上沿或 EMA
-> 回踩不破且承接
-> 当前价格距离入场区不过远
-> 市场环境允许
-> 盈亏比合格
-> 才进入观察/挂单/交易候选
```
### 4.2 新增文件
```text
app/strategies/__init__.py
app/strategies/box_retest_4h.py
app/strategies/orchestrator.py
```
### 4.3 第一版策略规则
先决条件:
- `market_regime.risk_level != critical`
- 交易宇宙通过。
- 4H 数据足够。
- 箱体宽度合理。
核心触发:
- `detect_box_breakout_pullback_4h().detected == True`
确认条件:
- `pullback_age_bars <= 4`
- `quality in 良好/优质`
- 没有明显 `trend_exhaustion`
- 不是已经远离 `entry_zone` 太多。
入场规则:
- 距离 `entry_zone` 小于配置阈值时允许挂单或买点确认。
- 如果已经大幅拉开,只进入观察,不追。
失效规则:
- 跌回箱体。
- 距离突破/回踩时间过久。
- 1H/15m 出现假突破或趋势衰竭。
输出:
- `StrategySignal(strategy_code='box_retest_4h_v1')`
### 4.4 与现有主链路关系
第一阶段不能直接删除现有主链路里的 `box_breakout_pullback_4h` 加分,否则会改变当前推荐行为过大。
处理方式:
- 主链路继续把它作为结构因子参与确认,策略来源仍为 `main_composite_v1`
- 独立策略并行生成 `box_retest_4h_v1` 信号。
- 如果同一 symbol 同时被主链路和箱体策略命中,短期由仲裁器保留更明确的策略快照,或在推荐 `strategy_snapshot_json` 中记录 secondary strategies。
- 明早先观察是否出现重复推荐,再决定是否让箱体策略接管该类信号。
## 5. API 与 UI 改造
### 5.1 机会总览
文件:
```text
static/app.html
app/web/routes_recommendations.py
```
改造点:
- 卡片显示中文策略名例如“4H箱体突破回踩策略”。
- 详情页展示:
- 策略来源
- 核心触发
- 确认因子
- 入场条件
- 风险/失效条件
- API 增加 `strategy_code`、`strategy_name`、`strategy_snapshot`、`factor_roles`。
### 5.2 策略交易页
文件:
```text
static/paper_trading.html
app/web/routes_paper_trading.py
```
改造点:
- 持仓中、挂单中、已完成、操作日志都显示策略列。
- 增加策略筛选器。
- 交易详情展示 strategy snapshot。
- 列表接口支持 `strategy_code` 参数过滤。
### 5.3 复盘中心
文件:
```text
app/db/review_center.py
app/db/strategy_insights.py
static/review_center.html
```
改造点:
- 增加“按策略”聚合。
- 指标:
- 推荐数
- 挂单数
- 成交数
- 平仓数
- 胜率
- 总收益
- 平均收益
- 最大回撤
- 平均持仓时长
- 适用 market regime
### 5.4 Pipeline / 日志中心
- pipeline 批次详情展示各策略产出的信号数量。
- 系统日志中策略运行错误要带 `strategy_code`
- 不要求今晚完整 UI但 API 返回结构要预留。
## 6. 测试计划
### 6.1 单元测试
新增/修改:
```text
tests/test_factor_roles.py
tests/test_strategy_contract.py
tests/test_strategy_lineage.py
tests/test_box_retest_strategy.py
tests/test_paper_trading.py
tests/test_strategy_insights.py
```
重点断言:
- 因子角色不会把未知因子默认当作 trigger。
- `box_breakout_pullback_4h` 只是 trigger不是完整策略。
- `create_recommendation()` 默认写入 `main_composite_v1`
- paper order / paper trade 继承 `strategy_code`
- 复盘可以按 `strategy_code` 聚合。
- API 输出策略中文名。
### 6.1.1 必须新增的回归测试
- migration 后字段存在。
- `StrategySignal` 序列化稳定。
- `main_composite_v1` 默认策略上下文生成。
- recommendation merge 不会清空已有策略来源。
- paper order 创建继承策略来源。
- paper trade 开仓继承策略来源。
- strategy aggregation 不再只按 `strategy_version`
### 6.2 容器验证
```bash
docker compose build alphax-web alphax-scheduler alphax-price-streamer
docker compose up -d alphax-web alphax-scheduler alphax-price-streamer
docker compose exec alphax-web python -m pytest -q tests/test_factor_roles.py tests/test_strategy_contract.py tests/test_strategy_lineage.py tests/test_box_retest_strategy.py
docker compose exec alphax-web python -m app.cli screener
docker compose exec alphax-web python -m app.cli confirm
docker compose exec alphax-web python -m app.cli paper-trader
```
## 7. 明早验收标准
明早交付至少要能做到:
- 数据库 migration 正常跑过。
- Docker 服务正常 up。
- 新推荐至少有 `strategy_code`
- 新挂单/持仓至少有 `strategy_code`
- paper trading 页面能看到策略来源。
- 复盘/策略归因能按策略聚合。
- `box_retest_4h_v1` 能独立输出策略信号或至少进入标准策略信号结构。
- `box_breakout_pullback_4h` 在代码和文档里没有被误当作完整策略。
- 不存在新增数据 `strategy_code=''` 的情况。
- 新增基础设施模块没有反向依赖 Web 或 DB 热路径。
- 未完成事项已明确写入 TODO不留口头债。
## 8. 风险边界
今晚不做:
- 不把多策略直接同步真实交易。
- 不做复杂策略投票。
- 不做多空完整重构。
- 不做大规模 UI 重设计。
- 不删除现有主链路。
今晚可以做:
- 保留主链路运行。
- 补策略血缘。
- 补第一版策略标准结构。
- 让 `box_retest_4h_v1` 作为独立候选跑起来。
- 给明早提供能观察的数据和复盘口径。
## 9. 今晚执行顺序
为了降低基础设施重构风险,按下面顺序施工:
1. 新增 migration 和回填。
2. 新增 `strategy_registry`、`factor_roles`、`strategy_contract` 纯核心模块。
3. 新增 `strategy_signal_queries`,不要碰业务主链路。
4. 修改 `create_recommendation()` 写入策略血缘。
5. 修改 paper orders / paper trades 继承策略血缘。
6. 修改读模型/API让前端能看到策略来源。
7. 修改 paper trading 页面最小展示策略列。
8. 新增 `box_retest_4h_v1` 策略模块和最小 orchestrator。
9. 补测试。
10. Docker build/up跑容器内回归。
11. 写明早交付总结和剩余 TODO。

View File

@ -9,6 +9,7 @@
- 高分机会和好买点必须拆开。
- 观察池、挂单池、交易账本不能混在一起。
- 所有能影响策略的因子都必须可复盘、可提权、可降权、可淘汰。
- 因子不等于策略。因子必须先归类为先决条件、触发、确认、入场、风控或归因;只有具备完整入场、退出、风控和复盘口径的交易剧本才能叫策略。
## 已完成
@ -30,9 +31,27 @@
- NodeReal 已作为当前链上主数据源DEX Screener / Etherscan / Helius 运行链路已移除。
- `FactorScorer` 已建立,确认层核心技术因子、板块、舆情、大户、链上因子已接入复盘权重。
- `factor_score_breakdown` 已进入确认上下文,复盘可追踪因子贡献。
- `box_breakout_pullback_4h` 已作为 4H 箱体突破回踩强结构因子接入确认层,但它只是 `box_retest_4h_v1` 这类策略的核心触发候选,不应单独等同于完整策略。
- 已新增 `docs/MULTI_STRATEGY_ARCHITECTURE.md`,定义多策略并行、策略血缘、因子角色和独立复盘口径。
## P0现在优先做
### 0. 多策略架构第一阶段
目标:从“大而全评分器”过渡到“统一交易宇宙 + 多个独立策略 + 独立交易账本评价”。
待做:
- 建立 `app/core/strategy_contract.py`,定义标准策略输出结构。
- 建立 `app/core/factor_roles.py`,统一因子角色:`prerequisite`、`trigger`、`confirmation`、`entry`、`risk`、`attribution`。
- 建立 `app/core/strategy_registry.py`,集中维护策略代码、中文名、描述、默认模式,避免策略名散落硬编码。
- 建立 `app/db/strategy_signal_queries.py``strategy_signals` 表,保存标准策略信号。
- 给 `recommendation`、`paper_trades`、`paper_orders` 增加 `strategy_code`、`strategy_signal_id`、`strategy_snapshot_json`、`factor_roles_json` 等策略血缘字段。
- 现有综合链路先标记为 `main_composite_v1`,避免无策略来源的推荐继续进入账本。
- 先把 `box_retest_4h_v1` 作为第一个独立策略候选拆出来:`box_breakout_pullback_4h` 只能是核心触发因子,仍要经过市场环境、交易宇宙、确认、入场、风控和失效条件。
- 复盘中心增加按 `strategy_code` 聚合的胜率、收益、最大回撤、盈亏比和持仓时长。
- 新数据不得出现空 `strategy_code`;旧数据通过 migration 回填为 `main_composite_v1`,但不能继续产生无来源样本。
### 1. Global Risk Engine
目标:单币再好,也要先看账户和大盘能不能开新仓。
@ -122,6 +141,8 @@
- Paper Trade Attribution按因子组、regime、entryAction 归因交易结果。
- Regime-based Scoring不同市场状态下使用不同因子权重。
- Watchlist / Trade Ledger 进一步分离:观察样本、挂单、持仓收益完全分开统计。
- 策略编排器:支持多个策略同时运行、启停、优先级、同币种冲突仲裁和同方向信号合并。
- 第一个独立策略模块:`app/strategies/box_retest_4h.py`,消费统一交易宇宙并输出标准 `strategy_signal`
## P2第三阶段
@ -130,6 +151,7 @@
- Onchain Flow 增强:链上事件按钱包角色、金额、方向、持续性细分。
- 舆情质量过滤:只在有稳定数据源时做 bot ratio / smart KOL。
- 策略分 regime 回测。
- `strategy_catalog`、`strategy_run_log`、`strategy_performance_daily` 等长期策略运营表。
## 暂缓
@ -140,8 +162,11 @@
## 下一步执行建议
1. 部署运行一段时间,观察 `market_regime`、`score_components`、`factor_score_breakdown.groups`、观察/挂单推进率是否能解释真实回撤。
2. 按线上样本校准 `entry_score` 门槛、group cap 和 high-risk 门槛。
3. 做 Regime-based Scoring让不同市场环境使用不同因子权重和分组上限。
4. 如果 JSON 决策日志查询不方便,再新增 `strategy_decision_log` 表和页面。
5. 后续再接入 BTC Dominance、TOTAL3、稳定币净流增强 Market Regime Engine。
1. 先完成多策略第一阶段的策略血缘字段和标准策略输出结构。
2. 把现有主链路标记为 `main_composite_v1`,确保 recommendation 和 paper trading 不再丢失策略来源。
3. 拆出 `box_retest_4h_v1` 作为第一个独立策略候选,先 observe/paper-only 跑样本。
4. 部署运行一段时间,观察 `market_regime`、`score_components`、`factor_score_breakdown.groups`、观察/挂单推进率是否能解释真实回撤。
5. 按线上样本校准 `entry_score` 门槛、group cap 和 high-risk 门槛。
6. 做 Regime-based Scoring让不同市场环境使用不同因子权重和分组上限。
7. 如果 JSON 决策日志查询不方便,再新增 `strategy_decision_log` 表和页面。
8. 后续再接入 BTC Dominance、TOTAL3、稳定币净流增强 Market Regime Engine。

View File

@ -338,7 +338,7 @@
{% block content %}
<div class="shell">
<!-- compatibility markers: 实时机会 / 历史机会 / drawPin / data-entry-price / v.count / 止损 / 止盈 -->
<!-- compatibility markers: 实时机会 / 实时推荐 / 历史机会 / 历史推荐 / drawPin / data-entry-price / v.count / 止损 / 止盈 -->
<div class="controls-row">
<div class="tabs">
<button class="tab-btn active" data-tab="live" onclick="switchTab('live')">实时机会<span class="count" id="liveCount"></span></button>
@ -890,6 +890,7 @@ function renderRecCard(r) {
return '<span class="sig '+cls+'">'+displaySignalText(s)+'</span>';
}).join('');
var score = r.rec_score||0, st = scoreTier(score), ver = r.strategy_version||'';
var strategyLabel = r.strategy_name || ({main_composite_v1:'综合确认主链路',box_retest_4h_v1:'4H箱体突破回踩'}[r.strategy_code||''] || r.strategy_code || '');
var hasQualityGate = ep.entry_quality_gate && Array.isArray(ep.entry_quality_gate.reasons) && ep.entry_quality_gate.reasons.length;
var entryLabel = isWait ? '回踩参考' : (hasQualityGate ? '失效参考' : '参考价位');
var entryRef = (isWait || hasQualityGate) ? (ep.entry_price || r.entry_price || 0) : (r.entry_price || ep.entry_price || 0);
@ -960,7 +961,7 @@ function renderRecCard(r) {
var compactSignals = sigs.slice(0,3).map(function(s){ return '<span class="summary-chip">'+displaySignalText(s)+'</span>'; }).join('');
var onchainChip = oc && (oc.event_count_24h || oc.onchain_score || oc.risk_score) ? '<span class="summary-chip">'+(ocRisk?'链上风险':'链上异动')+' '+(oc.event_count_24h||0)+'</span>' : '';
var orderChip = r.paper_order && r.paper_order.id ? '<span class="summary-chip">挂单 '+(PAPER_ORDER_STATUS_LABELS[String(r.paper_order.status||'').toLowerCase()]||r.paper_order.status)+'</span>' : '';
return '<a class="card summary-card '+(isWeakObserve?'weak-observe':'')+'" href="'+detailHref+'"><div class="summary-top"><div class="summary-main"><div class="summary-symbol"><div class="coin-icon">'+base.slice(0,2).toUpperCase()+'</div><div><span class="coin-symbol">'+base+'</span><div class="summary-meta">'+fmtTime(r.rec_time)+' · '+cleanDisplayText(levelLabel)+' · '+cleanDisplayText(horizon || levelFrameText(levelKey))+'</div></div></div></div><div class="summary-score"><span class="action-badge '+phase.cls+'">'+phase.label+'</span><span class="score-badge '+st.cls+'"><span class="score-num">'+score+'</span><span class="score-label">总分</span></span></div></div><div class="summary-price-row"><div class="summary-stat"><span>当前价</span><b>$'+priceFmt+'</b></div><div class="summary-stat"><span>'+changeLabel+'</span><b class="'+(changePct>=0?'green':'red')+'">'+(changePct==null?'--':changeSign+changePct.toFixed(1)+'%')+'</b></div><div class="summary-stat"><span>'+(isWait?'计划回踩':'计划入场')+'</span><b>'+fmtP(entryRef || price)+'</b></div><div class="summary-stat"><span>止损 / 目标</span><b>'+riskText+' / '+targetText+'</b></div></div><div class="summary-decision '+decisionCls+'"><h3>'+decisionTitle+' · '+decisionFocus+'</h3><p>'+decisionReason+'</p></div><div class="summary-tags"><div class="summary-chips">'+(compactSignals||'<span class="summary-chip">暂无明确信号</span>')+onchainChip+orderChip+'</div><span class="detail-link">查看详情</span></div></a>';
return '<a class="card summary-card '+(isWeakObserve?'weak-observe':'')+'" href="'+detailHref+'"><div class="summary-top"><div class="summary-main"><div class="summary-symbol"><div class="coin-icon">'+base.slice(0,2).toUpperCase()+'</div><div><span class="coin-symbol">'+base+'</span><div class="summary-meta">'+fmtTime(r.rec_time)+' · '+cleanDisplayText(strategyLabel || levelLabel)+' · '+cleanDisplayText(horizon || levelFrameText(levelKey))+'</div></div></div></div><div class="summary-score"><span class="action-badge '+phase.cls+'">'+phase.label+'</span><span class="score-badge '+st.cls+'"><span class="score-num">'+score+'</span><span class="score-label">总分</span></span></div></div><div class="summary-price-row"><div class="summary-stat"><span>当前价</span><b>$'+priceFmt+'</b></div><div class="summary-stat"><span>'+changeLabel+'</span><b class="'+(changePct>=0?'green':'red')+'">'+(changePct==null?'--':changeSign+changePct.toFixed(1)+'%')+'</b></div><div class="summary-stat"><span>'+(isWait?'计划回踩':'计划入场')+'</span><b>'+fmtP(entryRef || price)+'</b></div><div class="summary-stat"><span>策略</span><b>'+cleanDisplayText(strategyLabel || '--')+'</b></div></div><div class="summary-decision '+decisionCls+'"><h3>'+decisionTitle+' · '+decisionFocus+'</h3><p>'+decisionReason+'</p></div><div class="summary-tags"><div class="summary-chips">'+(compactSignals||'<span class="summary-chip">暂无明确信号</span>')+onchainChip+orderChip+'</div><span class="detail-link">查看详情</span></div></a>';
} catch (e) {
console.error('renderRecCard hard fail', r && r.symbol, e);
return renderLiveFallbackCard(r);

View File

@ -164,10 +164,11 @@ function renderOrders(items){if(!items.length){$('orderRows').innerHTML='<tr><td
'<td><div class="riskline"><span>TP $'+fmt(x.tp1,6)+'</span><span>SL $'+fmt(x.stop_loss,6)+'</span></div></td>'+
'<td>'+time(x.created_at)+'</td>'+
'<td>'+time(x.expires_at)+'</td>'+
'<td><div>'+esc(x.source_status||'--')+'</div><div class="muted">'+esc(x.source_action||'')+'</div></td>'+
'<td><div>'+esc(strategyName(x))+'</div><div class="muted">'+esc(x.source_status||'--')+' · '+esc(x.source_action||'')+'</div></td>'+
'<td><button class="row-action" type="button" onclick="deleteOrder('+Number(x.id)+',\''+esc(String(x.symbol||'')).replace(/'/g,'&#39;')+'\')">删除</button></td>'+
'</tr>'}).join('')}
function protectionCell(x){var trail=Number(x.trailing_stop||0);var trailHtml=trail>0?'<span class="trail-line">移动止盈 $'+fmt(trail,6)+'</span>':'<span class="trail-line off">移动止盈未启动</span>';return '<div class="riskline"><span>TP $'+fmt(x.tp1,6)+'</span><span>SL $'+fmt(x.stop_loss,6)+'</span>'+trailHtml+'</div>'}
function strategyName(x){return (x&&x.strategy_name)||({main_composite_v1:'综合确认主链路',box_retest_4h_v1:'4H箱体突破回踩'}[(x&&x.strategy_code)||'']||((x&&x.strategy_code)||'--'))}
async function loadOpenTrades(nextOffset){openOffset=Math.max(0,nextOffset||0);$('openRows').innerHTML='<tr><td colspan="14" class="loading">加载中...</td></tr>';try{var d=await api('/api/paper-trading/trades?limit='+LIMIT+'&offset='+openOffset+'&status=open');openTotal=d.total||0;renderTradeRows('openRows',d.items||[],'暂无持仓中的策略交易');renderOpenPager()}catch(e){$('openRows').innerHTML='<tr><td colspan="14" class="empty">'+esc(e.message)+'</td></tr>'}}
async function loadCompleted(){await Promise.all([loadCompletedTrades(),loadCompletedOrders()])}
async function loadCompletedTrades(){$('completedTradeRows').innerHTML='<tr><td colspan="14" class="loading">加载中...</td></tr>';try{var d=await api('/api/paper-trading/trades?limit=80&offset=0&status=closed');renderTradeRows('completedTradeRows',d.items||[],'暂无已平仓策略交易')}catch(e){$('completedTradeRows').innerHTML='<tr><td colspan="14" class="empty">'+esc(e.message)+'</td></tr>'}}
@ -185,7 +186,7 @@ function renderTradeRows(targetId,items,emptyText){if(!items.length){$(targetId)
'<td>'+pct(x.status==='closed'?x.realized_pnl_pct:x.pnl_pct)+'</td>'+
'<td><div>'+money(pnlUsdt)+'</div><div class="muted">账户 '+(x.account_return_pct>0?'+':'')+fmt(x.account_return_pct,2)+'% · 保证金 '+(x.margin_roi_pct>0?'+':'')+fmt(x.margin_roi_pct,2)+'%</div></td>'+
'<td>'+esc(x.exit_reason||'--')+'</td>'+
'<td><div>'+esc(x.source_status||'--')+'</div><div class="muted">'+esc(x.strategy_version||'')+'</div></td>'+
'<td><div>'+esc(strategyName(x))+'</div><div class="muted">'+esc(x.source_status||'--')+' · '+esc(x.strategy_version||'')+'</div></td>'+
'<td><button class="row-action" type="button" onclick="deleteTrade('+Number(x.id)+',\''+esc(String(x.symbol||'')).replace(/'/g,'&#39;')+'\',\''+esc(String(x.status||'')).replace(/'/g,'&#39;')+'\')">删除</button></td>'+
'</tr>'}).join('')}
function renderCompletedOrders(items){if(!items.length){$('completedOrderRows').innerHTML='<tr><td colspan="11" class="empty">暂无已结束策略挂单</td></tr>';return}$('completedOrderRows').innerHTML=items.map(function(x){var latest=x.latest_price||x.current_price_at_create||0,dist=Number(x.distance_to_target_pct||0),ended=x.filled_at||x.canceled_at||x.updated_at;return '<tr>'+
@ -198,7 +199,7 @@ function renderCompletedOrders(items){if(!items.length){$('completedOrderRows').
'<td><div class="riskline"><span>TP $'+fmt(x.tp1,6)+'</span><span>SL $'+fmt(x.stop_loss,6)+'</span></div></td>'+
'<td>'+time(x.created_at)+'</td>'+
'<td>'+time(ended)+'</td>'+
'<td><div>'+esc(x.cancel_reason||x.source_status||'--')+'</div><div class="muted">'+esc(x.source_action||'')+'</div></td>'+
'<td><div>'+esc(strategyName(x))+'</div><div class="muted">'+esc(x.cancel_reason||x.source_status||'--')+' · '+esc(x.source_action||'')+'</div></td>'+
'<td><button class="row-action" type="button" onclick="deleteOrder('+Number(x.id)+',\''+esc(String(x.symbol||'')).replace(/'/g,'&#39;')+'\')">删除</button></td>'+
'</tr>'}).join('')}
function orderStatus(x){return {filled:'已成交',expired:'已过期',canceled:'已取消',rejected:'已拒绝'}[x.status]||x.status||'--'}

View File

@ -56,7 +56,7 @@ function rows(items,label,value,sub){items=items||[];if(!items.length)return '<d
function digestItems(items,label,sub){items=items||[];if(!items.length)return '<div class="digest-empty">暂无动作</div>';return items.slice(0,4).map(function(x){return '<div class="digest-item"><b>'+esc(label(x))+'</b><span>'+esc(sub?sub(x):'')+'</span></div>'}).join('')}
function renderStrategyDigest(d){var it=d.iteration||{},dig=it.digest||{},latest=dig.latest||{},m=latest.metrics||{},decision=latest.decision||'hold';var badgeCls=decision==='release'?'ok':decision==='gray'?'warn':'warn';$('strategyDigest').innerHTML='<div class="digest-head"><div><div class="digest-title">策略迭代摘要</div><div class="digest-sub">'+esc(latest.title||'暂无复盘')+' · '+time(latest.time)+' · 版本 '+esc(latest.strategy_version||'--')+'</div></div><span class="badge '+badgeCls+'">'+esc(decision)+'</span></div><div class="kpis">'+[['因子生效调整',m.factor_weight_updates||0,'blue'],['候选 / 灰度',(it.summary&&it.summary.candidate_count||0)+' / '+(it.summary&&it.summary.gray_count||0),''],['本轮有效复盘',m.effective_review_count||0,''],['发布状态',latest.reason||'继续观察','']].map(function(x){return '<div class="kpi"><span>'+esc(x[0])+'</span><b class="'+(x[2]||'')+'">'+esc(x[1])+'</b></div>'}).join('')+'</div><div class="digest-grid"><div class="digest-card"><h3>升权了什么</h3>'+digestItems(dig.upgraded,function(x){return x.signal||'--'},function(x){return '权重 '+x.old_weight+' → '+x.new_weight+' · 样本 '+(x.sample_size||0)+' · 命中 '+(x.hit_rate||0)+'%'})+'</div><div class="digest-card"><h3>降权 / 淘汰</h3>'+digestItems(dig.downgraded,function(x){return x.signal||'--'},function(x){return (x.action||'调整')+' · '+(x.old_weight!=null?'权重 '+x.old_weight+' → '+x.new_weight:x.detail||'')})+'</div><div class="digest-card"><h3>灰度观察</h3>'+digestItems(dig.gray,function(x){return x.signal||('候选 #'+x.id)},function(x){return '样本 '+(x.sample_size||0)+' · 置信 '+(x.confidence||0)+'% · '+(x.description||'')})+'</div><div class="digest-card"><h3>最近迭代</h3>'+digestItems(dig.timeline,function(x){return time(x.time)+' · '+(x.decision||'hold')},function(x){return x.reason||x.title||''})+'</div></div>'}
function renderOpportunity(o){var s=o.summary||{};$('oppDef').textContent=o.definition||'';$('opportunityPanel').innerHTML=kpis([['机会样本',s.total_opportunities||0,'blue'],['可买/等回踩',(s.buy_now_count||0)+' / '+(s.wait_pullback_count||0),''],['策略执行',s.paper_executed_count||0,'green'],['漏选爆发',s.missed_explosion_count||0,'red'],['有效复盘',s.effective_review_count||0,''],['机会命中',num(s.opportunity_hit_rate,1)+'%','green'],['观察样本',s.observe_count||0,''],['失效样本',s.invalid_count||0,'red']])+'<div class="split"><div><div class="mini-title">状态分布</div>'+rows(o.status_distribution,function(x){return x.name||'--'},function(x){return x.count})+'</div><div><div class="mini-title">复盘结果</div>'+rows(o.outcome_distribution,function(x){return x.name||'--'},function(x){return x.count})+'</div></div>'}
function renderPaper(p){var s=p.summary||{},ta=p.trade_attribution||{},wo=p.watch_order_attribution||{},tf=ta.factor||[],te=ta.entry_path||[],tg=ta.factor_group||[],wr=wo.watch_pool||[],orows=wo.paper_orders||[];$('paperDef').textContent=p.definition||'';$('paperPanel').innerHTML=kpis([['当前余额','$'+num(s.current_balance_usdt,2),'blue'],['总收益',usd(s.total_pnl_usdt),Number(s.total_pnl_usdt||0)>=0?'green':'red'],['账户收益率',pct(s.account_total_return_pct),Number(s.account_total_return_pct||0)>=0?'green':'red'],['胜率',num(s.win_rate,1)+'%',''],['开仓/平仓',(s.open_count||0)+' / '+(s.closed_count||0),''],['已实现',usd(s.realized_pnl_usdt),Number(s.realized_pnl_usdt||0)>=0?'green':'red'],['未实现',usd(s.open_unrealized_pnl_usdt),Number(s.open_unrealized_pnl_usdt||0)>=0?'green':'red'],['累计杠杆',num(s.cumulative_leverage,2)+'x','']])+'<div class="split"><div><div class="mini-title">退出原因</div>'+rows(p.exit_reasons,function(x){return x.name||'--'},function(x){return x.count})+'</div><div><div class="mini-title">执行事件</div>'+rows(p.event_types,function(x){return x.name||'--'},function(x){return x.count})+'</div><div><div class="mini-title">真实交易因子</div>'+rows(tf.slice(0,6),function(x){return x.factor||'--'},function(x){return usd(x.realized_pnl_usdt)},function(x){return '平仓 '+(x.closed_trade_count||0)+' · 胜率 '+num(x.win_rate_pct,1)+'% · 均值 '+pct(x.avg_realized_pnl_pct)})+'</div><div><div class="mini-title">因子组表现</div>'+rows(tg.slice(0,6),function(x){return x.factor_group||'--'},function(x){return usd(x.realized_pnl_usdt)},function(x){return '平仓 '+(x.closed_trade_count||0)+' · 胜率 '+num(x.win_rate_pct,1)+'%'})+'</div><div><div class="mini-title">入场路径表现</div>'+rows(te.slice(0,6),function(x){return x.entry_path||'--'},function(x){return usd(x.realized_pnl_usdt)},function(x){return '平仓 '+(x.closed_trade_count||0)+' · 胜率 '+num(x.win_rate_pct,1)+'%'})+'</div><div><div class="mini-title">观察/挂单推进</div>'+rows(wr.concat(orows).slice(0,6),function(x){return x.watch_bucket||x.order_bucket||'--'},function(x){return (x.executed_pct!=null?num(x.executed_pct,1):num(x.fill_pct,1))+'%'},function(x){return '样本 '+(x.opportunity_count||x.order_count||0)+' · 执行/成交 '+(x.executed_count||x.filled_count||0)})+'</div></div>'}
function renderPaper(p){var s=p.summary||{},ta=p.trade_attribution||{},wo=p.watch_order_attribution||{},tf=ta.factor||[],te=ta.entry_path||[],tg=ta.factor_group||[],ts=ta.strategy_code||[],wr=wo.watch_pool||[],orows=wo.paper_orders||[];$('paperDef').textContent=p.definition||'';$('paperPanel').innerHTML=kpis([['当前余额','$'+num(s.current_balance_usdt,2),'blue'],['总收益',usd(s.total_pnl_usdt),Number(s.total_pnl_usdt||0)>=0?'green':'red'],['账户收益率',pct(s.account_total_return_pct),Number(s.account_total_return_pct||0)>=0?'green':'red'],['胜率',num(s.win_rate,1)+'%',''],['开仓/平仓',(s.open_count||0)+' / '+(s.closed_count||0),''],['已实现',usd(s.realized_pnl_usdt),Number(s.realized_pnl_usdt||0)>=0?'green':'red'],['未实现',usd(s.open_unrealized_pnl_usdt),Number(s.open_unrealized_pnl_usdt||0)>=0?'green':'red'],['累计杠杆',num(s.cumulative_leverage,2)+'x','']])+'<div class="split"><div><div class="mini-title">策略表现</div>'+rows(ts.slice(0,8),function(x){return x.strategy_name||x.strategy_code||'--'},function(x){return usd(x.realized_pnl_usdt)},function(x){return '平仓 '+(x.closed_trade_count||0)+' · 胜率 '+num(x.win_rate_pct,1)+'% · 均值 '+pct(x.avg_realized_pnl_pct)})+'</div><div><div class="mini-title">退出原因</div>'+rows(p.exit_reasons,function(x){return x.name||'--'},function(x){return x.count})+'</div><div><div class="mini-title">执行事件</div>'+rows(p.event_types,function(x){return x.name||'--'},function(x){return x.count})+'</div><div><div class="mini-title">真实交易因子</div>'+rows(tf.slice(0,6),function(x){return x.factor||'--'},function(x){return usd(x.realized_pnl_usdt)},function(x){return '平仓 '+(x.closed_trade_count||0)+' · 胜率 '+num(x.win_rate_pct,1)+'% · 均值 '+pct(x.avg_realized_pnl_pct)})+'</div><div><div class="mini-title">因子组表现</div>'+rows(tg.slice(0,6),function(x){return x.factor_group||'--'},function(x){return usd(x.realized_pnl_usdt)},function(x){return '平仓 '+(x.closed_trade_count||0)+' · 胜率 '+num(x.win_rate_pct,1)+'%'})+'</div><div><div class="mini-title">入场路径表现</div>'+rows(te.slice(0,6),function(x){return x.entry_path||'--'},function(x){return usd(x.realized_pnl_usdt)},function(x){return '平仓 '+(x.closed_trade_count||0)+' · 胜率 '+num(x.win_rate_pct,1)+'%'})+'</div><div><div class="mini-title">观察/挂单推进</div>'+rows(wr.concat(orows).slice(0,6),function(x){return x.watch_bucket||x.order_bucket||'--'},function(x){return (x.executed_pct!=null?num(x.executed_pct,1):num(x.fill_pct,1))+'%'},function(x){return '样本 '+(x.opportunity_count||x.order_count||0)+' · 执行/成交 '+(x.executed_count||x.filled_count||0)})+'</div></div>'}
function renderEvidence(e){var s=e.summary||{};$('evidenceDef').textContent=e.definition||'';$('evidencePanel').innerHTML=kpis([['新闻事件',s.news_count||0,'blue'],['有效舆情',s.actionable_news_count||0,''],['链上信号',s.onchain_signal_count||0,'blue'],['高置信链上',s.high_confidence_onchain_count||0,'green'],['原始链上',s.raw_onchain_count||0,''],['已映射原始',s.mapped_raw_onchain_count||0,'green'],['LLM 调用',s.llm_runs||0,''],['LLM 成功',s.llm_success_count||0,'green']])+'<div class="split"><div><div class="mini-title">链上信号</div>'+rows(e.onchain_signals,function(x){return x.name||'--'},function(x){return x.count})+'</div><div><div class="mini-title">舆情决策</div>'+rows(e.news_decisions,function(x){return x.name||'未处理'},function(x){return x.count})+'</div></div>'}
function renderIteration(i){var s=i.summary||{};$('iterationDef').textContent=i.definition||'';$('iterationPanel').innerHTML=kpis([['迭代记录',s.iteration_count||0,'blue'],['候选规则',s.candidate_count||0,''],['灰度规则',s.gray_count||0,'green'],['生效规则',s.active_count||0,'green']])+'<div class="row" style="margin-bottom:10px"><div class="row-main"><div class="row-title">最新发布结论:'+esc(s.latest_release_decision||'hold')+'</div><div class="row-sub">'+esc(s.latest_release_reason||'暂无发布说明')+'</div></div><div class="value">闸门</div></div><div class="split"><div><div class="mini-title">发布决策</div>'+rows(i.release_decisions,function(x){return x.name||'--'},function(x){return x.count})+'</div><div><div class="mini-title">候选状态</div>'+rows(i.candidate_status,function(x){return x.name||'--'},function(x){return x.count})+'</div></div>'}
function renderRecent(d){var opp=(d.opportunity&&d.opportunity.missed_explosions)||[], trades=(d.paper_trading&&d.paper_trading.recent_trades)||[], news=(d.evidence&&d.evidence.recent_news)||[], chain=(d.evidence&&d.evidence.recent_onchain)||[];$('recentPanel').innerHTML='<div class="split"><div><div class="mini-title">最近策略交易</div>'+rows(trades.slice(0,8),function(x){return (x.symbol||'--')+' · '+(x.status||'--')},function(x){return x.status==='closed'?pct(x.realized_pnl_pct):pct(x.pnl_pct)},function(x){return time(x.opened_at)+' · '+(x.exit_reason||x.source_status||'')})+'</div><div><div class="mini-title">漏选爆发</div>'+rows(opp.slice(0,8),function(x){return x.symbol||'--'},function(x){return pct(x.gain_pct)},function(x){return time(x.detect_time)+' · '+(x.reason_missed||'')})+'</div><div><div class="mini-title">舆情事件</div>'+rows(news.slice(0,8),function(x){return (x.symbol||'--')+' · '+(x.title||'--')},function(x){return x.importance||'--'},function(x){return time(x.detected_at)+' · '+(x.source||'')+' · '+(x.decision||'未处理')})+'</div><div><div class="mini-title">链上信号</div>'+rows(chain.slice(0,8),function(x){return (x.symbol||'--')+' · '+(x.signal_label||x.signal_code||'--')},function(x){return x.severity||x.confidence||'--'},function(x){return time(x.detected_at)+' · '+(x.source||'')+' · '+(x.direction||'')})+'</div></div>'}

View File

@ -84,6 +84,7 @@ _ID_TABLES = {
"review_log",
"missed_explosions",
"strategy_iteration_log",
"strategy_signals",
"strategy_rule_candidate",
"strategy_failure_pattern",
"push_log",

View File

@ -0,0 +1,116 @@
from app.core.factor_roles import RISK, TRIGGER, factor_role, factor_roles_for_codes
from app.core.strategy_contract import StrategySignal, default_main_composite_signal
from app.core.strategy_registry import BOX_RETEST_4H_STRATEGY, MAIN_COMPOSITE_STRATEGY, strategy_label
from app.db.recommendation_commands import create_recommendation
from app.db.strategy_signal_queries import insert_strategy_signal
from app.db.paper_trading import _open_trade, _order_payload_from_rec
def test_factor_roles_never_promote_unknown_to_trigger():
assert factor_role("box_breakout_pullback_4h") == TRIGGER
assert factor_role("false_breakout") == RISK
assert factor_role("new_unknown_factor") == "unknown"
assert factor_roles_for_codes(["box_breakout_pullback_4h", "new_unknown_factor"]) == {
"box_breakout_pullback_4h": "trigger",
"new_unknown_factor": "unknown",
}
def test_default_main_composite_strategy_signal_is_stable():
signal = default_main_composite_signal(
symbol="AAA/USDT",
score=70,
signal_codes=["vp_fly_1h_current"],
entry_plan={"entry_action": "观察"},
).to_json_dict()
assert signal["strategy_code"] == MAIN_COMPOSITE_STRATEGY
assert signal["strategy_name"] == "综合确认主链路"
assert signal["factor_roles"]["vp_fly_1h_current"] == "trigger"
def test_strategy_signal_insert_and_recommendation_lineage(pg_conn):
signal = insert_strategy_signal(
StrategySignal(
strategy_code=BOX_RETEST_4H_STRATEGY,
symbol="BOX/USDT",
score=10,
confidence=80,
trigger={"factor_code": "box_breakout_pullback_4h"},
factor_roles={"box_breakout_pullback_4h": "trigger"},
entry_plan={"entry_action": "等回踩", "entry_price": 1.0},
)
)
rec_id = create_recommendation(
symbol="BOX/USDT",
rec_state="爆发",
rec_score=30,
entry_price=1.0,
stop_loss=0.94,
tp1=1.12,
signals=["4H箱体突破回踩(箱体上沿 $1, 量2x)"],
entry_plan={"entry_action": "等回踩", "entry_price": 1.0, "stop_loss": 0.94, "tp1": 1.12},
strategy_code=signal["strategy_code"],
strategy_signal_id=signal["strategy_signal_id"],
strategy_snapshot=signal,
factor_roles=signal["factor_roles"],
)
row = pg_conn.execute("SELECT * FROM recommendation WHERE id=%s", (rec_id,)).fetchone()
assert row["strategy_code"] == BOX_RETEST_4H_STRATEGY
assert row["strategy_signal_id"] == signal["strategy_signal_id"]
assert "box_breakout_pullback_4h" in row["factor_roles_json"]
def test_paper_order_and_trade_inherit_strategy_lineage(pg_conn):
rec = {
"id": 1,
"symbol": "BOX/USDT",
"rec_score": 100,
"entry_price": 1.0,
"stop_loss": 0.94,
"tp1": 1.12,
"tp2": 1.2,
"execution_status": "buy_now",
"action_status": "可即刻买入",
"strategy_version": "v-test",
"strategy_code": BOX_RETEST_4H_STRATEGY,
"strategy_signal_id": 42,
"strategy_snapshot_json": '{"strategy_code":"box_retest_4h_v1"}',
"factor_roles_json": '{"box_breakout_pullback_4h":"trigger"}',
"entry_plan": {"entry_action": "可即刻买入", "entry_price": 1.0, "stop_loss": 0.94, "tp1": 1.12},
}
payload = _order_payload_from_rec(rec, 1.01, "2026-05-27T00:00:00", {"trade_notional_usdt": 100})
assert payload["strategy_code"] == BOX_RETEST_4H_STRATEGY
assert payload["strategy_signal_id"] == 42
result = _open_trade(
pg_conn,
rec,
1.0,
"2026-05-27T00:00:00",
config={
"enabled": True,
"trade_notional_usdt": 100,
"trade_leverage": 1,
"account_equity_usdt": 20000,
"fee_rate": 0,
"min_rec_score": 0,
"min_rr": 0,
"max_stop_loss_leverage_risk_pct": 999,
"max_cumulative_leverage": 999,
"max_account_drawdown_pause_pct": 0,
"weak_entry_pause": {"enabled": False},
},
push_open_card=False,
)
assert result["opened"] is True
row = pg_conn.execute("SELECT * FROM paper_trades WHERE id=%s", (result["trade_id"],)).fetchone()
event = pg_conn.execute("SELECT * FROM paper_trade_events WHERE trade_id=%s", (result["trade_id"],)).fetchone()
assert row["strategy_code"] == BOX_RETEST_4H_STRATEGY
assert row["strategy_signal_id"] == 42
assert event["strategy_code"] == BOX_RETEST_4H_STRATEGY
assert strategy_label(row["strategy_code"]) == "4H箱体突破回踩"