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 @@
| 币种 | 链 | 重要性 | 风险分 | 映射事件 | 最近事件 | 数据源 | 主链路 |
|---|---|---|---|---|---|---|---|
| 加载中... | |||||||
| 币种 | 链 | 重要性 | 风险分 | 映射事件 | 最近事件 | 数据源 | 策略状态 |
|---|---|---|---|---|---|---|---|
| 加载中... | |||||||
这里展示策略信号进入交易账本后的表现:只有系统把可买信号转入持仓或挂单后,才会进入收益统计。推荐历史和观察池不会直接产生收益率。