diff --git a/AGENTS.md b/AGENTS.md index f235471..e8a7fcc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,7 +75,7 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组 - `app/cli.py` - 统一命令入口:`screener`, `confirm`, `tracker`, `paper-trader`, `price-streamer`, `market`, `review`, `event`, `sentiment`, `onchain`, `llm-insights`。 -## 4. 代码主线 +## 4. 代码结构 ### 4.1 推荐系统业务闭环 @@ -88,7 +88,7 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组 3. `app/services/altcoin_confirm.py` 负责确认,判断候选是否形成更可执行的机会,并生成入场计划、上下文和推送候选。 4. `app/services/event_driven_screener.py` - 负责事件/舆情驱动的快速触发检查,是技术筛选主链路的补充入口。 + 负责事件/舆情驱动的快速触发检查,是多策略发现层的补充入口。 5. `app/services/price_streamer.py` 负责实时价格缓存,不等同于完整推荐状态跟踪。 6. `app/services/price_tracker.py` @@ -104,7 +104,7 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组 - 核心认知:因子不等于策略。一个因子可以是先决条件、触发、确认、入场、风控或归因,但不能因为单个因子表现好就直接升级成完整策略。 - 完整策略必须至少包含:适用市场环境、交易宇宙、先决条件、核心触发、辅助确认、入场规则、止盈止损、失效条件、仓位/杠杆约束和独立复盘口径。 -- 多策略链路必须保持独立:`main_composite_v1`、`box_retest_1h_v1`、`box_retest_4h_v1` 等策略可以共享行情、账户级风控和执行框架,但不能共享同一套入场门槛、RR、挂单距离和 paper trading 执行门禁。 +- 多策略链路必须保持独立:`main_composite_v1`、`box_retest_1h_v1`、`box_retest_4h_v1`、`volume_ignition_1h_v1`、`compression_breakout_4h_v1`、`intraday_momentum_15m_v1` 等策略可以共享行情、账户级风控和执行框架,但不能共享同一套入场门槛、RR、挂单距离和 paper trading 执行门禁。 - 策略级配置入口在 `app/core/strategy_registry.py`,`StrategyDefinition.entry_gate_config` 控制确认/跟踪/展示派生的买点质量闸门,`StrategyDefinition.paper_config` 控制该策略进入 paper trading 的入场、挂单和动态杠杆门槛。 - 新增策略时必须注册稳定 `strategy_code`,并明确自己的 `entry_gate_config` / `paper_config`。不要把新策略的特殊门槛写进全局 `paper_trading_config()` 或 `DEFAULT_ENTRY_GATE`,否则会影响其他策略的信号生成和成交样本。 - `apply_entry_quality_gate()` 必须传入或从 `entry_plan.strategy_code` 派生策略身份;`paper_trader.py` 中开仓、挂单、挂单成交和挂单维护应通过策略级配置合并后的参数执行。 @@ -126,8 +126,8 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组 - 多策略改造计划记录在 `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 承接、盈亏比、失效条件和账户风控。 +- 现有综合确认策略在迁移期标记为 `main_composite_v1`,它只是平等策略之一,用于避免无策略来源的推荐继续进入 paper trading。 +- 当前已拆出的独立策略包括:`box_retest_1h_v1` / `box_retest_4h_v1` 箱体突破回踩、`volume_ignition_1h_v1` 1H放量突破启动、`compression_breakout_4h_v1` 4H压缩蓄力突破、`intraday_momentum_15m_v1` 15m日内动量延续。它们可以共享交易宇宙和行情数据,但必须保留各自的触发、入场、失效和 paper trading 门禁。 - 新增策略必须先 observe-only 或 paper-only 积累样本,再进入灰度/发布;不能因为某个因子短期表现好就直接同步真实交易。 ### 4.1.3 链上数据源 @@ -180,7 +180,7 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组 - 市场环境识别中心,第一版基于市场快照、BTC/ETH 涨跌、山寨涨跌广度、强势/大跌数量和 funding 热度识别 `risk_off`、`btc_main_uptrend`、`altcoin_rotation`、`sideways_chop`、`meme_frenzy`、`unknown`。 - `app/core/global_risk.py` - paper trading 全局风控门禁。单币机会进入开仓或挂单成交前,需要先检查市场环境和账户风险;critical 禁止新开仓,high 只允许高质量机会。 -- 多策略基础设施当前内置 `main_composite_v1`、`box_retest_4h_v1`、`box_retest_1h_v1`。`box_breakout_pullback_4h` / `box_breakout_pullback_1h` 只是触发因子,只有和入场确认、风控、失效条件组成完整剧本后,才作为对应策略信号写入 `strategy_signals`。 +- 多策略基础设施当前内置 `main_composite_v1`、`box_retest_4h_v1`、`box_retest_1h_v1`、`volume_ignition_1h_v1`、`compression_breakout_4h_v1`、`intraday_momentum_15m_v1`。`box_breakout_pullback_4h` / `box_breakout_pullback_1h`、`vp_fly_1h_current`、`short_tf_15m_ignition` 等只是因子,只有和入场确认、风控、失效条件组成完整剧本后,才作为对应策略信号写入 `strategy_signals`。 - 确认层也会应用同一市场风控语义:`risk_level=critical` 且 `position_multiplier=0` 时,强势发现仍可记录为观察,但不能输出 `buy_now` 或新挂单动作;已有活跃可交易推荐会被降级为观察并写入 `market_risk_gate`。 ## 5. 数据与状态中心 @@ -205,7 +205,7 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组 - `app/db/recommendation_queries.py` - 推荐热路径查询、active/deduped 查询;不应反向依赖 `altcoin_db.py`。 - `app/db/push_queries.py` - - 推送冷却去重、推送日志、推送前单条推荐读取;推送层只能消费这里派生后的主链路口径。 + - 推送冷却去重、推送日志、推送前单条推荐读取;推送层只能消费这里派生后的统一状态口径。 - `app/db/tracking_queries.py` - 最新价格缓存、推荐跟踪价格/PnL 写入、入场时点更新。 - `app/db/cron_queries.py` @@ -296,7 +296,7 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组 - `/docker` - 容器入口与调度器 - `/tools` - - 非主链路工具脚本,如回测和输出摘要脚本 + - 非运行服务工具脚本,如回测和输出摘要脚本 - `/templates` - 后端读取的 HTML 模板资源 - `/docs` @@ -423,7 +423,7 @@ docker compose run --rm alphax-web python scripts/postgres/validate_import.py -- - schema 变化必须通过 `app/db/migrations/*.sql`。 - 查询最新运行状态优先看 PostgreSQL 表,而不是历史文件。 - Docker 容器内运行和宿主机运行可能使用不同连接地址,排查时先确认 `DATABASE_URL`。 -- 调度器并发运行时要检查 lock group,避免多个任务同时写推荐主链路。 +- 调度器并发运行时要检查 lock group,避免多个任务同时写推荐状态机和策略交易账本。 ### 9.3 状态机不要各写各的 @@ -448,7 +448,7 @@ docker compose run --rm alphax-web python scripts/postgres/validate_import.py -- ### 9.4 推荐链路当前特别注意点 -当前主链路已经能持续产生筛选和确认样本,但后半段仍需要重点盯住: +当前多策略发现与确认链路已经能持续产生筛选和确认样本,但后半段仍需要重点盯住: - `latest_price_cache` 可能是实时的,但不代表 `recommendation.pnl_pct` 已更新。 - `price_tracking` 是跟踪流水,不应和 `latest_price_cache` 混为一谈。 @@ -507,6 +507,11 @@ docker compose run --rm alphax-web python scripts/postgres/validate_import.py -- 4. 修改后至少补 1 个相关测试,最好补到最接近业务口径的那层。 5. 如果变更影响推荐状态或展示桶,务必同时检查 API、前端、推送、paper trading、历史统计五个面。 6. 如果变更影响调度任务,务必检查 `scheduler_job_config`、`scheduler_runtime_status` 和最近 `cron_run_log`。 +7. 不要把“底座已建好”当成“功能已完成”。涉及策略、复盘、交易、UI 的任务必须形成端到端闭环:数据写入、状态流转、API、页面展示、操作入口、测试和文档至少各检查一次。 +8. 少问用户开放问题。除非存在资金安全、真实下单、删除数据、不可逆迁移等高风险选择,否则应基于当前代码和产品目标直接推进,并在结果里说明假设和取舍。 +9. 如果发现自己只完成了半成品,不能用“后续可以”收尾;应继续把缺口补完,或明确标记为阻塞并说明为什么当前环境无法完成。 +10. 多策略相关改动必须同时回答三个问题:每个策略是否独立产生信号、是否能独立进入/退出策略交易、是否能独立复盘评价并给出保留/灰度/暂停建议。 +11. UI 层必须与后端能力对等。后端支持多策略筛选、评价或状态时,页面也要提供可理解的筛选、展示和操作入口,不能只返回隐藏字段。 --- diff --git a/app/analysis/reverse_analysis.py b/app/analysis/reverse_analysis.py index 3132b10..6d43834 100644 --- a/app/analysis/reverse_analysis.py +++ b/app/analysis/reverse_analysis.py @@ -425,7 +425,7 @@ def discover_new_rules(pattern_summary, all_features, sector_alignments, signifi if not rule: continue - # 新体系:逆向分析只生成候选规则,不直接写 learned_rules,避免涨幅榜小样本污染主策略。 + # 新体系:逆向分析只生成候选规则,不直接写 learned_rules,避免涨幅榜小样本污染已发布策略。 rule["candidate_id"] = upsert_strategy_rule_candidate( source="reverse_analysis", rule_type=rule.get("type", "bonus"), diff --git a/app/config/config_loader.py b/app/config/config_loader.py index 3789eaa..c5ade72 100644 --- a/app/config/config_loader.py +++ b/app/config/config_loader.py @@ -425,7 +425,7 @@ def promote_candidate_rule_to_learned_rule(candidate, release_version=""): """把通过发布门槛的候选规则正式写入 learned_rules。 候选规则来自 DB strategy_rule_candidate;只有发布闸门通过时才调用, - 避免日常研究直接污染主策略。 + 避免日常研究直接污染已发布策略。 """ desc = (candidate.get("rule_description") or "").strip() if not desc: diff --git a/app/core/strategy_registry.py b/app/core/strategy_registry.py index b50d7be..495c67d 100644 --- a/app/core/strategy_registry.py +++ b/app/core/strategy_registry.py @@ -8,6 +8,9 @@ from dataclasses import dataclass, field MAIN_COMPOSITE_STRATEGY = "main_composite_v1" BOX_RETEST_1H_STRATEGY = "box_retest_1h_v1" BOX_RETEST_4H_STRATEGY = "box_retest_4h_v1" +VOLUME_IGNITION_1H_STRATEGY = "volume_ignition_1h_v1" +COMPRESSION_BREAKOUT_4H_STRATEGY = "compression_breakout_4h_v1" +INTRADAY_MOMENTUM_15M_STRATEGY = "intraday_momentum_15m_v1" @dataclass(frozen=True) @@ -24,8 +27,8 @@ class StrategyDefinition: STRATEGY_DEFINITIONS: dict[str, StrategyDefinition] = { MAIN_COMPOSITE_STRATEGY: StrategyDefinition( strategy_code=MAIN_COMPOSITE_STRATEGY, - strategy_name="综合确认主链路", - description="迁移期兼容主链路,承载现有综合筛选与确认逻辑。", + strategy_name="综合确认策略", + description="迁移期兼容综合策略,承载现有综合筛选与确认逻辑;它与其他策略平等运行。", mode="paper_enabled", ), BOX_RETEST_1H_STRATEGY: StrategyDefinition( @@ -72,6 +75,70 @@ STRATEGY_DEFINITIONS: dict[str, StrategyDefinition] = { "dynamic_leverage_min": 3, }, ), + VOLUME_IGNITION_1H_STRATEGY: StrategyDefinition( + strategy_code=VOLUME_IGNITION_1H_STRATEGY, + strategy_name="1H放量突破启动", + description="1H量价齐飞或连续放量后的启动策略,适合捕捉日内到3天的第一段加速。", + mode="paper_only", + entry_gate_config={ + "min_entry_score_buy_now": 2, + "min_entry_score_wait_pullback": 1, + "min_rr_buy_now": 1.25, + "breakout_distance_wait_pct": 12, + "gain_24h_wait_pct": 10, + }, + paper_config={ + "entry_min_rr": 1.5, + "order_min_rr": 1.5, + "order_min_distance_to_entry_pct": 0, + "order_require_current_trigger": True, + "dynamic_leverage_enabled": True, + "dynamic_leverage_min": 3, + }, + ), + COMPRESSION_BREAKOUT_4H_STRATEGY: StrategyDefinition( + strategy_code=COMPRESSION_BREAKOUT_4H_STRATEGY, + strategy_name="4H压缩蓄力突破", + description="4H静K蓄力、底部抬高或压缩放量后的突破策略,偏向捕捉1周以内主升前段。", + mode="paper_only", + entry_gate_config={ + "min_entry_score_buy_now": 2, + "min_entry_score_wait_pullback": 0, + "min_rr_buy_now": 1.3, + "max_wait_pullback_deviation_pct": 18, + "breakout_distance_wait_pct": 18, + "gain_24h_wait_pct": 12, + }, + paper_config={ + "entry_min_rr": 1.6, + "order_min_rr": 1.6, + "order_min_distance_to_entry_pct": 0, + "order_require_current_trigger": False, + "dynamic_leverage_enabled": True, + "dynamic_leverage_min": 3, + }, + ), + INTRADAY_MOMENTUM_15M_STRATEGY: StrategyDefinition( + strategy_code=INTRADAY_MOMENTUM_15M_STRATEGY, + strategy_name="15m日内动量延续", + description="短周期放量突破与1H背景共振的日内动量策略,只做当前触发,不做纯观察追高。", + mode="paper_only", + entry_gate_config={ + "min_entry_score_buy_now": 3, + "min_entry_score_wait_pullback": 2, + "min_rr_buy_now": 1.4, + "breakout_distance_wait_pct": 8, + "gain_24h_wait_pct": 8, + }, + paper_config={ + "entry_min_rr": 1.6, + "order_min_rr": 1.6, + "order_min_distance_to_entry_pct": 0, + "order_require_current_trigger": True, + "dynamic_leverage_enabled": True, + "dynamic_leverage_min": 3, + }, + ), } diff --git a/app/db/migrations/0015_multi_strategy.sql b/app/db/migrations/0015_multi_strategy.sql index 4a56d3d..31406a1 100644 --- a/app/db/migrations/0015_multi_strategy.sql +++ b/app/db/migrations/0015_multi_strategy.sql @@ -65,7 +65,7 @@ CREATE INDEX IF NOT EXISTS idx_paper_orders_strategy_code 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), + ('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, diff --git a/app/db/migrations/0017_altcoin_parallel_strategies.sql b/app/db/migrations/0017_altcoin_parallel_strategies.sql new file mode 100644 index 0000000..42766a8 --- /dev/null +++ b/app/db/migrations/0017_altcoin_parallel_strategies.sql @@ -0,0 +1,12 @@ +INSERT INTO strategy_catalog ( + strategy_code, strategy_name, strategy_version, status, mode, description, config_json, created_at, updated_at +) VALUES + ('volume_ignition_1h_v1', '1H放量突破启动', '', 'active', 'paper_only', '1H量价齐飞或连续放量后的启动策略,捕捉日内到3天的第一段加速。', '{}', NOW()::TEXT, NOW()::TEXT), + ('compression_breakout_4h_v1', '4H压缩蓄力突破', '', 'active', 'paper_only', '4H静K蓄力、底部抬高或压缩放量后的突破策略,偏向捕捉1周以内主升前段。', '{}', NOW()::TEXT, NOW()::TEXT), + ('intraday_momentum_15m_v1', '15m日内动量延续', '', 'active', 'paper_only', '短周期放量突破与1H背景共振的日内动量策略,只做当前触发,不做纯观察追高。', '{}', 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; diff --git a/app/db/migrations/0018_strategy_equal_status_labels.sql b/app/db/migrations/0018_strategy_equal_status_labels.sql new file mode 100644 index 0000000..edd688a --- /dev/null +++ b/app/db/migrations/0018_strategy_equal_status_labels.sql @@ -0,0 +1,5 @@ +UPDATE strategy_catalog +SET strategy_name='综合确认策略', + description='迁移期兼容综合策略:统一承载旧的综合筛选与确认逻辑,与其他策略平等运行。', + updated_at=NOW()::TEXT +WHERE strategy_code='main_composite_v1'; diff --git a/app/db/paper_trading.py b/app/db/paper_trading.py index d9d525e..24ec2fb 100644 --- a/app/db/paper_trading.py +++ b/app/db/paper_trading.py @@ -1956,11 +1956,12 @@ def list_paper_orders(limit: int = 50, offset: int = 0, status: str = "", strate } -def list_paper_trade_events(limit: int = 80, offset: int = 0, symbol: str = "", event_type: str = "") -> dict: +def list_paper_trade_events(limit: int = 80, offset: int = 0, symbol: str = "", event_type: str = "", strategy_code: str = "") -> dict: limit = max(1, min(_safe_int(limit, 80), 200)) offset = max(0, _safe_int(offset, 0)) symbol = str(symbol or "").strip().upper() event_type = str(event_type or "").strip() + strategy_code = str(strategy_code or "").strip() where = [] params = [] if symbol: @@ -1969,11 +1970,19 @@ def list_paper_trade_events(limit: int = 80, offset: int = 0, symbol: str = "", if event_type: where.append("e.event_type=%s") params.append(event_type) + if strategy_code: + where.append("COALESCE(NULLIF(e.strategy_code, ''), t.strategy_code)=%s") + params.append(normalize_strategy_code(strategy_code)) where_sql = "WHERE " + " AND ".join(where) if where else "" conn = get_conn() try: total = conn.execute( - f"SELECT COUNT(*) FROM paper_trade_events e {where_sql}", + f""" + SELECT COUNT(*) + FROM paper_trade_events e + LEFT JOIN paper_trades t ON t.id = e.trade_id + {where_sql} + """, tuple(params), ).fetchone()[0] rows = conn.execute( diff --git a/app/db/recommendation_state.py b/app/db/recommendation_state.py index a920224..46f86ba 100644 --- a/app/db/recommendation_state.py +++ b/app/db/recommendation_state.py @@ -28,7 +28,7 @@ def derive_minimal_state_fields(status, action_status, entry_plan=None): action = normalize_action_status(action_status, status) if action == "可即刻买入": execution_status = "buy_now" - reason = "主链路确认当前入场窗口" + reason = "策略确认当前入场窗口" elif action == "等回踩": execution_status = "wait_pullback" reason = "等待回踩触发,未触发前不计推荐收益" @@ -169,7 +169,7 @@ def execution_fields_from_persisted_state(item, entry_plan=None): return "invalid", "🔴 已失效,勿追", reason if execution_status == "buy_now": stop = str(entry_plan.get("stop_loss", "")) if entry_plan else "" - return "buy_now", "🟢 现在可买", "推荐时就是可即刻买入;主链路确认当前仍在入场窗口" + ((",风险边界 " + stop) if stop else "") + return "buy_now", "🟢 现在可买", "推荐时就是可即刻买入;策略确认当前仍在入场窗口" + ((",风险边界 " + stop) if stop else "") if execution_status == "wait_pullback": gate = entry_plan.get("entry_quality_gate") or {} if gate.get("reasons"): diff --git a/app/db/review_center.py b/app/db/review_center.py index 7190b67..33ac27a 100644 --- a/app/db/review_center.py +++ b/app/db/review_center.py @@ -13,7 +13,7 @@ from datetime import datetime, timedelta from app.db.paper_trading import get_paper_trading_summary from app.db.schema import get_conn -from app.db.strategy_insights import get_strategy_insights +from app.db.strategy_insights import get_strategy_evaluation, get_strategy_insights def _safe_int(value, default=0): @@ -387,6 +387,7 @@ def get_review_center_dashboard(days=30): paper = _paper_review(conn, since, days) evidence = _evidence_review(conn, since) iteration = _iteration_review(conn, since) + strategy_evaluation = get_strategy_evaluation(days=days) finally: conn.close() @@ -400,6 +401,7 @@ def get_review_center_dashboard(days=30): "策略迭代只发布经过样本约束和灰度闸门验证的规则。", ], "opportunity": opportunity, + "strategy_evaluation": strategy_evaluation, "paper_trading": paper, "evidence": evidence, "iteration": iteration, diff --git a/app/db/strategy_insights.py b/app/db/strategy_insights.py index 8c69910..3e6596f 100644 --- a/app/db/strategy_insights.py +++ b/app/db/strategy_insights.py @@ -2,8 +2,9 @@ import json import re +from datetime import datetime, timedelta -from app.core.strategy_registry import normalize_strategy_code, strategy_label +from app.core.strategy_registry import normalize_strategy_code, registered_strategy_codes, strategy_definition, strategy_label from app.db.schema import get_conn @@ -31,6 +32,297 @@ def safe_dict_json(value): return {} +def _safe_float(value, default=0.0): + try: + if value is None or value == "": + return default + return float(value) + except Exception: + return default + + +def _safe_int(value, default=0): + try: + if value is None or value == "": + return default + return int(value) + except Exception: + return default + + +def _pct(part, total): + return round(float(part or 0) / float(total or 0) * 100, 2) if total else 0.0 + + +def evaluate_strategy_decision(metrics: dict) -> dict: + """Turn strategy metrics into an explicit lifecycle recommendation. + + This is advisory only. It does not mutate strategy configs; release/pause + should still go through the strategy iteration gate. + """ + signal_count = _safe_int(metrics.get("signal_count")) + opportunity_count = _safe_int(metrics.get("opportunity_count")) + trade_count = _safe_int(metrics.get("trade_count")) + closed_count = _safe_int(metrics.get("closed_trade_count")) + win_rate = _safe_float(metrics.get("win_rate_pct")) + avg_pnl = _safe_float(metrics.get("avg_realized_pnl_pct")) + realized = _safe_float(metrics.get("realized_pnl_usdt")) + worst = _safe_float(metrics.get("worst_pnl_pct")) + fill_rate = _safe_float(metrics.get("order_fill_rate_pct")) + trade_conversion = _safe_float(metrics.get("trade_conversion_pct")) + + score = 50.0 + score += min(signal_count, 40) * 0.25 + score += min(opportunity_count, 40) * 0.2 + score += min(trade_conversion, 40) * 0.25 + score += (win_rate - 50) * 0.35 if closed_count else 0 + score += max(-12, min(12, avg_pnl)) * 1.8 + score += 6 if realized > 0 else (-6 if realized < 0 else 0) + score += 4 if fill_rate >= 40 else 0 + score -= 8 if worst <= -8 else 0 + score = round(max(0, min(100, score)), 1) + + reasons = [] + next_actions = [] + decision = "observe" + decision_label = "继续观察" + + if signal_count < 5 and opportunity_count < 5: + decision = "collect_samples" + decision_label = "样本不足" + reasons.append("信号和机会样本不足,暂不判断优劣") + next_actions.append("继续收集样本,不要直接调高权重") + elif trade_count == 0: + decision = "review_entry_gate" + decision_label = "检查入场闸门" + reasons.append("已有发现样本,但还没有进入策略交易") + next_actions.append("检查 RR、买点距离、全局风控和挂单成交条件") + elif closed_count < 5: + decision = "gray" + decision_label = "灰度观察" + reasons.append("已有交易样本,但平仓数量不足 5 笔") + next_actions.append("保持 paper-only,等更多已平仓样本") + elif win_rate >= 55 and avg_pnl > 0 and realized > 0: + decision = "promote" + decision_label = "优先保留" + reasons.append("胜率、平均收益和已实现收益同时为正") + next_actions.append("允许维持或小幅提升策略权重,但仍需观察回撤") + elif win_rate < 35 or avg_pnl <= -2 or realized < 0 and worst <= -6: + decision = "pause" + decision_label = "暂停/降权" + reasons.append("胜率或平均收益不达标,且存在较差回撤") + next_actions.append("暂停新增真实跟单,只保留观察或降低仓位") + elif fill_rate < 15 and opportunity_count >= 10: + decision = "tune_entry" + decision_label = "优化入场" + reasons.append("机会样本不少,但挂单成交或执行转化偏低") + next_actions.append("复查挂单价格、有效期、回踩距离和成交触发") + else: + decision = "keep" + decision_label = "保留运行" + reasons.append("当前表现没有触发暂停或升级条件") + next_actions.append("继续按当前门槛运行并积累样本") + + return { + "decision": decision, + "decision_label": decision_label, + "evaluation_score": score, + "reasons": reasons, + "next_actions": next_actions, + } + + +def get_strategy_evaluation(days: int = 30) -> dict: + days = max(1, min(_safe_int(days, 30), 365)) + since = (datetime.now() - timedelta(days=days)).isoformat() + codes = registered_strategy_codes() + metrics = {} + for code in codes: + definition = strategy_definition(code) + metrics[code] = { + "strategy_code": code, + "strategy_name": definition.strategy_name, + "description": definition.description, + "mode": definition.mode, + "status": definition.status, + "signal_count": 0, + "candidate_signal_count": 0, + "observe_signal_count": 0, + "avg_signal_confidence": 0.0, + "opportunity_count": 0, + "actionable_count": 0, + "buy_now_count": 0, + "wait_pullback_count": 0, + "observe_count": 0, + "order_count": 0, + "filled_order_count": 0, + "canceled_order_count": 0, + "trade_count": 0, + "open_trade_count": 0, + "closed_trade_count": 0, + "win_count": 0, + "loss_count": 0, + "realized_pnl_usdt": 0.0, + "pnl_pct_values": [], + "best_pnl_pct": None, + "worst_pnl_pct": None, + } + + def bucket(code): + normalized = normalize_strategy_code(code) + if normalized not in metrics: + definition = strategy_definition(normalized) + metrics[normalized] = { + "strategy_code": normalized, + "strategy_name": definition.strategy_name, + "description": definition.description, + "mode": definition.mode, + "status": definition.status, + "signal_count": 0, + "candidate_signal_count": 0, + "observe_signal_count": 0, + "avg_signal_confidence": 0.0, + "opportunity_count": 0, + "actionable_count": 0, + "buy_now_count": 0, + "wait_pullback_count": 0, + "observe_count": 0, + "order_count": 0, + "filled_order_count": 0, + "canceled_order_count": 0, + "trade_count": 0, + "open_trade_count": 0, + "closed_trade_count": 0, + "win_count": 0, + "loss_count": 0, + "realized_pnl_usdt": 0.0, + "pnl_pct_values": [], + "best_pnl_pct": None, + "worst_pnl_pct": None, + } + return metrics[normalized] + + conn = get_conn() + try: + for row in conn.execute( + """ + SELECT strategy_code, + COUNT(*) AS signal_count, + SUM(CASE WHEN signal_status='candidate' THEN 1 ELSE 0 END) AS candidate_count, + SUM(CASE WHEN signal_status='observe' THEN 1 ELSE 0 END) AS observe_count, + AVG(confidence) AS avg_confidence + FROM strategy_signals + WHERE created_at >= %s + GROUP BY strategy_code + """, + (since,), + ).fetchall(): + b = bucket(row.get("strategy_code")) + b["signal_count"] = _safe_int(row.get("signal_count")) + b["candidate_signal_count"] = _safe_int(row.get("candidate_count")) + b["observe_signal_count"] = _safe_int(row.get("observe_count")) + b["avg_signal_confidence"] = round(_safe_float(row.get("avg_confidence")), 2) + + for row in conn.execute( + """ + SELECT strategy_code, + COUNT(*) AS opportunity_count, + SUM(CASE WHEN execution_status IN ('buy_now','wait_pullback') THEN 1 ELSE 0 END) AS actionable_count, + SUM(CASE WHEN execution_status='buy_now' THEN 1 ELSE 0 END) AS buy_now_count, + SUM(CASE WHEN execution_status='wait_pullback' THEN 1 ELSE 0 END) AS wait_pullback_count, + SUM(CASE WHEN execution_status='observe' THEN 1 ELSE 0 END) AS observe_count + FROM recommendation + WHERE rec_time >= %s + GROUP BY strategy_code + """, + (since,), + ).fetchall(): + b = bucket(row.get("strategy_code")) + for key in ("opportunity_count", "actionable_count", "buy_now_count", "wait_pullback_count", "observe_count"): + b[key] = _safe_int(row.get(key)) + + for row in conn.execute( + """ + SELECT strategy_code, + COUNT(*) AS order_count, + SUM(CASE WHEN status='filled' THEN 1 ELSE 0 END) AS filled_count, + SUM(CASE WHEN status IN ('canceled','rejected','expired') THEN 1 ELSE 0 END) AS canceled_count + FROM paper_orders + WHERE created_at >= %s + GROUP BY strategy_code + """, + (since,), + ).fetchall(): + b = bucket(row.get("strategy_code")) + b["order_count"] = _safe_int(row.get("order_count")) + b["filled_order_count"] = _safe_int(row.get("filled_count")) + b["canceled_order_count"] = _safe_int(row.get("canceled_count")) + + for row in conn.execute( + """ + SELECT strategy_code, status, realized_pnl_pct, realized_pnl_usdt, pnl_pct + FROM paper_trades + WHERE opened_at >= %s + """, + (since,), + ).fetchall(): + b = bucket(row.get("strategy_code")) + status = row.get("status") or "" + b["trade_count"] += 1 + if status == "open": + b["open_trade_count"] += 1 + if status == "closed": + b["closed_trade_count"] += 1 + pnl_pct = _safe_float(row.get("realized_pnl_pct")) + pnl_usdt = _safe_float(row.get("realized_pnl_usdt")) + b["realized_pnl_usdt"] += pnl_usdt + b["pnl_pct_values"].append(pnl_pct) + if pnl_pct > 0: + b["win_count"] += 1 + elif pnl_pct < 0: + b["loss_count"] += 1 + b["best_pnl_pct"] = pnl_pct if b["best_pnl_pct"] is None else max(b["best_pnl_pct"], pnl_pct) + b["worst_pnl_pct"] = pnl_pct if b["worst_pnl_pct"] is None else min(b["worst_pnl_pct"], pnl_pct) + finally: + conn.close() + + strategies = [] + for item in metrics.values(): + values = item.pop("pnl_pct_values", []) + item["realized_pnl_usdt"] = round(item["realized_pnl_usdt"], 4) + item["avg_realized_pnl_pct"] = round(sum(values) / len(values), 4) if values else 0.0 + item["win_rate_pct"] = _pct(item["win_count"], item["closed_trade_count"]) + item["actionable_rate_pct"] = _pct(item["actionable_count"], item["opportunity_count"]) + item["trade_conversion_pct"] = _pct(item["trade_count"], item["opportunity_count"]) + item["order_fill_rate_pct"] = _pct(item["filled_order_count"], item["order_count"]) + item["candidate_signal_rate_pct"] = _pct(item["candidate_signal_count"], item["signal_count"]) + item["best_pnl_pct"] = item["best_pnl_pct"] if item["best_pnl_pct"] is not None else 0.0 + item["worst_pnl_pct"] = item["worst_pnl_pct"] if item["worst_pnl_pct"] is not None else 0.0 + item.update(evaluate_strategy_decision(item)) + strategies.append(item) + + strategies.sort(key=lambda x: (x["evaluation_score"], x["realized_pnl_usdt"], x["closed_trade_count"], x["signal_count"]), reverse=True) + decisions = {} + for item in strategies: + decisions[item["decision"]] = decisions.get(item["decision"], 0) + 1 + return { + "definition": "策略评价按 strategy_code 独立统计发现、执行、成交、收益和回撤,并给出保留/灰度/暂停等建议;建议不直接改配置,仍需经过策略迭代闸门。", + "days": days, + "generated_at": datetime.now().isoformat(timespec="seconds"), + "summary": { + "strategy_count": len(strategies), + "active_signal_strategy_count": sum(1 for x in strategies if x["signal_count"] > 0), + "traded_strategy_count": sum(1 for x in strategies if x["trade_count"] > 0), + "promote_count": decisions.get("promote", 0), + "pause_count": decisions.get("pause", 0), + "gray_count": decisions.get("gray", 0), + "collect_samples_count": decisions.get("collect_samples", 0), + }, + "decision_distribution": [{"name": k, "count": v} for k, v in sorted(decisions.items(), key=lambda x: (-x[1], x[0]))], + "strategies": strategies, + } + + def get_strategy_insights(): """Strategy attribution based on opportunity and paper-trading conversion. diff --git a/app/services/altcoin_confirm.py b/app/services/altcoin_confirm.py index 334d449..6859b6f 100644 --- a/app/services/altcoin_confirm.py +++ b/app/services/altcoin_confirm.py @@ -54,6 +54,11 @@ 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.altcoin_breakout import ( + build_compression_breakout_4h_signal, + build_intraday_momentum_15m_signal, + build_volume_ignition_1h_signal, +) from app.strategies.box_retest_4h import build_box_retest_1h_signal, build_box_retest_4h_signal from app.config.config_loader import _get_section as _get_cfg_section from app.core.pa_engine import ( @@ -85,10 +90,13 @@ def _strategy_context_for_recommendation(symbol: str, result: dict, entry_plan: """Build and persist a standard strategy signal when an independent strategy matches.""" bp_1h = result.get("box_breakout_pullback_1h") or (result.get("market_context") or {}).get("box_breakout_pullback_1h") or {} bp_4h = result.get("box_breakout_pullback_4h") or (result.get("market_context") or {}).get("box_breakout_pullback_4h") or {} - if not bp_1h.get("detected") and not bp_4h.get("detected"): - return {} market_regime = result.get("market_regime") or (result.get("market_context") or {}).get("market_regime") or {} signal_candidates = [] + signal_candidates.extend([ + build_volume_ignition_1h_signal(symbol=symbol, result=result, entry_plan=entry_plan or {}), + build_compression_breakout_4h_signal(symbol=symbol, result=result, entry_plan=entry_plan or {}), + build_intraday_momentum_15m_signal(symbol=symbol, result=result, entry_plan=entry_plan or {}), + ]) if bp_1h.get("detected"): signal_candidates.append( build_box_retest_1h_signal( @@ -2074,8 +2082,8 @@ def main(compact: bool = False, verbose: bool = False, limit: int | None = None, detail={**cand_detail, **result}, ) result["state_update"] = state_result - # 飞书只是通知层:确认阶段不再绕过 recommendation 主链路直接推送。 - # 先完成 create_recommendation + DB 主状态派生,再用同一条主链路结果决定是否通知。 + # 飞书只是通知层:确认阶段不再绕过 recommendation 状态机直接推送。 + # 先完成 create_recommendation + DB 状态派生,再用同一条状态结果决定是否通知。 # 🟢 只做做多!方向永远多头 rec_direction = get_strategy_direction() diff --git a/app/services/chat_assistant.py b/app/services/chat_assistant.py index 5897ebe..c819b3b 100644 --- a/app/services/chat_assistant.py +++ b/app/services/chat_assistant.py @@ -504,7 +504,7 @@ def _fallback_answer(intent: str, message: str, context: dict) -> dict: evidence.append(f"技术面:{tech_summary.get('headline', '')}") if recommendations: r = recommendations[0] - evidence.append(f"主链路:{r.get('execution_label') or r.get('action_status') or r.get('execution_status')},原因:{r.get('execution_reason') or r.get('state_reason') or '--'}") + evidence.append(f"策略状态:{r.get('execution_label') or r.get('action_status') or r.get('execution_status')},原因:{r.get('execution_reason') or r.get('state_reason') or '--'}") if sentiment: evidence.append(f"舆情:近 72h 有 {len(sentiment)} 条相关事件,最新为「{sentiment[0].get('title', '')[:60]}」。") if isinstance(onchain, dict) and (onchain.get("events") or onchain.get("metrics")): diff --git a/app/services/price_tracker.py b/app/services/price_tracker.py index f7fbef1..6fcccc0 100644 --- a/app/services/price_tracker.py +++ b/app/services/price_tracker.py @@ -321,8 +321,8 @@ def track_prices(): # PA增强:动态跟踪信号分析 tracking_signals = analyze_tracking_signals(symbol, rec, current_price) - # 主链路状态迁移:tracker 只提交“候选状态 + 当前价”,最终状态由 DB 主链路统一落库。 - # 飞书推送只能消费主链路返回的最终状态,不能再自行判断。 + # 统一状态迁移:tracker 只提交“候选状态 + 当前价”,最终状态由 DB 状态机统一落库。 + # 飞书推送只能消费统一状态机返回的最终状态,不能再自行判断。 terminal_action = { "hit_tp2": "止盈2", "stopped_out": "止损", diff --git a/app/services/review_engine.py b/app/services/review_engine.py index 70d779f..7daf867 100644 --- a/app/services/review_engine.py +++ b/app/services/review_engine.py @@ -908,7 +908,7 @@ def _extract_rules_from_review(): "score_adjust": 2 if len(combo) >= 2 else 1, "source": "review_pattern", } - # 新体系:先进入候选规则池,不直接污染主策略。达到发布门槛后再升级为 active。 + # 新体系:先进入候选规则池,不直接污染已发布策略。达到发布门槛后再升级为 active。 rule["candidate_id"] = upsert_strategy_rule_candidate( source="review_pattern", rule_type=rule.get("type", "bonus"), @@ -1215,7 +1215,7 @@ def _build_dual_attribution(results, effect_summary): confidence_score=round(min(90, 45 + cnt * 10), 1), sample_size=cnt, status="candidate", - notes="失败归因生成:先入候选池,不立即改主策略", + notes="失败归因生成:先入候选池,不立即改已发布策略", source_ref=f"failure:{ftype}", ) candidate_rules.append({"id": cid, "type": "penalty", "signal": ftype, "description": desc, "confidence_score": round(min(90, 45 + cnt * 10), 1), "sample_size": cnt, "status": "candidate"}) diff --git a/app/strategies/altcoin_breakout.py b/app/strategies/altcoin_breakout.py new file mode 100644 index 0000000..be7c62f --- /dev/null +++ b/app/strategies/altcoin_breakout.py @@ -0,0 +1,176 @@ +"""Altcoin breakout strategy profiles. + +These builders turn existing evidence into complete strategy signals. A factor +can support a strategy, but the strategy still owns trigger freshness, entry +quality and risk semantics. +""" + +from __future__ import annotations + +from app.core.factor_roles import CONFIRMATION, ENTRY, PREREQUISITE, RISK, TRIGGER +from app.core.strategy_contract import StrategySignal, current_strategy_version +from app.core.strategy_registry import ( + COMPRESSION_BREAKOUT_4H_STRATEGY, + INTRADAY_MOMENTUM_15M_STRATEGY, + VOLUME_IGNITION_1H_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 _signals_text(result: dict) -> str: + return " ".join(str(x or "") for x in (result or {}).get("signals") or []) + + +def _trigger_context(result: dict) -> dict: + return (result or {}).get("trigger_context") or ((result or {}).get("market_context") or {}).get("trigger_context") or {} + + +def _has_current_trigger(result: dict) -> bool: + trigger = _trigger_context(result) + text = _signals_text(result) + return bool(trigger.get("current_triggers")) or "15min即刻入场" in text or "15min强突破" in text or "15min 强突破" in text + + +def _status_for_entry(result: dict, entry_plan: dict | None = None, *, require_current_trigger: bool = False, allow_wait: bool = True) -> tuple[str, list[str]]: + reasons = [] + entry_action = str((entry_plan or {}).get("entry_action") or ((result or {}).get("entry_plan") or {}).get("entry_action") or (result or {}).get("entry_action") or "").strip() + if require_current_trigger and not _has_current_trigger(result): + return "observe", ["缺少当前低周期触发"] + if entry_action in ("可即刻买入", "即刻买入"): + return "candidate", reasons + if entry_action == "等回踩" and allow_wait: + return "candidate", reasons + if entry_action: + reasons.append(f"当前入场动作 {entry_action} 不满足策略执行条件") + return "observe", reasons + + +def build_volume_ignition_1h_signal(*, symbol: str, result: dict, entry_plan: dict | None = None) -> StrategySignal | None: + text = _signals_text(result) + has_vp = "量价齐飞" in text or ("连续" in text and "放量" in text) + has_breakout = "1H" in text and ("突破" in text or "起爆" in text) + if not (has_vp or has_breakout): + return None + status, reasons = _status_for_entry(result, entry_plan, require_current_trigger=False, allow_wait=True) + score = _safe_float(result.get("score")) + confidence = min(100.0, max(0.0, score * 7 + (12 if _has_current_trigger(result) else 0))) + trigger = { + "factor_code": "vp_fly_1h_current" if has_vp else "ignition_1h_current", + "factor_label": "1H放量突破启动", + "has_current_trigger": _has_current_trigger(result), + "trigger_status": _trigger_context(result).get("trigger_status") or "", + "entry_action": (entry_plan or {}).get("entry_action") or "", + } + return StrategySignal( + strategy_code=VOLUME_IGNITION_1H_STRATEGY, + strategy_version=current_strategy_version(), + symbol=symbol, + direction="long", + status=status, + confidence=confidence, + score=score, + trigger=trigger, + factor_roles={ + "vp_fly_1h_current": TRIGGER, + "volume_consecutive_1h": CONFIRMATION, + "breakout_15m_current": ENTRY, + "pullback_15m_confirm": ENTRY, + "false_breakout": RISK, + "risk_reward_bad": RISK, + }, + entry_plan=entry_plan or {}, + risk_plan={ + "invalid_if": ["放量后不能延续", "15m假突破", "跌回启动K低点", "RR不足"], + "risk_reasons": reasons, + }, + decision_log={"module": VOLUME_IGNITION_1H_STRATEGY, "decision": status, "reasons": reasons}, + ) + + +def build_compression_breakout_4h_signal(*, symbol: str, result: dict, entry_plan: dict | None = None) -> StrategySignal | None: + text = _signals_text(result) + has_compression = any(key in text for key in ("静K", "压缩", "布林收窄", "底部抬高")) + has_breakout_context = any(key in text for key in ("突破", "起爆", "量价齐飞", "回踩")) + if not (has_compression and has_breakout_context): + return None + status, reasons = _status_for_entry(result, entry_plan, require_current_trigger=False, allow_wait=True) + score = _safe_float(result.get("score")) + confidence = min(100.0, max(0.0, score * 6 + (10 if "底部" in text else 0) + (8 if _has_current_trigger(result) else 0))) + return StrategySignal( + strategy_code=COMPRESSION_BREAKOUT_4H_STRATEGY, + strategy_version=current_strategy_version(), + symbol=symbol, + direction="long", + status=status, + confidence=confidence, + score=score, + trigger={ + "factor_code": "compression_surge_4h", + "factor_label": "4H压缩蓄力突破", + "has_current_trigger": _has_current_trigger(result), + "trigger_status": _trigger_context(result).get("trigger_status") or "", + }, + factor_roles={ + "static_accum_4h": PREREQUISITE, + "higher_lows_4h": PREREQUISITE, + "compression_surge_4h": TRIGGER, + "ignition_4h_current": CONFIRMATION, + "vp_fly_1h_current": CONFIRMATION, + "pullback_15m_confirm": ENTRY, + "false_breakout": RISK, + }, + entry_plan=entry_plan or {}, + risk_plan={ + "invalid_if": ["突破后跌回压缩区间", "回踩放量跌破", "无量反抽失败", "市场风险升高"], + "risk_reasons": reasons, + }, + decision_log={"module": COMPRESSION_BREAKOUT_4H_STRATEGY, "decision": status, "reasons": reasons}, + ) + + +def build_intraday_momentum_15m_signal(*, symbol: str, result: dict, entry_plan: dict | None = None) -> StrategySignal | None: + text = _signals_text(result) + has_short_tf = any(key in text for key in ("15min", "15m", "短周期", "5m/15m")) + if not has_short_tf: + return None + status, reasons = _status_for_entry(result, entry_plan, require_current_trigger=True, allow_wait=False) + score = _safe_float(result.get("score")) + confidence = min(100.0, max(0.0, score * 6 + 18)) + return StrategySignal( + strategy_code=INTRADAY_MOMENTUM_15M_STRATEGY, + strategy_version=current_strategy_version(), + symbol=symbol, + direction="long", + status=status, + confidence=confidence, + score=score, + trigger={ + "factor_code": "short_tf_15m_ignition", + "factor_label": "15m日内动量延续", + "has_current_trigger": _has_current_trigger(result), + "trigger_status": _trigger_context(result).get("trigger_status") or "", + }, + factor_roles={ + "short_tf_5m_ignition": PREREQUISITE, + "short_tf_15m_ignition": TRIGGER, + "short_tf_resonance": CONFIRMATION, + "vp_fly_1h_current": CONFIRMATION, + "breakout_15m_current": ENTRY, + "false_breakout": RISK, + "trend_exhaustion": RISK, + }, + entry_plan=entry_plan or {}, + risk_plan={ + "invalid_if": ["15m跌回突破K", "短周期量能衰减", "快速冲高回落", "RR不足"], + "risk_reasons": reasons, + }, + decision_log={"module": INTRADAY_MOMENTUM_15M_STRATEGY, "decision": status, "reasons": reasons}, + ) diff --git a/app/web/routes_paper_trading.py b/app/web/routes_paper_trading.py index b6ed657..d311466 100644 --- a/app/web/routes_paper_trading.py +++ b/app/web/routes_paper_trading.py @@ -11,6 +11,7 @@ from app.db.paper_trading import ( reset_paper_trading_data, send_paper_trading_report, ) +from app.db.strategy_insights import get_strategy_evaluation from app.web.shared import require_admin @@ -29,6 +30,12 @@ async def api_paper_trading_performance(days: int = 30, altcoin_session: str = C return get_paper_trading_performance(days=days) +@router.get("/api/paper-trading/strategies") +async def api_paper_trading_strategies(days: int = 30, altcoin_session: str = Cookie(default="")): + require_admin(altcoin_session) + return get_strategy_evaluation(days=days) + + @router.get("/api/paper-trading/trades") async def api_paper_trading_trades( limit: int = 50, @@ -59,10 +66,11 @@ async def api_paper_trading_events( offset: int = 0, symbol: str = "", event_type: str = "", + strategy_code: str = "", altcoin_session: str = Cookie(default=""), ): require_admin(altcoin_session) - return list_paper_trade_events(limit=limit, offset=offset, symbol=symbol, event_type=event_type) + return list_paper_trade_events(limit=limit, offset=offset, symbol=symbol, event_type=event_type, strategy_code=strategy_code) @router.post("/api/paper-trading/report") diff --git a/app/web/routes_strategy.py b/app/web/routes_strategy.py index 35cc881..9e24414 100644 --- a/app/web/routes_strategy.py +++ b/app/web/routes_strategy.py @@ -12,6 +12,7 @@ from app.db.review_queries import ( get_strategy_rule_candidates, refresh_strategy_candidate_performance, ) +from app.db.strategy_insights import get_strategy_evaluation from app.services.llm_insights import get_latest_review_memo from app.db.schema import get_conn from app.db.altcoin_db import _derive_execution_fields @@ -72,6 +73,12 @@ async def api_strategy_insights(altcoin_session: str = Cookie(default="")): return get_strategy_insights() +@router.get("/api/strategy/evaluation") +async def api_strategy_evaluation(days: int = 30, altcoin_session: str = Cookie(default="")): + require_api_user_with_subscription(altcoin_session) + return get_strategy_evaluation(days=days) + + @router.get("/api/strategy/lifecycle") async def api_strategy_lifecycle(days: int = 30, altcoin_session: str = Cookie(default="")): require_api_user_with_subscription(altcoin_session) diff --git a/docs/MULTI_STRATEGY_ARCHITECTURE.md b/docs/MULTI_STRATEGY_ARCHITECTURE.md index 4564cee..c17fad9 100644 --- a/docs/MULTI_STRATEGY_ARCHITECTURE.md +++ b/docs/MULTI_STRATEGY_ARCHITECTURE.md @@ -6,7 +6,7 @@ ## 1. 为什么要改 -当前系统已经有 `strategy_version`、`FactorScorer`、`factor_score_breakdown` 和 paper trading 归因,但主链路仍更像一个“大而全评分器”: +当前系统已经有 `strategy_version`、`FactorScorer`、`factor_score_breakdown` 和 paper trading 归因,但综合确认策略仍更像一个“大而全评分器”: - 很多因子在同一个确认函数里叠加,容易把单根行情重复加分。 - 推荐和 paper trading 更容易知道“综合分高不高”,但不容易知道“到底是哪套策略赚了钱”。 @@ -23,7 +23,7 @@ - 策略输出必须走统一契约,不能每个策略自定义一套不可比的 JSON。 - 推荐、挂单、持仓、事件日志、复盘都必须保留策略血缘。 - 因子角色必须显式声明,未知因子不能默认成为交易触发。 -- 旧主链路可以兼容为 `main_composite_v1`,但新数据不允许没有策略来源。 +- 历史综合确认策略可以兼容为 `main_composite_v1`,但它与其他策略平等运行;新数据不允许没有策略来源。 - 策略中文名、描述、启用状态要集中维护,不能散落在页面和业务代码里。 - 多策略架构上线后,收益评价必须按策略拆分;总收益只能作为账户层结果,不能替代策略评价。 @@ -179,12 +179,12 @@ - 建立 `app/core/strategy_contract.py`,定义策略输出结构。 - 建立 `app/core/factor_roles.py`,统一因子角色分类。 - 给 `recommendation` / `paper_trades` / `paper_orders` 补 `strategy_code` 和 `strategy_signal_id`。 -- 在确认层先把现有主链路标为 `main_composite_v1`。 +- 在确认层先把现有综合确认策略标为 `main_composite_v1`。 - 把 `box_breakout_pullback_4h` 标记为 `box_retest_4h_v1` 的核心触发候选,但仍通过完整策略条件判断。 ### P1:拆出第一个独立策略 -目标:让 `box_retest_4h_v1` 独立运行,与原主链路并行。 +目标:让 `box_retest_4h_v1` 独立运行,与其他策略平等并行。 - 新增 `app/strategies/box_retest_4h.py`。 - 让它消费统一交易宇宙和 4H K线。 diff --git a/docs/MULTI_STRATEGY_IMPLEMENTATION_PLAN.md b/docs/MULTI_STRATEGY_IMPLEMENTATION_PLAN.md index c22fa32..64cc1d4 100644 --- a/docs/MULTI_STRATEGY_IMPLEMENTATION_PLAN.md +++ b/docs/MULTI_STRATEGY_IMPLEMENTATION_PLAN.md @@ -5,7 +5,7 @@ 今晚目标不是一次性完成所有长期架构,而是交付一个能真实运行的第一阶段: - 每条推荐、挂单、持仓都能追溯到策略。 -- 现有主链路不被打断,统一标记为 `main_composite_v1`。 +- 现有综合确认策略不被打断,统一标记为 `main_composite_v1`。 - `box_retest_4h_v1` 进入独立策略雏形,不再只是综合确认层里的一个因子。 - paper trading 和复盘能按策略看表现。 - 前端至少能看到策略来源,明早可以观察跑出来的数据。 @@ -27,7 +27,7 @@ 允许的兼容: -- 旧主链路继续运行,但必须标记为 `main_composite_v1`。 +- 旧综合确认策略继续运行,但必须标记为 `main_composite_v1`,并与其他策略平等。 - 第一版 `box_retest_4h_v1` 可以先 paper-only / observe-only。 - 复杂仲裁可以先做最小规则,但接口要为后续多策略扩展留好。 @@ -219,7 +219,7 @@ risk_reward_bad -> risk entry_quality_gate -> risk ``` -注意:同一因子在不同策略里可以有不同角色,但默认映射用于主链路兼容。 +注意:同一因子在不同策略里可以有不同角色;默认映射只用于兼容综合确认策略。 该模块必须保持纯函数,不依赖 DB,不读运行时配置,避免策略基础设施反向依赖业务层。 @@ -252,7 +252,7 @@ decision_log: dict - 提供 `to_json_dict()`,统一序列化。 - 提供 `from_confirm_result()`,兼容现有确认链路。 -- 提供 `default_main_composite_signal()`,给旧主链路生成标准策略快照。 +- 提供 `default_main_composite_signal()`,给旧综合确认策略生成标准策略快照。 - 不在这里调用数据库,不在这里拉行情。 ### 3.2.1 新增 `app/db/strategy_signal_queries.py` @@ -337,7 +337,7 @@ app/core/strategy_registry.py 初始策略: ```text -main_composite_v1 -> 综合确认主链路 +main_composite_v1 -> 综合确认策略 box_retest_4h_v1 -> 4H箱体突破回踩 ``` @@ -403,15 +403,15 @@ app/strategies/orchestrator.py - `StrategySignal(strategy_code='box_retest_4h_v1')` -### 4.4 与现有主链路关系 +### 4.4 与现有综合确认策略关系 -第一阶段不能直接删除现有主链路里的 `box_breakout_pullback_4h` 加分,否则会改变当前推荐行为过大。 +第一阶段不能直接删除现有综合确认策略里的 `box_breakout_pullback_4h` 加分,否则会改变当前推荐行为过大。 处理方式: -- 主链路继续把它作为结构因子参与确认,策略来源仍为 `main_composite_v1`。 +- 综合确认策略继续把它作为结构因子参与确认,策略来源仍为 `main_composite_v1`。 - 独立策略并行生成 `box_retest_4h_v1` 信号。 -- 如果同一 symbol 同时被主链路和箱体策略命中,短期由仲裁器保留更明确的策略快照,或在推荐 `strategy_snapshot_json` 中记录 secondary strategies。 +- 如果同一 symbol 同时被综合确认策略和箱体策略命中,短期由仲裁器保留更明确的策略快照,或在推荐 `strategy_snapshot_json` 中记录 secondary strategies。 - 明早先观察是否出现重复推荐,再决定是否让箱体策略接管该类信号。 ## 5. API 与 UI 改造 @@ -552,11 +552,11 @@ docker compose exec alphax-web python -m app.cli paper-trader - 不做复杂策略投票。 - 不做多空完整重构。 - 不做大规模 UI 重设计。 -- 不删除现有主链路。 +- 不删除现有综合确认策略。 今晚可以做: -- 保留主链路运行。 +- 保留综合确认策略运行。 - 补策略血缘。 - 补第一版策略标准结构。 - 让 `box_retest_4h_v1` 作为独立候选跑起来。 @@ -568,7 +568,7 @@ docker compose exec alphax-web python -m app.cli paper-trader 1. 新增 migration 和回填。 2. 新增 `strategy_registry`、`factor_roles`、`strategy_contract` 纯核心模块。 -3. 新增 `strategy_signal_queries`,不要碰业务主链路。 +3. 新增 `strategy_signal_queries`,不要碰业务状态机。 4. 修改 `create_recommendation()` 写入策略血缘。 5. 修改 paper orders / paper trades 继承策略血缘。 6. 修改读模型/API,让前端能看到策略来源。 diff --git a/docs/OPTIMIZATION_TODO.md b/docs/OPTIMIZATION_TODO.md index bc61e93..f53551d 100644 --- a/docs/OPTIMIZATION_TODO.md +++ b/docs/OPTIMIZATION_TODO.md @@ -47,7 +47,7 @@ - 建立 `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`,避免无策略来源的推荐继续进入账本。 +- 现有综合确认策略先标记为 `main_composite_v1`,它与其他策略平等,避免无策略来源的推荐继续进入账本。 - 先把 `box_retest_4h_v1` 作为第一个独立策略候选拆出来:`box_breakout_pullback_4h` 只能是核心触发因子,仍要经过市场环境、交易宇宙、确认、入场、风控和失效条件。 - 复盘中心增加按 `strategy_code` 聚合的胜率、收益、最大回撤、盈亏比和持仓时长。 - 新数据不得出现空 `strategy_code`;旧数据通过 migration 回填为 `main_composite_v1`,但不能继续产生无来源样本。 @@ -163,7 +163,7 @@ ## 下一步执行建议 1. 先完成多策略第一阶段的策略血缘字段和标准策略输出结构。 -2. 把现有主链路标记为 `main_composite_v1`,确保 recommendation 和 paper trading 不再丢失策略来源。 +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 门槛。 diff --git a/docs/PROJECT_STRUCTURE.md b/docs/PROJECT_STRUCTURE.md index a1b773e..ee0ab87 100644 --- a/docs/PROJECT_STRUCTURE.md +++ b/docs/PROJECT_STRUCTURE.md @@ -25,7 +25,7 @@ - `app/analysis/` - `app/web/` -### 2. 运行主链路 +### 2. 运行服务链路 - `app/services/altcoin_screener.py` - `app/services/altcoin_confirm.py` diff --git a/static/app.html b/static/app.html index 7c2a37c..e78ea55 100644 --- a/static/app.html +++ b/static/app.html @@ -890,7 +890,7 @@ function renderRecCard(r) { return ''+displaySignalText(s)+''; }).join(''); var score = r.rec_score||0, st = scoreTier(score), ver = r.strategy_version||''; - var strategyLabel = r.strategy_name || ({main_composite_v1:'综合确认主链路',box_retest_1h_v1:'1H箱体突破回踩',box_retest_4h_v1:'4H箱体突破回踩'}[r.strategy_code||''] || r.strategy_code || ''); + var strategyLabel = r.strategy_name || ({main_composite_v1:'综合确认策略',box_retest_1h_v1:'1H箱体突破回踩',box_retest_4h_v1:'4H箱体突破回踩',volume_ignition_1h_v1:'1H放量突破启动',compression_breakout_4h_v1:'4H压缩蓄力突破',intraday_momentum_15m_v1:'15m日内动量延续'}[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); diff --git a/static/onchain.html b/static/onchain.html index c8b12b8..e0976fb 100644 --- a/static/onchain.html +++ b/static/onchain.html @@ -53,7 +53,7 @@
单币档案
选择币种
-
从映射信号流或资产列表中选择一个币,查看链上事件与主链路关系。
+
从映射信号流或资产列表中选择一个币,查看链上事件与策略信号关系。
@@ -63,7 +63,7 @@
-
币种重要性风险分映射事件最近事件数据源主链路
加载中...
+
币种重要性风险分映射事件最近事件数据源策略状态
加载中...
--
@@ -94,7 +94,7 @@ async function reloadRawEvents(offset){state.rawOffset=offset||0;$('rawFeed').in async function reloadTokens(offset){state.offset=offset||0;$('tokenTable').innerHTML='加载中...';try{var qs='hours='+$('hoursSel').value+'&limit='+state.limit+'&offset='+state.offset+'&chain='+encodeURIComponent($('chainSel').value)+'&signal='+encodeURIComponent($('signalSel').value);var d=await (await fetch(API+'/api/onchain/tokens?'+qs)).json();state.total=d.total||0;var items=d.items||[];if(!items.length){$('tokenTable').innerHTML='暂无链上异动 token'}else{$('tokenTable').innerHTML=items.map(function(t){return ''+esc(t.symbol)+''+esc(t.chain)+''+Number(t.onchain_score||0).toFixed(0)+''+Number(t.risk_score||0).toFixed(0)+''+Number(t.event_count||t.mapped_event_count||0).toFixed(0)+''+esc(t.latest_event_at?fmtTime(t.latest_event_at):'--')+''+esc(t.source||'nodereal')+''+recLabel(t.recommendation)+''}).join('');if(!state.selected&&items[0])loadDetail(items[0].symbol)}updatePager()}catch(e){$('tokenTable').innerHTML='加载失败'}} function updatePager(){var page=Math.floor(state.offset/state.limit)+1,totalPages=Math.max(1,Math.ceil((state.total||0)/state.limit));$('pageInfo').textContent='第 '+page+' / '+totalPages+' 页,共 '+state.total+' 个';$('prevBtn').disabled=state.offset<=0;$('nextBtn').disabled=state.offset+state.limit>=state.total} function page(step){var next=state.offset+step*state.limit;if(next<0||next>=state.total)return;reloadTokens(next)} -async function loadDetail(symbol){state.selected=symbol;$('detailNote').textContent=symbol;$('detailBody').innerHTML='
加载详情...
';try{var d=await (await fetch(API+'/api/onchain/tokens/'+encodeURIComponent(symbol)+'?hours=168')).json();var latest=(d.metrics||[])[0]||{};var rec=d.recommendation;var rawCount=Number(d.raw_event_count||latest.event_count||latest.mapped_event_count||0);var metrics='
'+[['重要性',Number(latest.onchain_score||0).toFixed(0)],['风险分',Number(latest.risk_score||0).toFixed(0)],['映射事件',rawCount.toFixed(0)],['数据源',latest.source||'nodereal']].map(function(x){return '
'+x[0]+''+x[1]+'
'}).join('')+'
';var recHtml=rec?'
主链路状态:'+esc(rec.action_status||rec.execution_status||'观察')+' · 推荐 #'+esc(rec.id)+'
':'
尚未形成主链路推荐;若链上信号质量足够,会先进入技术检查。
';var standardEvents=(d.events||[]).slice(0,10).map(function(e){var cls=e.direction==='risk'?'risk':e.direction==='positive'?'pos':'blue';return '
'+esc(e.signal_label||e.signal_code)+'
'+esc(e.severity||e.direction)+'
'+fmtTime(e.detected_at)+' · '+esc(e.chain)+' · '+fmtUsd(e.value_usd)+'
'+esc(e.wallet_label||e.counterparty_label||e.source||'链上事件')+'
'});var rawEvents=(d.raw_events||[]).slice(0,20).map(function(e){var amount=e.display_amount_label||fmtAmount(e.display_amount||e.total_amount||e.amount);var link=e.url?' · 查看来源':'';var route=(e.from_short&&e.to_short)?'
路径:'+esc(e.from_short)+' → '+esc(e.to_short):'';return '
'+esc(e.human_summary||e.event_label||'NodeReal 原始事件')+'
已映射
'+fmtTime(e.detected_at)+' · '+esc(e.chain)+' · '+esc(amount)+link+route+'
'+esc(e.pipeline_note||'已映射,可进入后续链上信号分析。')+'
'});var events=standardEvents.concat(rawEvents).join('')||'
暂无事件明细
';$('detailBody').innerHTML='
'+esc(d.symbol)+'
'+(d.mappings||[]).length+' 个合约映射 · 近 7 天
'+metrics+recHtml+'
'+events+'
'}catch(e){$('detailBody').innerHTML='
详情加载失败
'}} +async function loadDetail(symbol){state.selected=symbol;$('detailNote').textContent=symbol;$('detailBody').innerHTML='
加载详情...
';try{var d=await (await fetch(API+'/api/onchain/tokens/'+encodeURIComponent(symbol)+'?hours=168')).json();var latest=(d.metrics||[])[0]||{};var rec=d.recommendation;var rawCount=Number(d.raw_event_count||latest.event_count||latest.mapped_event_count||0);var metrics='
'+[['重要性',Number(latest.onchain_score||0).toFixed(0)],['风险分',Number(latest.risk_score||0).toFixed(0)],['映射事件',rawCount.toFixed(0)],['数据源',latest.source||'nodereal']].map(function(x){return '
'+x[0]+''+x[1]+'
'}).join('')+'
';var recHtml=rec?'
策略状态:'+esc(rec.action_status||rec.execution_status||'观察')+' · 推荐 #'+esc(rec.id)+'
':'
尚未形成策略推荐;若链上信号质量足够,会先进入技术检查。
';var standardEvents=(d.events||[]).slice(0,10).map(function(e){var cls=e.direction==='risk'?'risk':e.direction==='positive'?'pos':'blue';return '
'+esc(e.signal_label||e.signal_code)+'
'+esc(e.severity||e.direction)+'
'+fmtTime(e.detected_at)+' · '+esc(e.chain)+' · '+fmtUsd(e.value_usd)+'
'+esc(e.wallet_label||e.counterparty_label||e.source||'链上事件')+'
'});var rawEvents=(d.raw_events||[]).slice(0,20).map(function(e){var amount=e.display_amount_label||fmtAmount(e.display_amount||e.total_amount||e.amount);var link=e.url?' · 查看来源':'';var route=(e.from_short&&e.to_short)?'
路径:'+esc(e.from_short)+' → '+esc(e.to_short):'';return '
'+esc(e.human_summary||e.event_label||'NodeReal 原始事件')+'
已映射
'+fmtTime(e.detected_at)+' · '+esc(e.chain)+' · '+esc(amount)+link+route+'
'+esc(e.pipeline_note||'已映射,可进入后续链上信号分析。')+'
'});var events=standardEvents.concat(rawEvents).join('')||'
暂无事件明细
';$('detailBody').innerHTML='
'+esc(d.symbol)+'
'+(d.mappings||[]).length+' 个合约映射 · 近 7 天
'+metrics+recHtml+'
'+events+'
'}catch(e){$('detailBody').innerHTML='
详情加载失败
'}} function reloadAll(){state.offset=0;state.rawOffset=0;state.selected='';loadOverview();reloadRawEvents(0);reloadTokens(0)} reloadAll(); setInterval(reloadAll,300000); diff --git a/static/paper_trading.html b/static/paper_trading.html index 1e1c38b..52158ac 100644 --- a/static/paper_trading.html +++ b/static/paper_trading.html @@ -14,6 +14,9 @@

这里展示策略信号进入交易账本后的表现:只有系统把可买信号转入持仓或挂单后,才会进入收益统计。推荐历史和观察池不会直接产生收益率。

+
@@ -132,7 +135,11 @@ function setTradeTab(tab){['open','orders','completed','events'].forEach(functio async function api(url){var r=await fetch(url);var d=await r.json().catch(function(){return{}});if(!r.ok)throw new Error(d.detail||d.error||'请求失败');return d} async function postApi(url){var r=await fetch(url,{method:'POST'});var d=await r.json().catch(function(){return{}});if(!r.ok)throw new Error(d.detail||d.error||'请求失败');return d} async function deleteApi(url){var r=await fetch(url,{method:'DELETE'});var d=await r.json().catch(function(){return{}});if(!r.ok)throw new Error((d.detail&&d.detail.reason)||d.detail||d.error||'请求失败');return d} -async function loadAll(){await Promise.all([loadSummary(),loadPerformance(),loadOrders(),loadOpenTrades(openOffset),loadCompleted(),loadEvents(eventOffset)])} +function selectedStrategy(){return $('strategyFilter')?($('strategyFilter').value||''):''} +function strategyQuery(){var code=selectedStrategy();return code?'&strategy_code='+encodeURIComponent(code):''} +async function loadAll(){await Promise.all([loadStrategies(),loadSummary(),loadPerformance(),loadOrders(),loadOpenTrades(openOffset),loadCompleted(),loadEvents(eventOffset)])} +function onStrategyFilterChange(){openOffset=0;eventOffset=0;loadAll()} +async function loadStrategies(){try{var d=await api('/api/paper-trading/strategies?days=120');var sel=$('strategyFilter');var current=sel.value||'';var rows=(d.strategies||[]).filter(function(x){return (x.signal_count||0)||(x.opportunity_count||0)||(x.trade_count||0)||(x.order_count||0)});sel.innerHTML=''+rows.map(function(x){return ''}).join('');sel.value=Array.prototype.some.call(sel.options,function(o){return o.value===current})?current:''}catch(e){}} async function sendReport(){var btn=$('sendReportBtn'),note=$('reportNote');btn.disabled=true;btn.textContent='发送中...';note.style.display='block';note.textContent='正在汇总当前交易数据并发送飞书报告...';try{var d=await postApi('/api/paper-trading/report?days=30');note.textContent=d.ok?'报告已发送到飞书。':'报告生成完成,但飞书发送未成功:'+String(d.push_result||'未知原因');await loadSummary()}catch(e){note.textContent='发送失败:'+e.message}finally{btn.disabled=false;btn.textContent='发送策略交易报告'}} async function resetLedger(){var scope=$('resetScope').value||'all';var label=$('resetScope').selectedOptions[0]?$('resetScope').selectedOptions[0].textContent:scope;if(!confirm('确认重置“'+label+'”?这个操作会删除策略交易账本里的对应数据,不能从页面恢复。'))return;try{var d=await postApi('/api/paper-trading/reset?scope='+encodeURIComponent(scope));var del=d.deleted||{};alert('已重置:持仓/历史 '+(del.trades||0)+' 条,挂单 '+(del.orders||0)+' 条,日志 '+(del.events||0)+' 条。');await loadAll()}catch(e){alert('重置失败:'+e.message)}} async function deleteTrade(id,symbol,status){if(!confirm('确认删除 '+symbol+' 的'+(status==='open'?'持仓':'历史仓位')+'记录?相关操作日志也会一起删除。'))return;try{await deleteApi('/api/paper-trading/trades/'+encodeURIComponent(id));await loadAll()}catch(e){alert('删除失败:'+e.message)}} @@ -153,7 +160,7 @@ function renderPerformance(d){var points=d.points||[];if(!points.length){$('perf '最大回撤 '+fmt(dd,2)+'%', '总收益 '+(pnl>0?'+':'')+fmt(pnl,2)+'U' ].join('');AlphaXCharts.renderEquity($('performanceChart'),d)} -async function loadOrders(){$('orderRows').innerHTML='加载中...';try{var d=await api('/api/paper-trading/orders?limit=50&offset=0&status=pending');renderOrders(d.items||[])}catch(e){$('orderRows').innerHTML=''+esc(e.message)+''}} +async function loadOrders(){$('orderRows').innerHTML='加载中...';try{var d=await api('/api/paper-trading/orders?limit=50&offset=0&status=pending'+strategyQuery());renderOrders(d.items||[])}catch(e){$('orderRows').innerHTML=''+esc(e.message)+''}} function renderOrders(items){if(!items.length){$('orderRows').innerHTML='暂无等待触价的策略挂单';return}$('orderRows').innerHTML=items.map(function(x){var latest=x.latest_price||x.current_price_at_create||0,dist=Number(x.distance_to_target_pct||0);return ''+ '
'+esc(x.symbol)+'
#'+esc(x.id)+' · Rec '+esc(x.recommendation_id)+'
'+ ''+esc(x.status==='pending'?'等待成交':x.status)+''+ @@ -168,11 +175,11 @@ function renderOrders(items){if(!items.length){$('orderRows').innerHTML=''+ ''}).join('')} function protectionCell(x){var trail=Number(x.trailing_stop||0);var trailHtml=trail>0?'移动止盈 $'+fmt(trail,6)+'':'移动止盈未启动';return '
TP $'+fmt(x.tp1,6)+'SL $'+fmt(x.stop_loss,6)+''+trailHtml+'
'} -function strategyName(x){return (x&&x.strategy_name)||({main_composite_v1:'综合确认主链路',box_retest_1h_v1:'1H箱体突破回踩',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='加载中...';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=''+esc(e.message)+''}} +function strategyName(x){return (x&&x.strategy_name)||({main_composite_v1:'综合确认策略',box_retest_1h_v1:'1H箱体突破回踩',box_retest_4h_v1:'4H箱体突破回踩',volume_ignition_1h_v1:'1H放量突破启动',compression_breakout_4h_v1:'4H压缩蓄力突破',intraday_momentum_15m_v1:'15m日内动量延续'}[(x&&x.strategy_code)||'']||((x&&x.strategy_code)||'--'))} +async function loadOpenTrades(nextOffset){openOffset=Math.max(0,nextOffset||0);$('openRows').innerHTML='加载中...';try{var d=await api('/api/paper-trading/trades?limit='+LIMIT+'&offset='+openOffset+'&status=open'+strategyQuery());openTotal=d.total||0;renderTradeRows('openRows',d.items||[],'暂无持仓中的策略交易');renderOpenPager()}catch(e){$('openRows').innerHTML=''+esc(e.message)+''}} async function loadCompleted(){await Promise.all([loadCompletedTrades(),loadCompletedOrders()])} -async function loadCompletedTrades(){$('completedTradeRows').innerHTML='加载中...';try{var d=await api('/api/paper-trading/trades?limit=80&offset=0&status=closed');renderTradeRows('completedTradeRows',d.items||[],'暂无已平仓策略交易')}catch(e){$('completedTradeRows').innerHTML=''+esc(e.message)+''}} -async function loadCompletedOrders(){$('completedOrderRows').innerHTML='加载中...';try{var sets=await Promise.all(['filled','expired','canceled','rejected'].map(function(s){return api('/api/paper-trading/orders?limit=50&offset=0&status='+s)}));var items=[];sets.forEach(function(d){items=items.concat(d.items||[])});items.sort(function(a,b){return String(b.updated_at||b.created_at).localeCompare(String(a.updated_at||a.created_at))});renderCompletedOrders(items)}catch(e){$('completedOrderRows').innerHTML=''+esc(e.message)+''}} +async function loadCompletedTrades(){$('completedTradeRows').innerHTML='加载中...';try{var d=await api('/api/paper-trading/trades?limit=80&offset=0&status=closed'+strategyQuery());renderTradeRows('completedTradeRows',d.items||[],'暂无已平仓策略交易')}catch(e){$('completedTradeRows').innerHTML=''+esc(e.message)+''}} +async function loadCompletedOrders(){$('completedOrderRows').innerHTML='加载中...';try{var sets=await Promise.all(['filled','expired','canceled','rejected'].map(function(s){return api('/api/paper-trading/orders?limit=50&offset=0&status='+s+strategyQuery())}));var items=[];sets.forEach(function(d){items=items.concat(d.items||[])});items.sort(function(a,b){return String(b.updated_at||b.created_at).localeCompare(String(a.updated_at||a.created_at))});renderCompletedOrders(items)}catch(e){$('completedOrderRows').innerHTML=''+esc(e.message)+''}} function renderTradeRows(targetId,items,emptyText){if(!items.length){$(targetId).innerHTML=''+esc(emptyText||'暂无策略交易')+'';return}$(targetId).innerHTML=items.map(function(x){var st=x.status==='open'?'持仓中':'已平仓';var latest=x.latest_price||x.current_price||0;var pnlUsdt=x.status==='closed'?x.realized_pnl_usdt:x.unrealized_pnl_usdt;return ''+ '
'+esc(x.symbol)+'
#'+esc(x.id)+' · Rec '+esc(x.recommendation_id)+'
'+ ''+st+''+ @@ -205,7 +212,7 @@ function renderCompletedOrders(items){if(!items.length){$('completedOrderRows'). function orderStatus(x){return {filled:'已成交',expired:'已过期',canceled:'已取消',rejected:'已拒绝'}[x.status]||x.status||'--'} function cancelReasonLabel(r){return {global_risk_rejected:'全局风控拒绝:市场/账户风险过高,未转持仓',stop_loss_leverage_risk_exceeded:'止损杠杆风险超限:按当前止损和杠杆亏损过大',portfolio_drawdown_pause:'账户回撤保护:暂停新增仓位',weak_entries_pause:'近期弱入场过多:暂停新增仓位',recommendation_invalid:'原推荐已失效:机会过期/归档后撤单',too_far_from_entry:'价格远离计划价:继续等待意义不大',expired:'挂单超时:超过有效期未成交',upgraded_to_buy_now:'信号升级为入场窗口:旧挂单已撤销',canceled:'已取消',filled:'已成交',rejected:'已拒绝'}[r]||r||'--'} function renderOpenPager(){var page=Math.floor(openOffset/LIMIT)+1,totalPages=Math.max(1,Math.ceil(openTotal/LIMIT));$('openPageInfo').textContent='第 '+page+' / '+totalPages+' 页 · 共 '+openTotal+' 条';$('openPager').innerHTML='第 '+page+' / '+totalPages+' 页'} -async function loadEvents(nextOffset){eventOffset=Math.max(0,nextOffset||0);$('eventRows').innerHTML='
加载中...
';try{var sym=$('eventSymbol').value||'';var typ=$('eventType').value||'';var d=await api('/api/paper-trading/events?limit='+EVENT_LIMIT+'&offset='+eventOffset+'&symbol='+encodeURIComponent(sym)+'&event_type='+encodeURIComponent(typ));eventTotal=d.total||0;renderEvents(d.items||[]);renderEventPager()}catch(e){$('eventRows').innerHTML='
'+esc(e.message)+'
'}} +async function loadEvents(nextOffset){eventOffset=Math.max(0,nextOffset||0);$('eventRows').innerHTML='
加载中...
';try{var sym=$('eventSymbol').value||'';var typ=$('eventType').value||'';var d=await api('/api/paper-trading/events?limit='+EVENT_LIMIT+'&offset='+eventOffset+'&symbol='+encodeURIComponent(sym)+'&event_type='+encodeURIComponent(typ)+strategyQuery());eventTotal=d.total||0;renderEvents(d.items||[]);renderEventPager()}catch(e){$('eventRows').innerHTML='
'+esc(e.message)+'
'}} function eventLabel(t){return {open:'开仓',close:'平仓',trailing_activate:'移动止盈激活',trailing_move:'移动止盈上移'}[t]||t||'动作'} function eventCls(t){if(t==='open')return'event-open';if(t==='close')return'event-close';if(String(t).indexOf('trailing')===0)return'event-trailing';return''} function contextPill(text,cls){return ''+esc(text)+''} diff --git a/static/review_center.html b/static/review_center.html index 3e642cf..70b382f 100644 --- a/static/review_center.html +++ b/static/review_center.html @@ -2,7 +2,7 @@ {% block title %}复盘中心 — AlphaX Agent{% endblock %} {% block extra_head_css %} {% endblock %} {% block content %} @@ -23,6 +23,7 @@
加载中...
+
加载中...
加载中...
@@ -55,13 +56,15 @@ function kpis(items){return '
'+items.map(function(x){return '< function rows(items,label,value,sub){items=items||[];if(!items.length)return '
暂无数据
';return '
'+items.map(function(x){return '
'+esc(label(x))+'
'+esc(sub?sub(x):'')+'
'+esc(value?value(x):'')+'
'}).join('')+'
'} function digestItems(items,label,sub){items=items||[];if(!items.length)return '
暂无动作
';return items.slice(0,4).map(function(x){return '
'+esc(label(x))+''+esc(sub?sub(x):'')+'
'}).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='
策略迭代摘要
'+esc(latest.title||'暂无复盘')+' · '+time(latest.time)+' · 版本 '+esc(latest.strategy_version||'--')+'
'+esc(decision)+'
'+[['因子生效调整',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 '
'+esc(x[0])+''+esc(x[1])+'
'}).join('')+'

升权了什么

'+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)+'%'})+'

降权 / 淘汰

'+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||'')})+'

灰度观察

'+digestItems(dig.gray,function(x){return x.signal||('候选 #'+x.id)},function(x){return '样本 '+(x.sample_size||0)+' · 置信 '+(x.confidence||0)+'% · '+(x.description||'')})+'

最近迭代

'+digestItems(dig.timeline,function(x){return time(x.time)+' · '+(x.decision||'hold')},function(x){return x.reason||x.title||''})+'
'} +function decisionCls(d){return ({promote:'promote',pause:'pause',gray:'gray',tune_entry:'tune_entry',review_entry_gate:'review_entry_gate'}[d]||'')} +function renderStrategyBoard(d){var se=d.strategy_evaluation||{},s=se.summary||{},items=se.strategies||[];var cards=items.slice(0,6).map(function(x){var score=Math.max(0,Math.min(100,Number(x.evaluation_score||0)));return '
'+esc(x.strategy_name||x.strategy_code)+'
'+esc(x.description||'暂无策略说明')+'
'+score.toFixed(0)+'
信号'+esc(x.signal_count||0)+'
机会'+esc(x.opportunity_count||0)+'
平仓'+esc(x.closed_trade_count||0)+'
胜率'+num(x.win_rate_pct,1)+'%
收益'+usd(x.realized_pnl_usdt)+'
均值'+pct(x.avg_realized_pnl_pct)+'
成交'+num(x.order_fill_rate_pct,1)+'%
转化'+num(x.trade_conversion_pct,1)+'%
'+esc(x.decision_label||x.decision)+' '+esc((x.reasons||[])[0]||'继续观察')+'
'+esc((x.next_actions||[])[0]||'等待更多样本')+'
'}).join('')||'
暂无策略评价数据
';$('strategyBoard').innerHTML='
多策略优胜劣汰
'+esc(se.definition||'按策略独立评价发现、执行、收益和风险。')+'
策略 '+esc(s.strategy_count||0)+' · 已交易 '+esc(s.traded_strategy_count||0)+' · 待暂停 '+esc(s.pause_count||0)+'
'+cards+'
'} 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']])+'
状态分布
'+rows(o.status_distribution,function(x){return x.name||'--'},function(x){return x.count})+'
复盘结果
'+rows(o.outcome_distribution,function(x){return x.name||'--'},function(x){return x.count})+'
'} 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','']])+'
策略表现
'+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)})+'
退出原因
'+rows(p.exit_reasons,function(x){return x.name||'--'},function(x){return x.count})+'
执行事件
'+rows(p.event_types,function(x){return x.name||'--'},function(x){return x.count})+'
真实交易因子
'+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)})+'
因子组表现
'+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)+'%'})+'
入场路径表现
'+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)+'%'})+'
观察/挂单推进
'+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)})+'
'} 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']])+'
链上信号
'+rows(e.onchain_signals,function(x){return x.name||'--'},function(x){return x.count})+'
舆情决策
'+rows(e.news_decisions,function(x){return x.name||'未处理'},function(x){return x.count})+'
'} 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']])+'
最新发布结论:'+esc(s.latest_release_decision||'hold')+'
'+esc(s.latest_release_reason||'暂无发布说明')+'
闸门
发布决策
'+rows(i.release_decisions,function(x){return x.name||'--'},function(x){return x.count})+'
候选状态
'+rows(i.candidate_status,function(x){return x.name||'--'},function(x){return x.count})+'
'} 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='
最近策略交易
'+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||'')})+'
漏选爆发
'+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||'')})+'
舆情事件
'+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||'未处理')})+'
链上信号
'+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||'')})+'
'} -function render(d){renderStrategyDigest(d);$('principles').innerHTML=(d.principles||[]).map(function(x){return '
'+esc(x)+'
'}).join('');renderOpportunity(d.opportunity||{});renderPaper(d.paper_trading||{});renderEvidence(d.evidence||{});renderIteration(d.iteration||{});renderRecent(d)} -async function loadAll(){try{var days=$('daysSel').value;var d=await (await fetch(API+'/api/review-center/dashboard?days='+days+'&_ts='+Date.now(),{cache:'no-store'})).json();render(d)}catch(e){['strategyDigest','principles','opportunityPanel','paperPanel','evidencePanel','iterationPanel','recentPanel'].forEach(function(id){$(id).innerHTML='
加载失败
'})}} +function render(d){renderStrategyDigest(d);renderStrategyBoard(d);$('principles').innerHTML=(d.principles||[]).map(function(x){return '
'+esc(x)+'
'}).join('');renderOpportunity(d.opportunity||{});renderPaper(d.paper_trading||{});renderEvidence(d.evidence||{});renderIteration(d.iteration||{});renderRecent(d)} +async function loadAll(){try{var days=$('daysSel').value;var d=await (await fetch(API+'/api/review-center/dashboard?days='+days+'&_ts='+Date.now(),{cache:'no-store'})).json();render(d)}catch(e){['strategyDigest','strategyBoard','principles','opportunityPanel','paperPanel','evidencePanel','iterationPanel','recentPanel'].forEach(function(id){$(id).innerHTML='
加载失败
'})}} loadAll(); {% endblock %} diff --git a/tests/test_llm_insights.py b/tests/test_llm_insights.py index 49580a3..6376fdb 100644 --- a/tests/test_llm_insights.py +++ b/tests/test_llm_insights.py @@ -79,7 +79,7 @@ def _fetch_llm_row(db_path): return dict(row) if row else None -def test_disabled_llm_skips_and_does_not_change_mainline(monkeypatch, temp_db): +def test_disabled_llm_skips_and_does_not_change_strategy_state(monkeypatch, temp_db): _insert_recommendation(temp_db) monkeypatch.setattr(llm_insights, "get_llm_module_enabled", lambda module: False) result = llm_insights.run(scope="recommendations", limit=10) diff --git a/tests/test_multi_strategy_infra.py b/tests/test_multi_strategy_infra.py index 209ab0c..3d7e558 100644 --- a/tests/test_multi_strategy_infra.py +++ b/tests/test_multi_strategy_infra.py @@ -1,9 +1,23 @@ 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_1H_STRATEGY, BOX_RETEST_4H_STRATEGY, MAIN_COMPOSITE_STRATEGY, strategy_label +from app.core.strategy_registry import ( + BOX_RETEST_1H_STRATEGY, + BOX_RETEST_4H_STRATEGY, + COMPRESSION_BREAKOUT_4H_STRATEGY, + INTRADAY_MOMENTUM_15M_STRATEGY, + MAIN_COMPOSITE_STRATEGY, + VOLUME_IGNITION_1H_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 +from app.db.strategy_signal_queries import insert_strategy_signal +from app.db.strategy_insights import evaluate_strategy_decision +from app.strategies.altcoin_breakout import ( + build_compression_breakout_4h_signal, + build_intraday_momentum_15m_signal, + build_volume_ignition_1h_signal, +) from app.strategies.box_retest_4h import build_box_retest_1h_signal @@ -27,9 +41,109 @@ def test_default_main_composite_strategy_signal_is_stable(): ).to_json_dict() assert signal["strategy_code"] == MAIN_COMPOSITE_STRATEGY - assert signal["strategy_name"] == "综合确认主链路" + assert signal["strategy_name"] == "综合确认策略" assert signal["factor_roles"]["vp_fly_1h_current"] == "trigger" assert strategy_label(BOX_RETEST_1H_STRATEGY) == "1H箱体突破回踩" + assert strategy_label(VOLUME_IGNITION_1H_STRATEGY) == "1H放量突破启动" + assert strategy_label(COMPRESSION_BREAKOUT_4H_STRATEGY) == "4H压缩蓄力突破" + assert strategy_label(INTRADAY_MOMENTUM_15M_STRATEGY) == "15m日内动量延续" + + +def test_volume_ignition_strategy_builds_independent_signal(): + signal = build_volume_ignition_1h_signal( + symbol="VOL/USDT", + result={ + "score": 8, + "signals": ["1H量价齐飞 · 连续放量", "15min即刻入场"], + "trigger_context": {"current_triggers": ["15m突破"], "trigger_status": "current"}, + "entry_plan": {"entry_action": "可即刻买入"}, + }, + entry_plan={"entry_action": "可即刻买入", "entry_price": 1.0}, + ) + payload = signal.to_json_dict() + + assert payload["strategy_code"] == VOLUME_IGNITION_1H_STRATEGY + assert payload["status"] == "candidate" + assert payload["trigger"]["factor_code"] == "vp_fly_1h_current" + assert payload["factor_roles"]["vp_fly_1h_current"] == "trigger" + + +def test_compression_breakout_strategy_requires_structure_and_breakout_context(): + signal = build_compression_breakout_4h_signal( + symbol="QUIET/USDT", + result={"score": 6, "signals": ["4H静K压缩,突破箱体上沿"], "entry_plan": {"entry_action": "等回踩"}}, + entry_plan={"entry_action": "等回踩", "entry_price": 1.0}, + ) + payload = signal.to_json_dict() + + assert payload["strategy_code"] == COMPRESSION_BREAKOUT_4H_STRATEGY + assert payload["status"] == "candidate" + assert payload["factor_roles"]["compression_surge_4h"] == "trigger" + assert build_compression_breakout_4h_signal( + symbol="NOBOX/USDT", + result={"score": 6, "signals": ["1H量价齐飞"], "entry_plan": {"entry_action": "可即刻买入"}}, + entry_plan={"entry_action": "可即刻买入"}, + ) is None + + +def test_intraday_momentum_strategy_requires_current_trigger(): + stale = build_intraday_momentum_15m_signal( + symbol="FAST/USDT", + result={"score": 7, "signals": ["15m短周期启动"], "entry_plan": {"entry_action": "可即刻买入"}}, + entry_plan={"entry_action": "可即刻买入"}, + ).to_json_dict() + fresh = build_intraday_momentum_15m_signal( + symbol="FAST/USDT", + result={ + "score": 7, + "signals": ["15min强突破"], + "trigger_context": {"current_triggers": ["15m突破"], "trigger_status": "current"}, + "entry_plan": {"entry_action": "可即刻买入"}, + }, + entry_plan={"entry_action": "可即刻买入"}, + ).to_json_dict() + + assert stale["status"] == "observe" + assert "缺少当前低周期触发" in stale["risk_plan"]["risk_reasons"] + assert fresh["status"] == "candidate" + + +def test_strategy_evaluation_recommends_promote_or_pause(): + strong = evaluate_strategy_decision({ + "signal_count": 24, + "opportunity_count": 16, + "trade_count": 8, + "closed_trade_count": 8, + "win_rate_pct": 62.5, + "avg_realized_pnl_pct": 3.2, + "realized_pnl_usdt": 180, + "worst_pnl_pct": -3.5, + "order_fill_rate_pct": 45, + "trade_conversion_pct": 50, + }) + weak = evaluate_strategy_decision({ + "signal_count": 24, + "opportunity_count": 16, + "trade_count": 8, + "closed_trade_count": 8, + "win_rate_pct": 25, + "avg_realized_pnl_pct": -2.5, + "realized_pnl_usdt": -120, + "worst_pnl_pct": -9, + "order_fill_rate_pct": 20, + "trade_conversion_pct": 50, + }) + unfilled = evaluate_strategy_decision({ + "signal_count": 18, + "opportunity_count": 12, + "trade_count": 0, + "closed_trade_count": 0, + }) + + assert strong["decision"] == "promote" + assert strong["evaluation_score"] > weak["evaluation_score"] + assert weak["decision"] == "pause" + assert unfilled["decision"] == "review_entry_gate" def test_strategy_signal_insert_and_recommendation_lineage(pg_conn):