This commit is contained in:
aaron 2026-05-29 10:09:30 +08:00
parent b65fc75893
commit 1e94714234
28 changed files with 783 additions and 68 deletions

View File

@ -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 层必须与后端能力对等。后端支持多策略筛选、评价或状态时,页面也要提供可理解的筛选、展示和操作入口,不能只返回隐藏字段。
---

View File

@ -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"),

View File

@ -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:

View File

@ -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,
},
),
}

View File

@ -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,

View File

@ -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;

View File

@ -0,0 +1,5 @@
UPDATE strategy_catalog
SET strategy_name='综合确认策略',
description='迁移期兼容综合策略:统一承载旧的综合筛选与确认逻辑,与其他策略平等运行。',
updated_at=NOW()::TEXT
WHERE strategy_code='main_composite_v1';

View File

@ -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(

View File

@ -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"):

View File

@ -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,

View File

@ -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.

View File

@ -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()

View File

@ -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")):

View File

@ -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": "止损",

View File

@ -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"})

View File

@ -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},
)

View File

@ -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")

View File

@ -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)

View File

@ -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线。

View File

@ -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让前端能看到策略来源。

View File

@ -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 门槛。

View File

@ -25,7 +25,7 @@
- `app/analysis/`
- `app/web/`
### 2. 运行链路
### 2. 运行服务链路
- `app/services/altcoin_screener.py`
- `app/services/altcoin_confirm.py`

View File

