diff --git a/AGENTS.md b/AGENTS.md index 43d0309..41f5af8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`。 diff --git a/app/core/factor_roles.py b/app/core/factor_roles.py new file mode 100644 index 0000000..792bef5 --- /dev/null +++ b/app/core/factor_roles.py @@ -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 diff --git a/app/core/strategy_contract.py b/app/core/strategy_contract.py new file mode 100644 index 0000000..204bdf3 --- /dev/null +++ b/app/core/strategy_contract.py @@ -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 {}, + } diff --git a/app/core/strategy_registry.py b/app/core/strategy_registry.py new file mode 100644 index 0000000..96882e9 --- /dev/null +++ b/app/core/strategy_registry.py @@ -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 diff --git a/app/db/altcoin_db.py b/app/db/altcoin_db.py index 7c0ae87..2277e21 100644 --- a/app/db/altcoin_db.py +++ b/app/db/altcoin_db.py @@ -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 diff --git a/app/db/analytics.py b/app/db/analytics.py index 01f473a..9c620ad 100644 --- a/app/db/analytics.py +++ b/app/db/analytics.py @@ -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 diff --git a/app/db/migrations/0015_multi_strategy.sql b/app/db/migrations/0015_multi_strategy.sql new file mode 100644 index 0000000..4a56d3d --- /dev/null +++ b/app/db/migrations/0015_multi_strategy.sql @@ -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, '') = ''; diff --git a/app/db/paper_trading.py b/app/db/paper_trading.py index 4c1e93e..488dbf3 100644 --- a/app/db/paper_trading.py +++ b/app/db/paper_trading.py @@ -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, diff --git a/app/db/recommendation_commands.py b/app/db/recommendation_commands.py index 21f85f7..4b0cd98 100644 --- a/app/db/recommendation_commands.py +++ b/app/db/recommendation_commands.py @@ -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, ), ) diff --git a/app/db/recommendation_queries.py b/app/db/recommendation_queries.py index d0b1859..4444766 100644 --- a/app/db/recommendation_queries.py +++ b/app/db/recommendation_queries.py @@ -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 diff --git a/app/db/recommendation_state.py b/app/db/recommendation_state.py index 6fe788a..91f0b60 100644 --- a/app/db/recommendation_state.py +++ b/app/db/recommendation_state.py @@ -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 diff --git a/app/db/strategy_insights.py b/app/db/strategy_insights.py index 3d876b7..8c69910 100644 --- a/app/db/strategy_insights.py +++ b/app/db/strategy_insights.py @@ -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 diff --git a/app/db/strategy_signal_queries.py b/app/db/strategy_signal_queries.py new file mode 100644 index 0000000..8284755 --- /dev/null +++ b/app/db/strategy_signal_queries.py @@ -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() diff --git a/app/services/altcoin_confirm.py b/app/services/altcoin_confirm.py index e99f200..ef7df71 100644 --- a/app/services/altcoin_confirm.py +++ b/app/services/altcoin_confirm.py @@ -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 diff --git a/app/strategies/__init__.py b/app/strategies/__init__.py new file mode 100644 index 0000000..4de074d --- /dev/null +++ b/app/strategies/__init__.py @@ -0,0 +1,2 @@ +"""Independent strategy modules.""" + diff --git a/app/strategies/box_retest_4h.py b/app/strategies/box_retest_4h.py new file mode 100644 index 0000000..013bcc6 --- /dev/null +++ b/app/strategies/box_retest_4h.py @@ -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, + }, + ) diff --git a/app/strategies/orchestrator.py b/app/strategies/orchestrator.py new file mode 100644 index 0000000..562e3f5 --- /dev/null +++ b/app/strategies/orchestrator.py @@ -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()) diff --git a/app/web/routes_paper_trading.py b/app/web/routes_paper_trading.py index 5b82e5c..b6ed657 100644 --- a/app/web/routes_paper_trading.py +++ b/app/web/routes_paper_trading.py @@ -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") diff --git a/docs/MULTI_STRATEGY_ARCHITECTURE.md b/docs/MULTI_STRATEGY_ARCHITECTURE.md new file mode 100644 index 0000000..4564cee --- /dev/null +++ b/docs/MULTI_STRATEGY_ARCHITECTURE.md @@ -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,积累样本后再考虑真实同步。 diff --git a/docs/MULTI_STRATEGY_IMPLEMENTATION_PLAN.md b/docs/MULTI_STRATEGY_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..c22fa32 --- /dev/null +++ b/docs/MULTI_STRATEGY_IMPLEMENTATION_PLAN.md @@ -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。 diff --git a/docs/OPTIMIZATION_TODO.md b/docs/OPTIMIZATION_TODO.md index 2b3f397..bc61e93 100644 --- a/docs/OPTIMIZATION_TODO.md +++ b/docs/OPTIMIZATION_TODO.md @@ -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。 diff --git a/static/app.html b/static/app.html index b13112a..d7f68a7 100644 --- a/static/app.html +++ b/static/app.html @@ -338,7 +338,7 @@ {% block content %}
'+decisionReason+'
'+decisionReason+'