@ -890,7 +890,7 @@ function renderRecCard(r) {
return '<span class="sig '+cls+'">'+displaySignalText(s)+'</span>';
}).join('');
var score = r.rec_score||0, st = scoreTier(score), ver = r.strategy_version||'';
var strategyLabel = r.strategy_name || ({main_composite_v1:'综合确认主链路',box_retest_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);

View File

@ -53,7 +53,7 @@
</div>
<section class="panel">
<div class="panel-head"><div class="panel-title">单币档案</div><div class="panel-note" id="detailNote">选择币种</div></div>
<div class="detail-body" id="detailBody"><div class="empty">从映射信号流或资产列表中选择一个币,查看链上事件与主链路关系。</div></div>
<div class="detail-body" id="detailBody"><div class="empty">从映射信号流或资产列表中选择一个币,查看链上事件与策略信号关系。</div></div>
</section>
</div>
<div class="layout" style="grid-template-columns:1fr;margin-top:14px">
@ -63,7 +63,7 @@
<select class="select" id="chainSel" onchange="reloadTokens(0)"><option value="">全部链</option><option value="ethereum">Ethereum</option><option value="bsc">BSC</option></select>
<select class="select" id="signalSel" onchange="reloadTokens(0)"><option value="">全部信号</option><option value="large_token_transfer">大额转账</option><option value="whale_accumulation">鲸鱼增持</option><option value="holder_growth">持有人增长</option><option value="exchange_outflow">交易所流出</option><option value="exchange_inflow_risk">交易所流入</option><option value="holder_concentration_risk">持仓集中风险</option></select>
</div>
<div class="table-wrap"><table class="table"><thead><tr><th>币种</th><th></th><th>重要性</th><th>风险分</th><th>映射事件</th><th>最近事件</th><th>数据源</th><th>主链路</th></tr></thead><tbody id="tokenTable"><tr><td colspan="8" class="loading">加载中...</td></tr></tbody></table></div>
<div class="table-wrap"><table class="table"><thead><tr><th>币种</th><th></th><th>重要性</th><th>风险分</th><th>映射事件</th><th>最近事件</th><th>数据源</th><th>策略状态</th></tr></thead><tbody id="tokenTable"><tr><td colspan="8" class="loading">加载中...</td></tr></tbody></table></div>
<div class="pager"><button id="prevBtn" onclick="page(-1)">上一页</button><span id="pageInfo">--</span><button id="nextBtn" onclick="page(1)">下一页</button></div>
</section>
</div>
@ -94,7 +94,7 @@ async function reloadRawEvents(offset){state.rawOffset=offset||0;$('rawFeed').in
async function reloadTokens(offset){state.offset=offset||0;$('tokenTable').innerHTML='<tr><td colspan="8" class="loading">加载中...</td></tr>';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='<tr><td colspan="8" class="empty">暂无链上异动 token</td></tr>'}else{$('tokenTable').innerHTML=items.map(function(t){return '<tr onclick="loadDetail(\''+esc(t.symbol)+'\')"><td class="sym">'+esc(t.symbol)+'</td><td>'+esc(t.chain)+'</td><td class="num">'+Number(t.onchain_score||0).toFixed(0)+'</td><td class="num">'+Number(t.risk_score||0).toFixed(0)+'</td><td class="num">'+Number(t.event_count||t.mapped_event_count||0).toFixed(0)+'</td><td>'+esc(t.latest_event_at?fmtTime(t.latest_event_at):'--')+'</td><td>'+esc(t.source||'nodereal')+'</td><td>'+recLabel(t.recommendation)+'</td></tr>'}).join('');if(!state.selected&&items[0])loadDetail(items[0].symbol)}updatePager()}catch(e){$('tokenTable').innerHTML='<tr><td colspan="8" class="empty">加载失败</td></tr>'}}
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='<div class="loading">加载详情...</div>';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='<div class="metric-grid">'+[['重要性',Number(latest.onchain_score||0).toFixed(0)],['风险分',Number(latest.risk_score||0).toFixed(0)],['映射事件',rawCount.toFixed(0)],['数据源',latest.source||'nodereal']].map(function(x){return '<div class="metric"><span>'+x[0]+'</span><b>'+x[1]+'</b></div>'}).join('')+'</div>';var recHtml=rec?'<div class="hint">主链路状态:'+esc(rec.action_status||rec.execution_status||'观察')+' · 推荐 #'+esc(rec.id)+'</div>':'<div class="hint">尚未形成主链路推荐;若链上信号质量足够,会先进入技术检查。</div>';var standardEvents=(d.events||[]).slice(0,10).map(function(e){var cls=e.direction==='risk'?'risk':e.direction==='positive'?'pos':'blue';return '<div class="event"><div class="event-top"><div class="event-title">'+esc(e.signal_label||e.signal_code)+'</div><span class="badge '+cls+'">'+esc(e.severity||e.direction)+'</span></div><div class="event-meta">'+fmtTime(e.detected_at)+' · '+esc(e.chain)+' · '+fmtUsd(e.value_usd)+'<br>'+esc(e.wallet_label||e.counterparty_label||e.source||'链上事件')+'</div></div>'});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?' · <a class="raw-link" href="'+esc(e.url)+'" target="_blank" rel="noopener">查看来源</a>':'';var route=(e.from_short&&e.to_short)?'<br>路径:'+esc(e.from_short)+' → '+esc(e.to_short):'';return '<div class="event"><div class="event-top"><div class="event-title">'+esc(e.human_summary||e.event_label||'NodeReal 原始事件')+'</div><span class="badge mapped">已映射</span></div><div class="event-meta">'+fmtTime(e.detected_at)+' · '+esc(e.chain)+' · '+esc(amount)+link+route+'<br>'+esc(e.pipeline_note||'已映射,可进入后续链上信号分析。')+'</div></div>'});var events=standardEvents.concat(rawEvents).join('')||'<div class="empty">暂无事件明细</div>';$('detailBody').innerHTML='<div class="detail-title">'+esc(d.symbol)+'</div><div class="detail-sub">'+(d.mappings||[]).length+' 个合约映射 · 近 7 天</div>'+metrics+recHtml+'<div class="event-feed">'+events+'</div>'}catch(e){$('detailBody').innerHTML='<div class="empty">详情加载失败</div>'}}
async function loadDetail(symbol){state.selected=symbol;$('detailNote').textContent=symbol;$('detailBody').innerHTML='<div class="loading">加载详情...</div>';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='<div class="metric-grid">'+[['重要性',Number(latest.onchain_score||0).toFixed(0)],['风险分',Number(latest.risk_score||0).toFixed(0)],['映射事件',rawCount.toFixed(0)],['数据源',latest.source||'nodereal']].map(function(x){return '<div class="metric"><span>'+x[0]+'</span><b>'+x[1]+'</b></div>'}).join('')+'</div>';var recHtml=rec?'<div class="hint">策略状态:'+esc(rec.action_status||rec.execution_status||'观察')+' · 推荐 #'+esc(rec.id)+'</div>':'<div class="hint">尚未形成策略推荐;若链上信号质量足够,会先进入技术检查。</div>';var standardEvents=(d.events||[]).slice(0,10).map(function(e){var cls=e.direction==='risk'?'risk':e.direction==='positive'?'pos':'blue';return '<div class="event"><div class="event-top"><div class="event-title">'+esc(e.signal_label||e.signal_code)+'</div><span class="badge '+cls+'">'+esc(e.severity||e.direction)+'</span></div><div class="event-meta">'+fmtTime(e.detected_at)+' · '+esc(e.chain)+' · '+fmtUsd(e.value_usd)+'<br>'+esc(e.wallet_label||e.counterparty_label||e.source||'链上事件')+'</div></div>'});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?' · <a class="raw-link" href="'+esc(e.url)+'" target="_blank" rel="noopener">查看来源</a>':'';var route=(e.from_short&&e.to_short)?'<br>路径:'+esc(e.from_short)+' → '+esc(e.to_short):'';return '<div class="event"><div class="event-top"><div class="event-title">'+esc(e.human_summary||e.event_label||'NodeReal 原始事件')+'</div><span class="badge mapped">已映射</span></div><div class="event-meta">'+fmtTime(e.detected_at)+' · '+esc(e.chain)+' · '+esc(amount)+link+route+'<br>'+esc(e.pipeline_note||'已映射,可进入后续链上信号分析。')+'</div></div>'});var events=standardEvents.concat(rawEvents).join('')||'<div class="empty">暂无事件明细</div>';$('detailBody').innerHTML='<div class="detail-title">'+esc(d.symbol)+'</div><div class="detail-sub">'+(d.mappings||[]).length+' 个合约映射 · 近 7 天</div>'+metrics+recHtml+'<div class="event-feed">'+events+'</div>'}catch(e){$('detailBody').innerHTML='<div class="empty">详情加载失败</div>'}}
function reloadAll(){state.offset=0;state.rawOffset=0;state.selected='';loadOverview();reloadRawEvents(0);reloadTokens(0)}
reloadAll();
setInterval(reloadAll,300000);

View File

@ -14,6 +14,9 @@
<p>这里展示策略信号进入交易账本后的表现:只有系统把可买信号转入持仓或挂单后,才会进入收益统计。推荐历史和观察池不会直接产生收益率。</p>
</div>
<div class="actions">
<select class="select" id="strategyFilter" title="按策略筛选" onchange="onStrategyFilterChange()">
<option value="">全部策略</option>
</select>
<button class="btn" id="sendReportBtn" onclick="sendReport()">发送策略交易报告</button>
<button class="btn" onclick="loadAll()">刷新</button>
</div>
@ -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='<option value="">全部策略</option>'+rows.map(function(x){return '<option value="'+esc(x.strategy_code)+'">'+esc(x.strategy_name||x.strategy_code)+' · '+esc(x.decision_label||'观察')+'</option>'}).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
'<span class="perf-pill">最大回撤 '+fmt(dd,2)+'%</span>',
'<span class="perf-pill '+(pnl>=0?'pos':'neg')+'">总收益 '+(pnl>0?'+':'')+fmt(pnl,2)+'U</span>'
].join('');AlphaXCharts.renderEquity($('performanceChart'),d)}
async function loadOrders(){$('orderRows').innerHTML='<tr><td colspan="11" class="loading">加载中...</td></tr>';try{var d=await api('/api/paper-trading/orders?limit=50&offset=0&status=pending');renderOrders(d.items||[])}catch(e){$('orderRows').innerHTML='<tr><td colspan="11" class="empty">'+esc(e.message)+'</td></tr>'}}
async function loadOrders(){$('orderRows').innerHTML='<tr><td colspan="11" class="loading">加载中...</td></tr>';try{var d=await api('/api/paper-trading/orders?limit=50&offset=0&status=pending'+strategyQuery());renderOrders(d.items||[])}catch(e){$('orderRows').innerHTML='<tr><td colspan="11" class="empty">'+esc(e.message)+'</td></tr>'}}
function renderOrders(items){if(!items.length){$('orderRows').innerHTML='<tr><td colspan="11" class="empty">暂无等待触价的策略挂单</td></tr>';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 '<tr>'+
'<td><div class="sym">'+esc(x.symbol)+'</div><div class="muted">#'+esc(x.id)+' · Rec '+esc(x.recommendation_id)+'</div></td>'+
'<td><span class="badge open">'+esc(x.status==='pending'?'等待成交':x.status)+'</span></td>'+
@ -168,11 +175,11 @@ function renderOrders(items){if(!items.length){$('orderRows').innerHTML='<tr><td
'<td><button class="row-action" type="button" onclick="deleteOrder('+Number(x.id)+',\''+esc(String(x.symbol||'')).replace(/'/g,'&#39;')+'\')">删除</button></td>'+
'</tr>'}).join('')}
function protectionCell(x){var trail=Number(x.trailing_stop||0);var trailHtml=trail>0?'<span class="trail-line">移动止盈 $'+fmt(trail,6)+'</span>':'<span class="trail-line off">移动止盈未启动</span>';return '<div class="riskline"><span>TP $'+fmt(x.tp1,6)+'</span><span>SL $'+fmt(x.stop_loss,6)+'</span>'+trailHtml+'</div>'}
function strategyName(x){return (x&&x.strategy_name)||({main_composite_v1:'综合确认主链路',box_retest_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='<tr><td colspan="14" class="loading">加载中...</td></tr>';try{var d=await api('/api/paper-trading/trades?limit='+LIMIT+'&offset='+openOffset+'&status=open');openTotal=d.total||0;renderTradeRows('openRows',d.items||[],'暂无持仓中的策略交易');renderOpenPager()}catch(e){$('openRows').innerHTML='<tr><td colspan="14" class="empty">'+esc(e.message)+'</td></tr>'}}
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='<tr><td colspan="14" class="loading">加载中...</td></tr>';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='<tr><td colspan="14" class="empty">'+esc(e.message)+'</td></tr>'}}
async function loadCompleted(){await Promise.all([loadCompletedTrades(),loadCompletedOrders()])}
async function loadCompletedTrades(){$('completedTradeRows').innerHTML='<tr><td colspan="14" class="loading">加载中...</td></tr>';try{var d=await api('/api/paper-trading/trades?limit=80&offset=0&status=closed');renderTradeRows('completedTradeRows',d.items||[],'暂无已平仓策略交易')}catch(e){$('completedTradeRows').innerHTML='<tr><td colspan="14" class="empty">'+esc(e.message)+'</td></tr>'}}
async function loadCompletedOrders(){$('completedOrderRows').innerHTML='<tr><td colspan="11" class="loading">加载中...</td></tr>';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='<tr><td colspan="11" class="empty">'+esc(e.message)+'</td></tr>'}}
async function loadCompletedTrades(){$('completedTradeRows').innerHTML='<tr><td colspan="14" class="loading">加载中...</td></tr>';try{var d=await api('/api/paper-trading/trades?limit=80&offset=0&status=closed'+strategyQuery());renderTradeRows('completedTradeRows',d.items||[],'暂无已平仓策略交易')}catch(e){$('completedTradeRows').innerHTML='<tr><td colspan="14" class="empty">'+esc(e.message)+'</td></tr>'}}
async function loadCompletedOrders(){$('completedOrderRows').innerHTML='<tr><td colspan="11" class="loading">加载中...</td></tr>';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='<tr><td colspan="11" class="empty">'+esc(e.message)+'</td></tr>'}}
function renderTradeRows(targetId,items,emptyText){if(!items.length){$(targetId).innerHTML='<tr><td colspan="14" class="empty">'+esc(emptyText||'暂无策略交易')+'</td></tr>';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 '<tr>'+
'<td><div class="sym">'+esc(x.symbol)+'</div><div class="muted">#'+esc(x.id)+' · Rec '+esc(x.recommendation_id)+'</div></td>'+
'<td><span class="badge '+esc(x.status)+'">'+st+'</span></td>'+
@ -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='<button '+(openOffset===0?'disabled':'')+' onclick="loadOpenTrades('+(openOffset-LIMIT)+')">上一页</button><span>第 '+page+' / '+totalPages+' 页</span><button '+((openOffset+LIMIT>=openTotal)?'disabled':'')+' onclick="loadOpenTrades('+(openOffset+LIMIT)+')">下一页</button>'}
async function loadEvents(nextOffset){eventOffset=Math.max(0,nextOffset||0);$('eventRows').innerHTML='<div class="loading">加载中...</div>';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='<div class="empty">'+esc(e.message)+'</div>'}}
async function loadEvents(nextOffset){eventOffset=Math.max(0,nextOffset||0);$('eventRows').innerHTML='<div class="loading">加载中...</div>';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='<div class="empty">'+esc(e.message)+'</div>'}}
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 '<span class="ctx-pill '+esc(cls||'')+'">'+esc(text)+'</span>'}

File diff suppressed because one or more lines are too long

View File

@ -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)

View File

@ -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):