This commit is contained in:
aaron 2026-05-31 22:47:03 +08:00
parent 447edff0f6
commit 7299f0259b
12 changed files with 433 additions and 64 deletions

View File

@ -105,6 +105,8 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组
- 核心认知:因子不等于策略。一个因子可以是先决条件、触发、确认、入场、风控或归因,但不能因为单个因子表现好就直接升级成完整策略。 - 核心认知:因子不等于策略。一个因子可以是先决条件、触发、确认、入场、风控或归因,但不能因为单个因子表现好就直接升级成完整策略。
- 完整策略必须至少包含:适用市场环境、交易宇宙、先决条件、核心触发、辅助确认、入场规则、止盈止损、失效条件、仓位/杠杆约束和独立复盘口径。 - 完整策略必须至少包含:适用市场环境、交易宇宙、先决条件、核心触发、辅助确认、入场规则、止盈止损、失效条件、仓位/杠杆约束和独立复盘口径。
- 多策略链路必须保持独立:`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 执行门禁。 - 多策略链路必须保持独立:`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 执行门禁。
- 多/空方向是机会到交易的强制字段发现、确认、推荐、跟踪、挂单、paper/live trading、复盘和 UI 都必须保留 `side=long/short`,不能在任一层默认覆盖成做多。`entry_plan.side` 是交易执行层的优先事实源,`direction` 只是面向用户的中文展示。
- 同一个 `symbol` 可以同时存在多头机会和空头机会。推荐写入和列表去重必须按 `symbol + direction/side` 处理,不能只按币种去重,否则会互相覆盖。
- 策略级配置入口在 `app/core/strategy_registry.py``StrategyDefinition.entry_gate_config` 控制确认/跟踪/展示派生的买点质量闸门,`StrategyDefinition.paper_config` 控制该策略进入 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`,否则会影响其他策略的信号生成和成交样本。 - 新增策略时必须注册稳定 `strategy_code`,并明确自己的 `entry_gate_config` / `paper_config`。不要把新策略的特殊门槛写进全局 `paper_trading_config()``DEFAULT_ENTRY_GATE`,否则会影响其他策略的信号生成和成交样本。
- `apply_entry_quality_gate()` 必须传入或从 `entry_plan.strategy_code` 派生策略身份;`paper_trader.py` 中开仓、挂单、挂单成交和挂单维护应通过策略级配置合并后的参数执行。 - `apply_entry_quality_gate()` 必须传入或从 `entry_plan.strategy_code` 派生策略身份;`paper_trader.py` 中开仓、挂单、挂单成交和挂单维护应通过策略级配置合并后的参数执行。
@ -128,6 +130,7 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组
- `strategy_version` 只表示版本,不应替代策略身份;后续推荐、挂单和交易账本都应补充 `strategy_code`、`strategy_signal_id`、`strategy_snapshot_json` 和 `factor_roles_json` - `strategy_version` 只表示版本,不应替代策略身份;后续推荐、挂单和交易账本都应补充 `strategy_code`、`strategy_signal_id`、`strategy_snapshot_json` 和 `factor_roles_json`
- 现有综合确认策略在迁移期标记为 `main_composite_v1`,它只是平等策略之一,用于避免无策略来源的推荐继续进入 paper trading。 - 现有综合确认策略在迁移期标记为 `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 门禁。 - 当前已拆出的独立策略包括:`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 门禁。
- 空头策略不能简单反转多头策略。当前第一版空头机会是 `breakdown_retest_short_1h_v1`核心剧本是“1H箱体下破 -> 反抽箱体下沿/均线 -> 反抽失败 -> 等反抽或开空”,并使用独立 `strategy_code`、`factor_roles`、RR/止损几何和复盘口径。
- 新增策略必须先 observe-only 或 paper-only 积累样本,再进入灰度/发布;不能因为某个因子短期表现好就直接同步真实交易。 - 新增策略必须先 observe-only 或 paper-only 积累样本,再进入灰度/发布;不能因为某个因子短期表现好就直接同步真实交易。
### 4.1.3 链上数据源 ### 4.1.3 链上数据源
@ -189,7 +192,8 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组
- `app/core/position_health.py` - `app/core/position_health.py`
- 已开仓仓位的健康检查中心。它不是入场规则,而是持仓后判断“机会是否仍按原计划运行”:超时未启动会收紧保护价或提前退出,浮盈大幅回吐且未触发移动止盈会提前退出,市场进入 critical 时未受保护的微盈利/弱势仓位可提前退出。 - 已开仓仓位的健康检查中心。它不是入场规则,而是持仓后判断“机会是否仍按原计划运行”:超时未启动会收紧保护价或提前退出,浮盈大幅回吐且未触发移动止盈会提前退出,市场进入 critical 时未受保护的微盈利/弱势仓位可提前退出。
- 交易执行能力必须按“决策核心 -> 执行适配 -> 账本记录”分层:移动止盈、仓位失效保护、动态杠杆、仓位 sizing、订单触发、账户风控都应有可被 paper trading 和 live trading 复用的核心模块。不要把这些规则直接写死在 `app/db/paper_trading.py`、`routes_live_trading.py` 或页面 JS 中。 - 交易执行能力必须按“决策核心 -> 执行适配 -> 账本记录”分层:移动止盈、仓位失效保护、动态杠杆、仓位 sizing、订单触发、账户风控都应有可被 paper trading 和 live trading 复用的核心模块。不要把这些规则直接写死在 `app/db/paper_trading.py`、`routes_live_trading.py` 或页面 JS 中。
- 交易执行层已支持 `side=long/short`paper trading 开仓、平仓、PnL、TP/SL、移动止盈、持仓健康和挂单触价都按 side 处理。当前已注册第一版独立空头策略 `breakdown_retest_short_1h_v1`,但这不代表发现/确认层已经全量接入空头扫描;新增做空必须继续使用独立 `strategy_code`、触发条件、失效条件和复盘口径,不能把多头策略简单反向。 - 交易执行层已支持 `side=long/short`paper trading 开仓、平仓、PnL、TP/SL、移动止盈、持仓健康和挂单触价都按 side 处理。当前发现/确认层已接入第一版独立空头策略 `breakdown_retest_short_1h_v1`;后续新增做空策略也必须继续使用独立 `strategy_code`、触发条件、失效条件和复盘口径,不能把多头策略简单反向。
- 多空交易数学必须走共享核心模块:`app/core/trade_math.py`、`app/core/order_lifecycle.py`、`app/core/trade_direction.py`。不要在页面、route、paper/live 适配层里重新手写“多头涨了盈利/空头跌了盈利”的判断,避免后续实盘同步和复盘口径分裂。
- 多策略基础设施当前内置 `main_composite_v1`、`box_retest_4h_v1`、`box_retest_1h_v1`、`volume_ignition_1h_v1`、`compression_breakout_4h_v1`、`intraday_momentum_15m_v1`、`breakdown_retest_short_1h_v1`。`box_breakout_pullback_4h` / `box_breakout_pullback_1h`、`vp_fly_1h_current`、`short_tf_15m_ignition`、`breakdown_retest_1h_short` 等只是因子,只有和入场确认、风控、失效条件组成完整剧本后,才作为对应策略信号写入 `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`、`breakdown_retest_short_1h_v1`。`box_breakout_pullback_4h` / `box_breakout_pullback_1h`、`vp_fly_1h_current`、`short_tf_15m_ignition`、`breakdown_retest_1h_short` 等只是因子,只有和入场确认、风控、失效条件组成完整剧本后,才作为对应策略信号写入 `strategy_signals`
- 确认层也会应用同一市场风控语义:`risk_level=critical` 且 `position_multiplier=0` 时,强势发现仍可记录为观察,但不能输出 `buy_now` 或新挂单动作;已有活跃可交易推荐会被降级为观察并写入 `market_risk_gate` - 确认层也会应用同一市场风控语义:`risk_level=critical` 且 `position_multiplier=0` 时,强势发现仍可记录为观察,但不能输出 `buy_now` 或新挂单动作;已有活跃可交易推荐会被降级为观察并写入 `market_risk_gate`

View File

@ -10,7 +10,9 @@ import re
from typing import Any, Dict, Iterable, Tuple from typing import Any, Dict, Iterable, Tuple
from app.core.opportunity_level import OPPORTUNITY_LEVELS from app.core.opportunity_level import OPPORTUNITY_LEVELS
from app.core.order_lifecycle import order_rr
from app.core.strategy_registry import normalize_strategy_code, strategy_entry_gate_config from app.core.strategy_registry import normalize_strategy_code, strategy_entry_gate_config
from app.core.trade_direction import normalize_trade_side
DEFAULT_ENTRY_GATE = { DEFAULT_ENTRY_GATE = {
"enabled": True, "enabled": True,
@ -147,6 +149,16 @@ def calc_rr_target_entry(stop_loss: float, tp1: float, min_rr: float) -> float:
return round((tp1 + min_rr * stop_loss) / (1 + min_rr), 8) return round((tp1 + min_rr * stop_loss) / (1 + min_rr), 8)
def calc_short_rr_target_entry(stop_loss: float, tp1: float, min_rr: float) -> float:
"""最低允许开空价在该价格或更高开空RR1 才能达到 min_rr。"""
stop_loss = to_float(stop_loss)
tp1 = to_float(tp1)
min_rr = to_float(min_rr)
if stop_loss <= tp1 or tp1 <= 0 or min_rr <= 0:
return 0.0
return round((tp1 + min_rr * stop_loss) / (1 + min_rr), 8)
def detect_breakout_distance_pct(signals: Iterable[Any]) -> float: def detect_breakout_distance_pct(signals: Iterable[Any]) -> float:
"""从“站稳突破位 +66.7%”等信号中提取最大追高距离。""" """从“站稳突破位 +66.7%”等信号中提取最大追高距离。"""
max_pct = 0.0 max_pct = 0.0
@ -285,6 +297,8 @@ def apply_entry_quality_gate(
entry_plan = dict(entry_plan or {}) entry_plan = dict(entry_plan or {})
entry_plan.setdefault("strategy_code", strategy_code) entry_plan.setdefault("strategy_code", strategy_code)
side = normalize_trade_side(entry_plan.get("side") or entry_plan.get("direction"))
entry_plan["side"] = side
signals = normalize_signals(signals) signals = normalize_signals(signals)
market_context = market_context or {} market_context = market_context or {}
derivatives_context = derivatives_context or {} derivatives_context = derivatives_context or {}
@ -300,8 +314,12 @@ def apply_entry_quality_gate(
tp1 = to_float(entry_plan.get("tp1") or entry_plan.get("take_profit_1")) tp1 = to_float(entry_plan.get("tp1") or entry_plan.get("take_profit_1"))
plan_entry_price = to_float(entry_plan.get("entry_price")) plan_entry_price = to_float(entry_plan.get("entry_price"))
invalid_plan_geometry = False invalid_plan_geometry = False
if current_price > 0 and stop_loss > 0 and tp1 > 0 and current_price > stop_loss: if current_price > 0 and stop_loss > 0 and tp1 > 0:
live_rr1 = round((tp1 - current_price) / (current_price - stop_loss), 2) live_rr1 = order_rr(side, current_price, stop_loss, tp1)
if live_rr1 > 0:
live_rr1 = round(live_rr1, 2)
else:
live_rr1 = 0.0
entry_plan["rr1_live"] = live_rr1 entry_plan["rr1_live"] = live_rr1
entry_plan["rr1_live_price"] = round(current_price, 8) entry_plan["rr1_live_price"] = round(current_price, 8)
# 当前价已经明显低于确认时价格时,旧 rr1/risk_reward_ok 会失真。 # 当前价已经明显低于确认时价格时,旧 rr1/risk_reward_ok 会失真。
@ -310,10 +328,17 @@ def apply_entry_quality_gate(
risk_reward_ok = live_rr1 >= _cfg_value(cfg, "min_rr_buy_now") risk_reward_ok = live_rr1 >= _cfg_value(cfg, "min_rr_buy_now")
entry_plan["risk_reward_ok_live"] = risk_reward_ok entry_plan["risk_reward_ok_live"] = risk_reward_ok
if action_status in ("可即刻买入", "等回踩") and plan_entry_price > 0: if action_status in ("可即刻买入", "等回踩") and plan_entry_price > 0:
if stop_loss > 0 and stop_loss >= plan_entry_price: if side == "short":
if stop_loss > 0 and stop_loss <= plan_entry_price:
invalid_plan_geometry = True
reasons.append("空头计划无效:止损价不高于计划开空价,转为观察")
if tp1 > 0 and tp1 >= plan_entry_price:
invalid_plan_geometry = True
reasons.append("空头计划无效TP1不低于计划开空价转为观察")
elif stop_loss > 0 and stop_loss >= plan_entry_price:
invalid_plan_geometry = True invalid_plan_geometry = True
reasons.append("多头计划无效:止损价不低于计划入场价,转为观察") reasons.append("多头计划无效:止损价不低于计划入场价,转为观察")
if tp1 > 0 and tp1 <= plan_entry_price: if side != "short" and tp1 > 0 and tp1 <= plan_entry_price:
invalid_plan_geometry = True invalid_plan_geometry = True
reasons.append("多头计划无效TP1不高于计划入场价转为观察") reasons.append("多头计划无效TP1不高于计划入场价转为观察")
entry_action = str(entry_plan.get("entry_action") or "").strip() entry_action = str(entry_plan.get("entry_action") or "").strip()
@ -363,16 +388,26 @@ def apply_entry_quality_gate(
reasons.append(f"{entry_plan.get('opportunity_level_label') or opportunity_level or '该机会'}级别需要低周期触发后才允许买入") reasons.append(f"{entry_plan.get('opportunity_level_label') or opportunity_level or '该机会'}级别需要低周期触发后才允许买入")
if not current_entry_trigger: if not current_entry_trigger:
reasons.append("缺少当前15min触发禁止现价买入") reasons.append("缺少当前15min触发禁止现价买入")
if bearish_flow_risk: if side != "short" and bearish_flow_risk:
reasons.append("出现空头加速/放量阴线风险,禁止现价买入") reasons.append("出现空头加速/放量阴线风险,禁止现价买入")
if current_price > 0: if current_price > 0:
plan_entry_price = to_float(entry_plan.get("entry_price")) plan_entry_price = to_float(entry_plan.get("entry_price"))
# 价格已经回到/跌破计划参考价,且实时 RR 已达标时,应转为入场窗口,不能继续显示“等回踩”。 # 价格已经回到/跌破计划参考价,且实时 RR 已达标时,应转为入场窗口,不能继续显示“等回踩”。
if plan_entry_price > 0 and current_price <= plan_entry_price * 1.003 and risk_reward_ok is not False and rr1 >= _cfg_value(cfg, "min_rr_buy_now"): target_touched = (
plan_entry_price > 0
and (
(side == "short" and current_price >= plan_entry_price * 0.997)
or (side != "short" and current_price <= plan_entry_price * 1.003)
)
)
if target_touched and risk_reward_ok is not False and rr1 >= _cfg_value(cfg, "min_rr_buy_now"):
entry_plan["entry_trigger_confirmed"] = True entry_plan["entry_trigger_confirmed"] = True
entry_plan["entry_action"] = "可即刻买入" entry_plan["entry_action"] = "可即刻买入"
# 缺少止损/目标价时 rr1 默认 999不能因为字段不全拦截测试/候选信号;有显式 rr1 时才按硬门槛降级。 # 缺少止损/目标价时 rr1 默认 999不能因为字段不全拦截测试/候选信号;有显式 rr1 时才按硬门槛降级。
if entry_action == "等回踩" and not entry_plan.get("entry_trigger_confirmed") and current_price > to_float(entry_plan.get("entry_price")) * 1.003: if entry_action == "等回踩" and not entry_plan.get("entry_trigger_confirmed") and (
(side == "short" and current_price < to_float(entry_plan.get("entry_price")) * 0.997)
or (side != "short" and current_price > to_float(entry_plan.get("entry_price")) * 1.003)
):
reasons.append("原计划为等回踩,当前尚未严格触达计划价") reasons.append("原计划为等回踩,当前尚未严格触达计划价")
if breakout_distance > _cfg_value(cfg, "breakout_distance_wait_pct"): if breakout_distance > _cfg_value(cfg, "breakout_distance_wait_pct"):
reasons.append(f"离突破位+{breakout_distance:.1f}%,现价追高降级") reasons.append(f"离突破位+{breakout_distance:.1f}%,现价追高降级")
@ -384,7 +419,10 @@ def apply_entry_quality_gate(
target_action = "观察" target_action = "观察"
reasons.append(f"买点分{entry_score:.1f}低于挂单门槛{_cfg_value(cfg, 'min_entry_score_wait_pullback')},不生成回踩挂单") reasons.append(f"买点分{entry_score:.1f}低于挂单门槛{_cfg_value(cfg, 'min_entry_score_wait_pullback')},不生成回踩挂单")
if plan_entry_price > 0: if plan_entry_price > 0:
wait_deviation_pct = round((current_price / plan_entry_price - 1) * 100, 2) if side == "short":
wait_deviation_pct = round((plan_entry_price / current_price - 1) * 100, 2) if current_price > 0 else 0
else:
wait_deviation_pct = round((current_price / plan_entry_price - 1) * 100, 2)
entry_plan["wait_pullback_deviation_pct"] = wait_deviation_pct entry_plan["wait_pullback_deviation_pct"] = wait_deviation_pct
lifecycle_plan_type = ((entry_plan.get("opportunity_lifecycle") or {}).get("plan_type") or "").strip() lifecycle_plan_type = ((entry_plan.get("opportunity_lifecycle") or {}).get("plan_type") or "").strip()
# 回踩参考已经被有效击穿,继续挂“等回踩”会误导;先降为观察。 # 回踩参考已经被有效击穿,继续挂“等回踩”会误导;先降为观察。
@ -403,7 +441,7 @@ def apply_entry_quality_gate(
target_action = "等回踩" if level_max_action == "wait_pullback" and not bearish_flow_risk else "观察" target_action = "等回踩" if level_max_action == "wait_pullback" and not bearish_flow_risk else "观察"
if level_max_action == "observe" or (level_max_action == "wait_pullback" and not current_entry_trigger): if level_max_action == "observe" or (level_max_action == "wait_pullback" and not current_entry_trigger):
reasons.append(f"{entry_plan.get('opportunity_level_label') or opportunity_level or '该机会'}级别最高只允许观察/等待,不能因到价直接升级为现价买入") reasons.append(f"{entry_plan.get('opportunity_level_label') or opportunity_level or '该机会'}级别最高只允许观察/等待,不能因到价直接升级为现价买入")
if bearish_flow_risk: if side != "short" and bearish_flow_risk:
reasons.append("出现空头加速/放量阴线风险,到价也不升级为现价买入") reasons.append("出现空头加速/放量阴线风险,到价也不升级为现价买入")
else: else:
target_action = "可即刻买入" target_action = "可即刻买入"
@ -427,25 +465,37 @@ def apply_entry_quality_gate(
if any("回踩参考已下破" in str(x) for x in reasons): if any("回踩参考已下破" in str(x) for x in reasons):
target_action = "观察" target_action = "观察"
elif any("回踩参考已到或更优" in str(x) for x in reasons) and not ( elif any("回踩参考已到或更优" in str(x) for x in reasons) and not (
level_max_action == "observe" or (level_max_action == "wait_pullback" and not current_entry_trigger) or bearish_flow_risk level_max_action == "observe" or (level_max_action == "wait_pullback" and not current_entry_trigger) or (side != "short" and bearish_flow_risk)
): ):
target_action = "可即刻买入" target_action = "可即刻买入"
elif action_status == "等回踩" and current_price > 0 and to_float(entry_plan.get("entry_price")) > 0 and current_price <= to_float(entry_plan.get("entry_price")) * 1.003 and (risk_reward_ok is False or rr1 < _cfg_value(cfg, "min_rr_buy_now")): elif action_status == "等回踩" and current_price > 0 and to_float(entry_plan.get("entry_price")) > 0 and (
(side == "short" and current_price >= to_float(entry_plan.get("entry_price")) * 0.997)
or (side != "short" and current_price <= to_float(entry_plan.get("entry_price")) * 1.003)
) and (risk_reward_ok is False or rr1 < _cfg_value(cfg, "min_rr_buy_now")):
target_action = "观察" target_action = "观察"
reasons.append("回踩参考已到,但实时盈亏比不达标,转为观察") reasons.append("回踩参考已到,但实时盈亏比不达标,转为观察")
elif has_entry_score and action_status == "可即刻买入" and entry_score < _cfg_value(cfg, "min_entry_score_wait_pullback"): elif has_entry_score and action_status == "可即刻买入" and entry_score < _cfg_value(cfg, "min_entry_score_wait_pullback"):
target_action = "观察" target_action = "观察"
reasons.append("买点分不足以进入挂单池,转为观察") reasons.append("买点分不足以进入挂单池,转为观察")
elif action_status == "可即刻买入" and current_price > 0 and stop_loss > 0 and tp1 > stop_loss and (risk_reward_ok is False or rr1 < _cfg_value(cfg, "min_rr_buy_now")): elif action_status == "可即刻买入" and current_price > 0 and stop_loss > 0 and tp1 > 0 and (risk_reward_ok is False or rr1 < _cfg_value(cfg, "min_rr_buy_now")):
rr_target_entry = calc_rr_target_entry(stop_loss, tp1, _cfg_value(cfg, "min_rr_buy_now")) rr_target_entry = (
if rr_target_entry > stop_loss and rr_target_entry < current_price: calc_short_rr_target_entry(stop_loss, tp1, _cfg_value(cfg, "min_rr_buy_now"))
if side == "short"
else calc_rr_target_entry(stop_loss, tp1, _cfg_value(cfg, "min_rr_buy_now"))
)
valid_wait = rr_target_entry > current_price if side == "short" else (rr_target_entry > stop_loss and rr_target_entry < current_price)
if valid_wait:
target_action = "等回踩" target_action = "等回踩"
entry_plan["entry_price"] = rr_target_entry entry_plan["entry_price"] = rr_target_entry
entry_plan["entry_method"] = f"等回踩至可买RR价 {rr_target_entry:.8g}" entry_plan["entry_method"] = f"反抽至可空RR价 {rr_target_entry:.8g}" if side == "short" else f"回踩至可买RR价 {rr_target_entry:.8g}"
entry_plan["entry_action"] = "等回踩" entry_plan["entry_action"] = "等回踩"
entry_plan["rr_target_entry"] = rr_target_entry entry_plan["rr_target_entry"] = rr_target_entry
entry_plan["rr_target_reason"] = f"现价RR不足需回落到该价或更低RR1才≥{_cfg_value(cfg, 'min_rr_buy_now')}" entry_plan["rr_target_reason"] = (
reasons.append(f"现价不买,等回落到{rr_target_entry:.8g}附近再评估") f"现价RR不足需反抽到该价或更高RR1才≥{_cfg_value(cfg, 'min_rr_buy_now')}"
if side == "short"
else f"现价RR不足需回落到该价或更低RR1才≥{_cfg_value(cfg, 'min_rr_buy_now')}"
)
reasons.append(("现价不空,等反抽到" if side == "short" else "现价不买,等回落到") + f"{rr_target_entry:.8g}附近再评估")
else: else:
target_action = "观察" target_action = "观察"
reasons.append("无法给出有效回踩买点,转为观察") reasons.append("无法给出有效回踩买点,转为观察")

View File

@ -0,0 +1,40 @@
"""Shared long/short direction helpers for opportunities and execution."""
from __future__ import annotations
def normalize_trade_side(value: object = "") -> str:
text = str(value or "").strip().lower()
if text in {"short", "sell", "", "空头", "做空", "空头启动"}:
return "short"
return "long"
def side_from_direction(direction: object = "") -> str:
text = str(direction or "").strip().lower()
if any(token in text for token in ("", "short", "sell")):
return "short"
return "long"
def side_label(side: object = "") -> str:
return "" if normalize_trade_side(side) == "short" else ""
def direction_label(side: object = "") -> str:
return "空头启动" if normalize_trade_side(side) == "short" else "多头启动"
def trade_side_from_payload(*payloads: object) -> str:
for payload in payloads:
if isinstance(payload, dict):
if payload.get("side"):
return normalize_trade_side(payload.get("side"))
if payload.get("direction"):
return side_from_direction(payload.get("direction"))
for payload in payloads:
if payload:
side = side_from_direction(payload)
if side == "short":
return "short"
return "long"

View File

@ -11,6 +11,7 @@ from app.core.opportunity_lifecycle import (
) )
from app.core.signal_taxonomy import signal_codes as build_signal_codes, signal_labels as build_signal_labels from app.core.signal_taxonomy import signal_codes as build_signal_codes, signal_labels as build_signal_labels
from app.core.strategy_contract import signal_to_recommendation_context from app.core.strategy_contract import signal_to_recommendation_context
from app.core.trade_direction import direction_label, trade_side_from_payload
from app.core.strategy_registry import normalize_strategy_code from app.core.strategy_registry import normalize_strategy_code
from app.db.recommendation_state import ( from app.db.recommendation_state import (
derive_minimal_state_fields, derive_minimal_state_fields,
@ -78,6 +79,7 @@ def create_recommendation(
factor_roles=None, factor_roles=None,
): ):
"""Create or merge the current recommendation record for one symbol.""" """Create or merge the current recommendation record for one symbol."""
entry_plan = dict(entry_plan or {})
raw_pct = round(rec_score * 100.0 / 30) if rec_score else 0 raw_pct = round(rec_score * 100.0 / 30) if rec_score else 0
rec_score_pct = min(raw_pct, 100) rec_score_pct = min(raw_pct, 100)
strategy_context = signal_to_recommendation_context( strategy_context = signal_to_recommendation_context(
@ -89,9 +91,17 @@ def create_recommendation(
fallback_symbol=symbol, fallback_symbol=symbol,
fallback_score=rec_score_pct, fallback_score=rec_score_pct,
signal_codes=build_signal_codes(build_signal_labels(signals if isinstance(signals, list) else normalize_signals(signals))), signal_codes=build_signal_codes(build_signal_labels(signals if isinstance(signals, list) else normalize_signals(signals))),
entry_plan=entry_plan or {}, entry_plan=entry_plan,
market_context=market_context or {}, market_context=market_context or {},
) )
side = trade_side_from_payload(entry_plan, strategy_context.get("strategy_snapshot"), direction)
entry_plan["side"] = side
direction = direction_label(side)
if isinstance(strategy_context.get("strategy_snapshot"), dict):
strategy_context["strategy_snapshot"]["direction"] = side
snapshot_plan = dict(strategy_context["strategy_snapshot"].get("entry_plan") or {})
snapshot_plan["side"] = side
strategy_context["strategy_snapshot"]["entry_plan"] = {**entry_plan, **snapshot_plan}
strategy_code = normalize_strategy_code(strategy_context.get("strategy_code")) strategy_code = normalize_strategy_code(strategy_context.get("strategy_code"))
strategy_signal_id = int(strategy_context.get("strategy_signal_id") or 0) strategy_signal_id = int(strategy_context.get("strategy_signal_id") or 0)
strategy_snapshot = strategy_context.get("strategy_snapshot") or {} strategy_snapshot = strategy_context.get("strategy_snapshot") or {}
@ -111,10 +121,10 @@ def create_recommendation(
duplicate_cursor = conn.execute( duplicate_cursor = conn.execute(
""" """
SELECT * FROM recommendation SELECT * FROM recommendation
WHERE symbol=%s AND status='active' AND COALESCE(display_bucket,'watch_pool') != 'history' WHERE symbol=%s AND status='active' AND direction=%s AND COALESCE(display_bucket,'watch_pool') != 'history'
ORDER BY id DESC LIMIT 1 ORDER BY id DESC LIMIT 1
""", """,
(symbol,), (symbol, direction),
) )
duplicate_row = duplicate_cursor.fetchone() if hasattr(duplicate_cursor, "fetchone") else None duplicate_row = duplicate_cursor.fetchone() if hasattr(duplicate_cursor, "fetchone") else None
if duplicate_row and (entry_plan or duplicate_row["rec_state"] == rec_state): if duplicate_row and (entry_plan or duplicate_row["rec_state"] == rec_state):

View File

@ -10,6 +10,7 @@ from app.db.recommendation_state import (
is_actionable_execution_status as _is_actionable_execution_status, is_actionable_execution_status as _is_actionable_execution_status,
) )
from app.core.strategy_registry import normalize_strategy_code, strategy_label from app.core.strategy_registry import normalize_strategy_code, strategy_label
from app.core.trade_direction import normalize_trade_side, side_label
from app.db.push_queries import get_recommendation_for_push, log_push, should_push from app.db.push_queries import get_recommendation_for_push, log_push, should_push
from app.db.schema import get_conn from app.db.schema import get_conn
from app.db.tracking_queries import update_recommendation_tracking from app.db.tracking_queries import update_recommendation_tracking
@ -148,6 +149,15 @@ def _decorate_recommendation(item: dict) -> dict:
item["strategy_name"] = strategy_label(item["strategy_code"]) item["strategy_name"] = strategy_label(item["strategy_code"])
item["strategy_snapshot"] = _loads_json(item.get("strategy_snapshot_json"), {}) item["strategy_snapshot"] = _loads_json(item.get("strategy_snapshot_json"), {})
item["factor_roles"] = _loads_json(item.get("factor_roles_json"), {}) item["factor_roles"] = _loads_json(item.get("factor_roles_json"), {})
side = normalize_trade_side(
item["entry_plan"].get("side")
or item["market_context"].get("side")
or item.get("direction")
or (item["strategy_snapshot"].get("direction") if isinstance(item.get("strategy_snapshot"), dict) else "")
)
item["side"] = side
item["side_label"] = side_label(side)
item["entry_plan"]["side"] = side
rec_result, rec_result_label = _classify_recommendation_result(item) rec_result, rec_result_label = _classify_recommendation_result(item)
item["recommendation_result"] = rec_result item["recommendation_result"] = rec_result
item["recommendation_result_label"] = rec_result_label item["recommendation_result_label"] = rec_result_label
@ -171,6 +181,12 @@ def get_active_recommendations(actionable_only: bool = False):
result = [] result = []
for row in rows: for row in rows:
item = _derive_execution_fields(dict(row)) item = _derive_execution_fields(dict(row))
entry_plan = _loads_json(item.get("entry_plan_json"), {})
side = normalize_trade_side(entry_plan.get("side") or item.get("direction"))
item["side"] = side
item["side_label"] = side_label(side)
item["entry_plan"] = entry_plan
item["entry_plan"]["side"] = side
if actionable_only and not _is_actionable_execution_status(item.get("execution_status")): if actionable_only and not _is_actionable_execution_status(item.get("execution_status")):
continue continue
result.append(item) result.append(item)
@ -260,6 +276,7 @@ def get_active_recommendations_deduped(
limit: int = 0, limit: int = 0,
offset: int = 0, offset: int = 0,
with_meta: bool = False, with_meta: bool = False,
side: str = "",
): ):
"""同 symbol 只保留最新 active 推荐,并附带派生执行状态。""" """同 symbol 只保留最新 active 推荐,并附带派生执行状态。"""
conn = get_conn() conn = get_conn()
@ -281,6 +298,10 @@ def get_active_recommendations_deduped(
if hours > 0: if hours > 0:
where += " AND rec_time >= %s" where += " AND rec_time >= %s"
params.append((datetime.now() - timedelta(hours=hours)).isoformat()) params.append((datetime.now() - timedelta(hours=hours)).isoformat())
side = normalize_trade_side(side) if side else ""
if side:
where += " AND direction=%s"
params.append("空头启动" if side == "short" else "多头启动")
try: try:
limit = max(0, int(limit or 0)) limit = max(0, int(limit or 0))
@ -323,7 +344,7 @@ def get_active_recommendations_deduped(
SELECT symbol, MAX(id) AS max_id SELECT symbol, MAX(id) AS max_id
FROM recommendation FROM recommendation
WHERE {where} WHERE {where}
GROUP BY symbol GROUP BY symbol, direction
) latest ON latest.max_id = r.id ) latest ON latest.max_id = r.id
ORDER BY r.rec_time DESC ORDER BY r.rec_time DESC
""", """,
@ -344,6 +365,8 @@ def get_active_recommendations_deduped(
"executable_now": 0, "executable_now": 0,
"planned_entry": 0, "planned_entry": 0,
"watch_pool": 0, "watch_pool": 0,
"long_count": 0,
"short_count": 0,
} }
now = datetime.now() now = datetime.now()
for row in rows: for row in rows:
@ -366,6 +389,10 @@ def get_active_recommendations_deduped(
if actionable_only and not _is_actionable_execution_status(item.get("execution_status")): if actionable_only and not _is_actionable_execution_status(item.get("execution_status")):
continue continue
all_items.append(item) all_items.append(item)
if item.get("side") == "short":
summary["short_count"] += 1
else:
summary["long_count"] += 1
if item.get("is_discovery_burst"): if item.get("is_discovery_burst"):
summary["discovery_burst"] += 1 summary["discovery_burst"] += 1
if item.get("is_executable_now"): if item.get("is_executable_now"):
@ -538,6 +565,8 @@ def get_opportunity_detail(symbol: str = "", rec_id: int = 0) -> dict | None:
"display_bucket": "watch_pool", "display_bucket": "watch_pool",
"observe_tier": "weak" if _safe_int(coin_state["score"]) < 4 else "strong", "observe_tier": "weak" if _safe_int(coin_state["score"]) < 4 else "strong",
"source": "coin_state", "source": "coin_state",
"side": "long",
"side_label": "",
} }
screening = [] screening = []

View File

@ -41,7 +41,13 @@ from app.config.config_loader import (
get_strategy_params, get_strategy_params,
) )
from app.core.opportunity_lifecycle import apply_entry_quality_gate from app.core.opportunity_lifecycle import apply_entry_quality_gate
from app.core.strategy_registry import BOX_RETEST_1H_STRATEGY, BOX_RETEST_4H_STRATEGY, MAIN_COMPOSITE_STRATEGY from app.core.strategy_registry import (
BOX_RETEST_1H_STRATEGY,
BOX_RETEST_4H_STRATEGY,
BREAKDOWN_RETEST_SHORT_1H_STRATEGY,
MAIN_COMPOSITE_STRATEGY,
)
from app.core.trade_direction import direction_label, normalize_trade_side
from app.core.opportunity_level import ( from app.core.opportunity_level import (
attach_opportunity_level, attach_opportunity_level,
classify_opportunity_level, classify_opportunity_level,
@ -60,6 +66,7 @@ from app.strategies.altcoin_breakout import (
build_volume_ignition_1h_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.strategies.box_retest_4h import build_box_retest_1h_signal, build_box_retest_4h_signal
from app.strategies.short_breakdown import build_breakdown_retest_short_1h_signal, detect_breakdown_retest_short_1h
from app.config.config_loader import _get_section as _get_cfg_section from app.config.config_loader import _get_section as _get_cfg_section
from app.core.pa_engine import ( from app.core.pa_engine import (
classify_candles, calc_atr, find_supply_demand_zones, classify_candles, calc_atr, find_supply_demand_zones,
@ -90,6 +97,7 @@ def _strategy_context_for_recommendation(symbol: str, result: dict, entry_plan:
"""Build and persist a standard strategy signal when an independent strategy matches.""" """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_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 {} bp_4h = result.get("box_breakout_pullback_4h") or (result.get("market_context") or {}).get("box_breakout_pullback_4h") or {}
short_1h = result.get("short_breakdown_retest_1h") or (result.get("market_context") or {}).get("short_breakdown_retest_1h") or {}
market_regime = result.get("market_regime") or (result.get("market_context") or {}).get("market_regime") or {} market_regime = result.get("market_regime") or (result.get("market_context") or {}).get("market_regime") or {}
signal_candidates = [] signal_candidates = []
signal_candidates.extend([ signal_candidates.extend([
@ -119,6 +127,17 @@ def _strategy_context_for_recommendation(symbol: str, result: dict, entry_plan:
decision_log=result.get("decision_log") or {}, decision_log=result.get("decision_log") or {},
) )
) )
if short_1h.get("detected"):
signal_candidates.append(
build_breakdown_retest_short_1h_signal(
symbol=symbol,
current_price=result.get("price") or 0,
detection=short_1h,
entry_plan=entry_plan or {},
market_regime=market_regime,
decision_log=result.get("decision_log") or {},
)
)
saved_payloads = [] saved_payloads = []
for signal in [item for item in signal_candidates if item]: for signal in [item for item in signal_candidates if item]:
saved_payloads.append(insert_strategy_signal(signal)) saved_payloads.append(insert_strategy_signal(signal))
@ -170,17 +189,20 @@ def symbol_recently_closed(symbol: str, hours: int = 8) -> bool:
return ((row[0] or 0) + (paper_row[0] or 0)) > 0 return ((row[0] or 0) + (paper_row[0] or 0)) > 0
def _active_recommendation_id(symbol: str) -> int: def _active_recommendation_id(symbol: str, side: str = "") -> int:
"""Return the current non-history recommendation id for merge/write diagnostics.""" """Return the current non-history recommendation id for merge/write diagnostics."""
conn = get_conn() conn = get_conn()
try: try:
row = conn.execute( row = conn.execute(
""" """
SELECT id FROM recommendation SELECT id FROM recommendation
WHERE symbol=%s AND status='active' AND COALESCE(display_bucket,'watch_pool') != 'history' WHERE symbol=%s
AND (%s = '' OR direction=%s)
AND status='active'
AND COALESCE(display_bucket,'watch_pool') != 'history'
ORDER BY id DESC LIMIT 1 ORDER BY id DESC LIMIT 1
""", """,
(symbol,), (symbol, direction_label(side) if side else "", direction_label(side) if side else ""),
).fetchone() ).fetchone()
return int(row["id"] if row else 0) return int(row["id"] if row else 0)
finally: finally:
@ -1165,6 +1187,13 @@ def confirm_burst(symbol, cand):
# 提取cand数据v1.7.0:用于辅助信号检测) # 提取cand数据v1.7.0:用于辅助信号检测)
cand_detail = json.loads(cand.get("detail_json", "{}")) cand_detail = json.loads(cand.get("detail_json", "{}"))
leader_status = cand.get("leader_status", "") leader_status = cand.get("leader_status", "")
trade_side = normalize_trade_side(
cand.get("side")
or cand.get("direction")
or cand_detail.get("side")
or (cand_detail.get("market_context") or {}).get("side")
or cand_detail.get("direction")
)
cand_change_24h = 0.0 cand_change_24h = 0.0
try: try:
cand_change_24h = float(cand.get("change_24h") or cand_detail.get("change_24h") or 0) cand_change_24h = float(cand.get("change_24h") or cand_detail.get("change_24h") or 0)
@ -1199,6 +1228,11 @@ def confirm_burst(symbol, cand):
bp_1h = {"detected": False} bp_1h = {"detected": False}
bp_4h = {"detected": False} bp_4h = {"detected": False}
bp_daily = {"detected": False} bp_daily = {"detected": False}
short_1h = (
cand_detail.get("short_breakdown_retest_1h")
or (cand_detail.get("market_context") or {}).get("short_breakdown_retest_1h")
or {"detected": False}
)
upstream_sector_context = cand_detail.get("sector_context") or {} upstream_sector_context = cand_detail.get("sector_context") or {}
if upstream_sector_context.get("hot_sectors") or upstream_sector_context.get("leader_symbol"): if upstream_sector_context.get("hot_sectors") or upstream_sector_context.get("leader_symbol"):
@ -1287,6 +1321,25 @@ def confirm_burst(symbol, cand):
if t and _safe_age_bars(bp_1h.get("pullback_age_bars")) <= 2: if t and _safe_age_bars(bp_1h.get("pullback_age_bars")) <= 2:
current_trigger_times.append(t) current_trigger_times.append(t)
if trade_side == "short":
try:
if not short_1h.get("detected") and h1_df is not None and len(h1_df) >= 60:
short_1h = detect_breakdown_retest_short_1h(h1_df, change_24h=cand_change_24h)
except Exception:
short_1h = {"detected": False}
if short_1h.get("detected"):
signals.extend(short_1h.get("signals") or ["1H破位反抽做空结构"])
score += factor_scorer.add_existing(
"breakdown_retest_1h_short",
short_1h.get("score", 0),
evidence="1H箱体下破后反抽失败",
value=short_1h,
cap=9,
)
t = _event_time_from_age(h1_df, short_1h.get("pullback_age_bars"))
if t and _safe_age_bars(short_1h.get("pullback_age_bars")) <= 2:
current_trigger_times.append(t)
# ---- PA引擎4H级别阻力/支撑) ---- # ---- PA引擎4H级别阻力/支撑) ----
pa_4h = full_pa_analysis(h4_df, "4h") if h4_df is not None and len(h4_df) >= 30 else {} pa_4h = full_pa_analysis(h4_df, "4h") if h4_df is not None and len(h4_df) >= 30 else {}
@ -1524,7 +1577,7 @@ def confirm_burst(symbol, cand):
) )
if not fresh_ok: if not fresh_ok:
signals.append("⛔ 候选过期无近6小时当前触发避免旧结构反复确认") signals.append("⛔ 候选过期无近6小时当前触发避免旧结构反复确认")
confirmed = fresh_ok and (vp_fly_count >= 1) confirmed = fresh_ok and ((vp_fly_count >= 1) if trade_side != "short" else bool(short_1h.get("detected") and short_1h.get("retest_rejected")))
if not confirmed and fresh_ok: if not confirmed and fresh_ok:
# 旧放量/旧起爆不能直接确认;但如果当前 15min/日线/结构给出强分, # 旧放量/旧起爆不能直接确认;但如果当前 15min/日线/结构给出强分,
# 允许作为“历史强背景 + 当前结构确认”。避免修复时效后把所有结构型机会一刀切为0。 # 允许作为“历史强背景 + 当前结构确认”。避免修复时效后把所有结构型机会一刀切为0。
@ -1671,7 +1724,57 @@ def confirm_burst(symbol, cand):
stop_cfg = confirm_stop_loss_params() stop_cfg = confirm_stop_loss_params()
if confirmed and atr_1h > 0: if confirmed and atr_1h > 0:
if entry_action == "即刻买入" and pa_15min_result: short_entry_plan = None
if trade_side == "short":
entry_action = "即刻买入" if short_1h.get("retest_rejected") else "等回踩"
retest_zone = short_1h.get("retest_zone") or []
if entry_action == "即刻买入":
entry_price = round(float(price), 6)
entry_method = "空头1H破位反抽失败当前可开空"
else:
zone_mid = sum(float(x or 0) for x in retest_zone[:2]) / 2 if len(retest_zone) >= 2 else float(short_1h.get("breakdown_level") or price)
entry_price = round(max(float(price), zone_mid), 6)
entry_method = f"等反抽到${entry_price:.4f}(箱体下沿反压)"
stop_loss = round(float(short_1h.get("stop_level") or price * 1.055), 6)
tp1 = round(float(short_1h.get("target_1") or max(price * 0.9, price - (stop_loss - price) * 1.8)), 6)
tp2 = round(max(tp1 * 0.94, price - (stop_loss - price) * 2.8), 6)
risk = stop_loss - price
reward1 = price - tp1
reward2 = price - tp2
rr1 = round(reward1 / risk, 2) if risk > 0 else 0
rr2 = round(reward2 / risk, 2) if risk > 0 else 0
level_meta = {
"opportunity_level": "structure_watch",
"opportunity_level_label": "结构机会",
"holding_horizon": "短线",
"entry_model": "1H箱体破位反抽",
"stop_model": "反抽高点上方",
"tp_model": "下方量度空间",
"max_action": "buy_now" if entry_action == "即刻买入" else "wait_pullback",
}
entry_plan = {
"side": "short",
"entry_price": entry_price,
"entry_method": entry_method,
"entry_action": entry_action,
"stop_loss": stop_loss,
"stop_pct": round(((stop_loss - price) / price) * 100, 1) if price > 0 else 0,
"tp1": tp1,
"tp2": tp2,
"rr1": rr1,
"rr2": rr2,
"atr_1h": round(float(atr_1h), 6),
"current_price": round(float(price), 6),
"risk_reward_ok": rr1 >= 1.3,
"pa_15min_summary": "空头结构以1H反抽失败为主15m仅作执行辅助",
"pa_1h_exhaustion": "low",
"stop_basis": "short_retest_high",
"tp_basis": "box_breakdown_measured_move",
}
entry_plan = attach_opportunity_level(entry_plan, level_meta)
short_entry_plan = dict(entry_plan)
gate_strategy_code = BREAKDOWN_RETEST_SHORT_1H_STRATEGY
elif entry_action == "即刻买入" and pa_15min_result:
entry_price = round(float(price), 6) entry_price = round(float(price), 6)
entry_method = "🟢15min即刻入场(突破进行中)" entry_method = "🟢15min即刻入场(突破进行中)"
elif entry_action == "等回踩" and pa_15min_result.get("wait_price", 0) > 0: elif entry_action == "等回踩" and pa_15min_result.get("wait_price", 0) > 0:
@ -1783,11 +1886,15 @@ def confirm_burst(symbol, cand):
} }
entry_plan = attach_opportunity_level(entry_plan, level_meta) entry_plan = attach_opportunity_level(entry_plan, level_meta)
gate_strategy_code = ( if trade_side == "short" and short_entry_plan is not None:
BOX_RETEST_1H_STRATEGY if bp_1h.get("detected") entry_plan = short_entry_plan
else BOX_RETEST_4H_STRATEGY if bp_4h.get("detected") gate_strategy_code = BREAKDOWN_RETEST_SHORT_1H_STRATEGY
else MAIN_COMPOSITE_STRATEGY else:
) gate_strategy_code = (
BOX_RETEST_1H_STRATEGY if bp_1h.get("detected")
else BOX_RETEST_4H_STRATEGY if bp_4h.get("detected")
else MAIN_COMPOSITE_STRATEGY
)
entry_plan.setdefault("strategy_code", gate_strategy_code) entry_plan.setdefault("strategy_code", gate_strategy_code)
# v1.7.5 买点质量闸门:确认强势 ≠ 允许现价追买。 # v1.7.5 买点质量闸门:确认强势 ≠ 允许现价追买。
@ -1870,6 +1977,8 @@ def confirm_burst(symbol, cand):
market_context["trigger_context"] = trigger_context market_context["trigger_context"] = trigger_context
market_context["box_breakout_pullback_1h"] = bp_1h if 'bp_1h' in locals() else {} market_context["box_breakout_pullback_1h"] = bp_1h if 'bp_1h' in locals() else {}
market_context["box_breakout_pullback_4h"] = bp_4h if 'bp_4h' in locals() else {} market_context["box_breakout_pullback_4h"] = bp_4h if 'bp_4h' in locals() else {}
market_context["short_breakdown_retest_1h"] = short_1h if 'short_1h' in locals() else {}
market_context["side"] = trade_side
market_context["factor_score_breakdown"] = factor_score_breakdown market_context["factor_score_breakdown"] = factor_score_breakdown
market_context["onchain_context"] = onchain_context market_context["onchain_context"] = onchain_context
market_context["market_regime"] = market_regime market_context["market_regime"] = market_regime
@ -1897,6 +2006,7 @@ def confirm_burst(symbol, cand):
}, },
) )
if entry_plan: if entry_plan:
entry_plan["side"] = trade_side
entry_plan["factor_score_breakdown"] = factor_score_breakdown entry_plan["factor_score_breakdown"] = factor_score_breakdown
entry_plan["onchain_context"] = onchain_context entry_plan["onchain_context"] = onchain_context
entry_plan["market_regime"] = market_regime entry_plan["market_regime"] = market_regime
@ -1917,6 +2027,8 @@ def confirm_burst(symbol, cand):
"pa_1d": pa_1d, "pa_1d": pa_1d,
"box_breakout_pullback_1h": bp_1h if 'bp_1h' in locals() else {}, "box_breakout_pullback_1h": bp_1h if 'bp_1h' in locals() else {},
"box_breakout_pullback_4h": bp_4h if 'bp_4h' in locals() else {}, "box_breakout_pullback_4h": bp_4h if 'bp_4h' in locals() else {},
"short_breakdown_retest_1h": short_1h if 'short_1h' in locals() else {},
"side": trade_side,
"m30_aligned": m30_aligned, "m30_aligned": m30_aligned,
"entry_action": (entry_plan or {}).get("entry_action") or entry_action, "entry_action": (entry_plan or {}).get("entry_action") or entry_action,
"market_context": market_context, "market_context": market_context,
@ -1937,9 +2049,11 @@ def _watch_candidate_plan(symbol, result, cand_detail):
market_context = result.get("market_context") or {} market_context = result.get("market_context") or {}
signals = list(result.get("signals") or []) signals = list(result.get("signals") or [])
price = float(result.get("price") or 0) price = float(result.get("price") or 0)
side = normalize_trade_side(result.get("side") or market_context.get("side") or cand_detail.get("side"))
level_meta = classify_opportunity_level( level_meta = classify_opportunity_level(
signals=signals, signals=signals,
entry_plan={ entry_plan={
"side": side,
"entry_action": "观察", "entry_action": "观察",
"entry_price": price, "entry_price": price,
"current_price": price, "current_price": price,
@ -1958,6 +2072,7 @@ def _watch_candidate_plan(symbol, result, cand_detail):
"max_action": "observe", "max_action": "observe",
} }
plan = { plan = {
"side": side,
"entry_action": "观察", "entry_action": "观察",
"entry_price": price, "entry_price": price,
"current_price": price, "current_price": price,
@ -2085,9 +2200,6 @@ def main(compact: bool = False, verbose: bool = False, limit: int | None = None,
# 飞书只是通知层:确认阶段不再绕过 recommendation 状态机直接推送。 # 飞书只是通知层:确认阶段不再绕过 recommendation 状态机直接推送。
# 先完成 create_recommendation + DB 状态派生,再用同一条状态结果决定是否通知。 # 先完成 create_recommendation + DB 状态派生,再用同一条状态结果决定是否通知。
# 🟢 只做做多!方向永远多头
rec_direction = get_strategy_direction()
# 🔴 v1.7.7 冷却期:刚止盈/止损的币不立即重新推荐 # 🔴 v1.7.7 冷却期:刚止盈/止损的币不立即重新推荐
# Sahara案例17:24止盈(+5%)→17:40重新推荐→现价跌-3% # Sahara案例17:24止盈(+5%)→17:40重新推荐→现价跌-3%
cooldown_hours = 8 if symbol_recently_closed(symbol, hours=8) else 0 cooldown_hours = 8 if symbol_recently_closed(symbol, hours=8) else 0
@ -2108,14 +2220,15 @@ def main(compact: bool = False, verbose: bool = False, limit: int | None = None,
continue continue
ep = result["entry_plan"] ep = result["entry_plan"]
rec_direction = direction_label(ep.get("side") or result.get("side"))
rec_entry_price = ep.get("entry_price") or result["price"] rec_entry_price = ep.get("entry_price") or result["price"]
if ep.get("entry_action") in ("等回踩", "观察") and result.get("price"): if normalize_trade_side(ep.get("side")) != "short" and ep.get("entry_action") in ("等回踩", "观察") and result.get("price"):
plan_stop = float(ep.get("stop_loss") or 0) plan_stop = float(ep.get("stop_loss") or 0)
plan_tp1 = float(ep.get("tp1") or 0) plan_tp1 = float(ep.get("tp1") or 0)
plan_entry = float(ep.get("entry_price") or 0) plan_entry = float(ep.get("entry_price") or 0)
if plan_entry > 0 and (plan_stop >= plan_entry or (plan_tp1 > 0 and plan_tp1 <= plan_entry)): if plan_entry > 0 and (plan_stop >= plan_entry or (plan_tp1 > 0 and plan_tp1 <= plan_entry)):
rec_entry_price = result["price"] rec_entry_price = result["price"]
previous_rec_id = _active_recommendation_id(symbol) previous_rec_id = _active_recommendation_id(symbol, ep.get("side") or result.get("side"))
strategy_ctx = _strategy_context_for_recommendation(symbol, result, ep) strategy_ctx = _strategy_context_for_recommendation(symbol, result, ep)
if strategy_ctx.get("strategy_code"): if strategy_ctx.get("strategy_code"):
ep["strategy_code"] = strategy_ctx["strategy_code"] ep["strategy_code"] = strategy_ctx["strategy_code"]
@ -2189,7 +2302,7 @@ def main(compact: bool = False, verbose: bool = False, limit: int | None = None,
signals=result.get("signals", []), signals=result.get("signals", []),
is_meme=int(is_meme_coin(symbol)), is_meme=int(is_meme_coin(symbol)),
entry_plan=watch_plan, entry_plan=watch_plan,
direction=get_strategy_direction(), direction=direction_label(watch_plan.get("side") or result.get("side")),
market_context=result.get("market_context"), market_context=result.get("market_context"),
derivatives_context=result.get("derivatives_context"), derivatives_context=result.get("derivatives_context"),
sector_context=result.get("sector_context"), sector_context=result.get("sector_context"),

View File

@ -67,6 +67,8 @@ from app.db.universe_audit import (
record_universe_decisions, record_universe_decisions,
) )
from app.db.short_tf_signals import record_short_tf_samples from app.db.short_tf_signals import record_short_tf_samples
from app.core.trade_direction import direction_label
from app.strategies.short_breakdown import detect_breakdown_retest_short_1h
exchange = ccxt.binance({"enableRateLimit": True}) exchange = ccxt.binance({"enableRateLimit": True})
REPO_ROOT = Path(__file__).resolve().parents[2] REPO_ROOT = Path(__file__).resolve().parents[2]
@ -1163,6 +1165,7 @@ def layer1_coarse_filter():
bb_data = None bb_data = None
static_accumulation = None static_accumulation = None
short_tf_ignition = None short_tf_ignition = None
short_breakdown_1h = {"detected": False}
# 1H量价齐飞检测核心 # 1H量价齐飞检测核心
kline_attempt_symbols.add(symbol) kline_attempt_symbols.add(symbol)
@ -1212,6 +1215,13 @@ def layer1_coarse_filter():
anomalies.append(f"1H放量({vp_data['max_vol_ratio']}x)但无量价齐飞(量价背离)") anomalies.append(f"1H放量({vp_data['max_vol_ratio']}x)但无量价齐飞(量价背离)")
anomaly_score += 1 # 量价背离最低分 anomaly_score += 1 # 量价背离最低分
short_breakdown_1h = detect_breakdown_retest_short_1h(h1_df, change_24h=change)
if short_breakdown_1h.get("detected"):
quality = float(short_breakdown_1h.get("quality") or 0)
if quality >= 0.48:
anomalies.extend(short_breakdown_1h.get("signals") or ["1H破位反抽做空结构"])
anomaly_score += int(short_breakdown_1h.get("score") or 4)
# 布林收窄检测4H级别 # 布林收窄检测4H级别
if h4_df is not None and len(h4_df) >= 20: if h4_df is not None and len(h4_df) >= 20:
bb_data = detect_bollinger_squeeze(h4_df) bb_data = detect_bollinger_squeeze(h4_df)
@ -1307,6 +1317,7 @@ def layer1_coarse_filter():
"bb_data": bb_data, "bb_data": bb_data,
"static_accumulation": static_accumulation, "static_accumulation": static_accumulation,
"short_tf_ignition": short_tf_ignition, "short_tf_ignition": short_tf_ignition,
"short_breakdown_retest_1h": short_breakdown_1h,
"h4_df": h4_df, "h4_df": h4_df,
"turnover_acceleration_1h": turnover_acc_1h, "turnover_acceleration_1h": turnover_acc_1h,
"turnover_acceleration_4h": turnover_acc_4h, "turnover_acceleration_4h": turnover_acc_4h,
@ -1838,15 +1849,15 @@ def layer2_fine_filter(candidates):
leader_symbol = info["leader"] leader_symbol = info["leader"]
leader_pct = info.get("leader_pct", 0) leader_pct = info.get("leader_pct", 0)
# 🟢 只做做多!空头信号只记录不加分,方向永远多头 side = "short" if (cand.get("short_breakdown_retest_1h") or {}).get("detected") else "long"
# 空头起爆/空头加速只是衰减参考,不生成推荐 direction = direction_label(side)
direction = get_strategy_direction() direction_num = -1 if side == "short" else 1
direction_num = 1
qualified[symbol] = { qualified[symbol] = {
"state": state, "state": state,
"score": score, "score": score,
"signals": signals, "signals": signals,
"side": side,
"direction": direction, "direction": direction,
"direction_num": direction_num, "direction_num": direction_num,
"sector": sector_str, "sector": sector_str,
@ -1870,6 +1881,8 @@ def layer2_fine_filter(candidates):
"trigger_context": {"trigger_status": _build_signal_recency(cand).get("status"), "current_triggers": _build_signal_recency(cand).get("current"), "stale_background": _build_signal_recency(cand).get("stale")}, "trigger_context": {"trigger_status": _build_signal_recency(cand).get("status"), "current_triggers": _build_signal_recency(cand).get("current"), "stale_background": _build_signal_recency(cand).get("stale")},
"turnover_acceleration_1h": cand.get("turnover_acceleration_1h"), "turnover_acceleration_1h": cand.get("turnover_acceleration_1h"),
"turnover_acceleration_4h": cand.get("turnover_acceleration_4h"), "turnover_acceleration_4h": cand.get("turnover_acceleration_4h"),
"side": side,
"short_breakdown_retest_1h": cand.get("short_breakdown_retest_1h") or {},
}, },
"derivatives_context": { "derivatives_context": {
"funding_rate": cand.get("funding_rate"), "funding_rate": cand.get("funding_rate"),

View File

@ -36,6 +36,8 @@ from app.core.pa_engine import (
) )
from app.config.config_loader import load_rules from app.config.config_loader import load_rules
from app.core.opportunity_lifecycle import apply_entry_quality_gate from app.core.opportunity_lifecycle import apply_entry_quality_gate
from app.core.trade_math import pnl_pct as side_pnl_pct, should_stop_loss, should_take_profit
from app.core.trade_direction import normalize_trade_side
from app.db.paper_trading import sync_recommendation as sync_paper_trade from app.db.paper_trading import sync_recommendation as sync_paper_trade
exchange = ccxt.binance({"enableRateLimit": True}) exchange = ccxt.binance({"enableRateLimit": True})
@ -116,6 +118,7 @@ def analyze_tracking_signals(symbol, rec, current_price):
tp1 = rec.get("tp1", entry_price * 1.03) tp1 = rec.get("tp1", entry_price * 1.03)
tp2 = rec.get("tp2", entry_price * 1.05) tp2 = rec.get("tp2", entry_price * 1.05)
entry_plan = rec.get("entry_plan") or {} entry_plan = rec.get("entry_plan") or {}
side = normalize_trade_side(entry_plan.get("side") or rec.get("side") or rec.get("direction"))
# ---- 拉取1H数据做趋势分析 ---- # ---- 拉取1H数据做趋势分析 ----
h1_df = fetch_klines(symbol, "1h", limit=100) h1_df = fetch_klines(symbol, "1h", limit=100)
@ -138,10 +141,10 @@ def analyze_tracking_signals(symbol, rec, current_price):
sell_signals.append("趋势中度衰减,关注止盈") sell_signals.append("趋势中度衰减,关注止盈")
# ---- 止盈信号检测 ---- # ---- 止盈信号检测 ----
pnl_pct = ((current_price / entry_price) - 1) * 100 if entry_price > 0 else 0 pnl_pct = side_pnl_pct(side, entry_price, current_price) if entry_price > 0 else 0
# TP/SL 是策略交易生命周期,不再写成推荐信号动作。 # TP/SL 是策略交易生命周期,不再写成推荐信号动作。
if tp1 > 0 and current_price >= tp1: if tp1 > 0 and should_take_profit(side, current_price, tp1):
sell_signals.append(f"策略交易目标价已到达(${tp1:.4f}),执行结果以交易账本为准") sell_signals.append(f"策略交易目标价已到达(${tp1:.4f}),执行结果以交易账本为准")
rules = load_rules() rules = load_rules()
@ -151,11 +154,12 @@ def analyze_tracking_signals(symbol, rec, current_price):
# ---- 止损接近警告 ---- # ---- 止损接近警告 ----
if stop_loss > 0: if stop_loss > 0:
loss_pct = ((current_price / stop_loss) - 1) * 100 loss_pct = ((current_price / stop_loss) - 1) * 100 if side != "short" else ((stop_loss / current_price) - 1) * 100
if loss_pct < 3: # 当前价离止损不到3% if loss_pct < 3: # 当前价离止损不到3%
sell_signals.append(f"⚠️ 接近止损!当前${current_price:.4f}离止损${stop_loss:.4f}{loss_pct:.1f}%") sell_signals.append(f"⚠️ 接近止损!当前${current_price:.4f}离止损${stop_loss:.4f}{loss_pct:.1f}%")
if current_price <= stop_loss: if should_stop_loss(side, current_price, stop_loss):
sell_signals.append(f"🔴 策略交易止损价已触达!${current_price:.4f}≤${stop_loss:.4f},执行结果以交易账本为准") op = "" if side == "short" else ""
sell_signals.append(f"🔴 策略交易止损价已触达!${current_price:.4f}{op}${stop_loss:.4f},执行结果以交易账本为准")
# ---- 趋势反转信号PA行为检测替代MACD ---- # ---- 趋势反转信号PA行为检测替代MACD ----
if h1_df is not None and len(h1_df) >= 30 and atr_1h > 0: if h1_df is not None and len(h1_df) >= 30 and atr_1h > 0:
@ -214,7 +218,7 @@ def analyze_tracking_signals(symbol, rec, current_price):
if action_status == "持有" and current_action in ("等回踩", "🟡等回踩") and h4_df is not None and atr_1h > 0: if action_status == "持有" and current_action in ("等回踩", "🟡等回踩") and h4_df is not None and atr_1h > 0:
pa_4h = full_pa_analysis(h4_df, "4h") pa_4h = full_pa_analysis(h4_df, "4h")
h4_zones = pa_4h.get("zones", []) h4_zones = pa_4h.get("zones", [])
direction = 1 # 做多方向 direction = -1 if side == "short" else 1
# 重新做15min入场点分析 # 重新做15min入场点分析
if m15_df is not None and len(m15_df) >= 20: if m15_df is not None and len(m15_df) >= 20:
@ -225,15 +229,15 @@ def analyze_tracking_signals(symbol, rec, current_price):
new_action = entry_result.get("action", "等回踩") new_action = entry_result.get("action", "等回踩")
if new_action == "即刻买入": if new_action == "即刻买入":
buy_signals.append(f"🟢 回踩确认完毕!可即刻入场(15min动K确认)") buy_signals.append("🟢 反抽确认完毕!可开空(15min动K确认)" if side == "short" else "🟢 回踩确认完毕!可即刻入场(15min动K确认)")
action_status = "可即刻买入" action_status = "可即刻买入"
elif new_action == "等回踩": elif new_action == "等回踩":
wait_price = entry_result.get("wait_price", 0) wait_price = entry_result.get("wait_price", 0)
if wait_price > 0: if wait_price > 0:
# 检查当前价是否接近回踩目标 # 检查当前价是否接近回踩目标
dist_pct = ((current_price / wait_price) - 1) * 100 dist_pct = ((current_price / wait_price) - 1) * 100 if side != "short" else ((wait_price / current_price) - 1) * 100
if abs(dist_pct) < 2: if abs(dist_pct) < 2:
buy_signals.append(f"🟢 当前价接近回踩目标!${current_price:.4f}≈${wait_price:.4f}") buy_signals.append(f"🟢 当前价接近反抽目标!${current_price:.4f}≈${wait_price:.4f}" if side == "short" else f"🟢 当前价接近回踩目标!${current_price:.4f}≈${wait_price:.4f}")
action_status = "可即刻买入" action_status = "可即刻买入"
entry_update = { entry_update = {

View File

@ -20,6 +20,71 @@ def _safe_float(value, default=0.0) -> float:
return default return default
def detect_breakdown_retest_short_1h(df, *, change_24h: float = 0.0) -> dict:
"""Detect a 1H breakdown followed by a failed retest of the broken box.
This is intentionally conservative: the setup needs a prior box, a close
below support, a retest near the broken support, and rejection back below it.
"""
if df is None or len(df) < 60:
return {"detected": False, "reason": "insufficient_data"}
try:
work = df.tail(72).copy()
prev = work.iloc[:-8]
recent = work.iloc[-8:]
current = float(work["close"].iloc[-1])
box_low = float(prev["low"].tail(48).min())
box_high = float(prev["high"].tail(48).max())
if current <= 0 or box_low <= 0 or box_high <= box_low:
return {"detected": False, "reason": "invalid_box"}
box_width_pct = (box_high / box_low - 1) * 100
if box_width_pct > 38:
return {"detected": False, "reason": "box_too_wide", "box_width_pct": round(box_width_pct, 2)}
closes = [float(x) for x in recent["close"].tolist()]
highs = [float(x) for x in recent["high"].tolist()]
opens = [float(x) for x in recent["open"].tolist()]
breakdown_closes = [x for x in closes if x < box_low * 0.992]
touched_retest = any(box_low * 0.988 <= h <= box_low * 1.035 for h in highs[-5:])
latest_rejected = current < box_low * 0.992 and closes[-1] < opens[-1]
below_ema = current < float(work["close"].ewm(span=25, adjust=False).mean().iloc[-1])
if not breakdown_closes or not touched_retest:
return {
"detected": False,
"reason": "no_breakdown_retest",
"box_low": round(box_low, 8),
"box_high": round(box_high, 8),
}
stop_level = max(max(highs[-5:]) * 1.012, box_low * 1.025)
risk = max(stop_level - current, current * 0.025)
target_1 = max(current - risk * 1.8, current * 0.72)
quality_score = 0
quality_score += 3 if latest_rejected else 1
quality_score += 2 if below_ema else 0
quality_score += 2 if float(change_24h or 0) <= -3 else 0
quality_score += 1 if box_width_pct <= 24 else 0
quality = "优质" if quality_score >= 7 else "良好" if quality_score >= 5 else "普通"
return {
"detected": True,
"breakdown_level": round(box_low, 8),
"box_high": round(box_high, 8),
"box_width_pct": round(box_width_pct, 2),
"retest_zone": round(box_low, 8),
"stop_level": round(stop_level, 8),
"target_1": round(target_1, 8),
"quality": quality,
"score": quality_score,
"retest_rejected": latest_rejected,
"relative_weakness": bool(below_ema or float(change_24h or 0) <= -3),
"pullback_age_bars": 0,
"signals": [
f"1H破位反抽做空(破位{box_low:.6g})",
"反抽失败确认" if latest_rejected else "等待反抽失败确认",
],
}
except Exception as exc:
return {"detected": False, "reason": f"error:{exc}"}
def build_breakdown_retest_short_1h_signal( def build_breakdown_retest_short_1h_signal(
*, *,
symbol: str, symbol: str,
@ -110,4 +175,4 @@ def build_breakdown_retest_short_1h_signal(
) )
__all__ = ["build_breakdown_retest_short_1h_signal"] __all__ = ["build_breakdown_retest_short_1h_signal", "detect_breakdown_retest_short_1h"]

View File

@ -76,6 +76,7 @@ async def api_recommendations(
archive_filter: str = "", archive_filter: str = "",
paged: bool = False, paged: bool = False,
compact: bool = False, compact: bool = False,
side: str = "",
altcoin_session: str = Cookie(default=""), altcoin_session: str = Cookie(default=""),
): ):
require_api_user_with_subscription(altcoin_session) require_api_user_with_subscription(altcoin_session)
@ -99,6 +100,7 @@ async def api_recommendations_active(
offset: int = 0, offset: int = 0,
paged: bool = False, paged: bool = False,
compact: bool = False, compact: bool = False,
side: str = "",
altcoin_session: str = Cookie(default=""), altcoin_session: str = Cookie(default=""),
): ):
require_api_user_with_subscription(altcoin_session) require_api_user_with_subscription(altcoin_session)
@ -110,6 +112,7 @@ async def api_recommendations_active(
limit=limit, limit=limit,
offset=offset, offset=offset,
with_meta=(paged or compact), with_meta=(paged or compact),
side=side,
) )
return get_active_recommendations(actionable_only=actionable_only) return get_active_recommendations(actionable_only=actionable_only)

View File

@ -97,6 +97,9 @@
.summary-tags { display:flex; align-items:center; justify-content:space-between; gap:10px; border-top:1px solid var(--hairline-soft); padding-top:10px; } .summary-tags { display:flex; align-items:center; justify-content:space-between; gap:10px; border-top:1px solid var(--hairline-soft); padding-top:10px; }
.summary-chips { display:flex; gap:5px; flex-wrap:wrap; min-width:0; } .summary-chips { display:flex; gap:5px; flex-wrap:wrap; min-width:0; }
.summary-chip { display:inline-flex; border:1px solid var(--hairline-soft); background:var(--surface); border-radius:999px; padding:4px 8px; color:var(--slate); font-size:11px; font-weight:850; max-width:150px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } .summary-chip { display:inline-flex; border:1px solid var(--hairline-soft); background:var(--surface); border-radius:999px; padding:4px 8px; color:var(--slate); font-size:11px; font-weight:850; max-width:150px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.side-badge { display:inline-flex; align-items:center; border-radius:999px; padding:3px 8px; font-size:11px; font-weight:950; border:1px solid var(--hairline-soft); }
.side-badge.long { color:var(--green); background:rgba(0,180,115,.08); border-color:rgba(0,180,115,.18); }
.side-badge.short { color:var(--red); background:rgba(238,61,76,.08); border-color:rgba(238,61,76,.18); }
.detail-link { color:var(--blue); font-size:12px; font-weight:950; white-space:nowrap; } .detail-link { color:var(--blue); font-size:12px; font-weight:950; white-space:nowrap; }
.card-bar { display: flex; align-items: center; justify-content: space-between; padding: 16px 18px 0; gap: 8px; } .card-bar { display: flex; align-items: center; justify-content: space-between; padding: 16px 18px 0; gap: 8px; }
.coin-left { display: flex; align-items: center; gap: 10px; min-width: 0; } .coin-left { display: flex; align-items: center; gap: 10px; min-width: 0; }
@ -379,6 +382,7 @@ var curTab = 'live';
var latestVersion = ''; var latestVersion = '';
var currentVersion = ''; var currentVersion = '';
var currentFilter = ''; var currentFilter = '';
var currentSideFilter = '';
var cachedLiveData = []; var cachedLiveData = [];
var liveOffset = 0; var liveOffset = 0;
var liveLimit = 24; var liveLimit = 24;
@ -658,6 +662,17 @@ function opportunityUrl(r) {
function goOpportunity(r) { function goOpportunity(r) {
location.href = opportunityUrl(r); location.href = opportunityUrl(r);
} }
function recSide(r) {
var side = String((r && (r.side || (r.entry_plan && r.entry_plan.side))) || '').toLowerCase();
return side === 'short' ? 'short' : 'long';
}
function sideText(r) { return recSide(r) === 'short' ? '空' : '多'; }
function sideBadgeHtml(r) { var s = recSide(r); return '<span class="side-badge '+s+'">'+(s === 'short' ? '空' : '多')+'</span>'; }
function sideChangePct(price, ref, side) {
price = Number(price || 0); ref = Number(ref || 0);
if (!price || !ref) return null;
return side === 'short' ? ((ref - price) / ref * 100) : ((price - ref) / ref * 100);
}
// ====== LIVE ====== // ====== LIVE ======
function isExpiredRec(r) { function isExpiredRec(r) {
@ -673,7 +688,7 @@ async function loadContent(reset) {
liveLoading = true; liveLoading = true;
try { try {
var offset = (reset === false) ? liveOffset : 0; var offset = (reset === false) ? liveOffset : 0;
var url = API+'/api/recommendations/active?with_tracking=true&actionable_only=false&hours=12&limit='+liveLimit+'&offset='+offset+'&compact=true'; var url = API+'/api/recommendations/active?with_tracking=true&actionable_only=false&hours=12&limit='+liveLimit+'&offset='+offset+'&compact=true'+(currentSideFilter ? '&side='+encodeURIComponent(currentSideFilter) : '');
var resp = await fetch(url); var resp = await fetch(url);
var page = await resp.json(); var page = await resp.json();
var items = Array.isArray(page.items) ? page.items : (Array.isArray(page) ? page : []); var items = Array.isArray(page.items) ? page.items : (Array.isArray(page) ? page : []);
@ -744,6 +759,7 @@ function isRenderableLiveRec(r) {
} }
function applyFilterAndRender() { function applyFilterAndRender() {
var visible = cachedLiveData.filter(function(r){ return isRenderableLiveRec(r); }); var visible = cachedLiveData.filter(function(r){ return isRenderableLiveRec(r); });
if (currentSideFilter) visible = visible.filter(function(r){ return recSide(r) === currentSideFilter; });
var weakCount = visible.filter(isWeakObserveRec).length; var weakCount = visible.filter(isWeakObserveRec).length;
var filtered = visible.filter(function(r){ return !isWeakObserveRec(r); }); var filtered = visible.filter(function(r){ return !isWeakObserveRec(r); });
if (!currentFilter) { if (!currentFilter) {
@ -765,6 +781,11 @@ function setFilter(status) {
refreshVisibleKlines(); refreshVisibleKlines();
} }
function setSideFilter(side) {
currentSideFilter = side || '';
loadContent(true);
}
function renderLiveStats(data) { function renderLiveStats(data) {
var visible = []; var visible = [];
try { try {
@ -776,16 +797,23 @@ function renderLiveStats(data) {
var buy = visible.filter(function(r){ return r.execution_status === 'buy_now' || r.display_bucket === 'realtime'; }).length; var buy = visible.filter(function(r){ return r.execution_status === 'buy_now' || r.display_bucket === 'realtime'; }).length;
var observeStrong = visible.filter(function(r){ return (r.display_bucket === 'watch_pool' || (r.execution_status !== 'buy_now' && r.display_bucket !== 'realtime')) && r.observe_tier !== 'weak'; }).length; var observeStrong = visible.filter(function(r){ return (r.display_bucket === 'watch_pool' || (r.execution_status !== 'buy_now' && r.display_bucket !== 'realtime')) && r.observe_tier !== 'weak'; }).length;
var observeWeak = visible.filter(function(r){ return r.observe_tier === 'weak'; }).length; var observeWeak = visible.filter(function(r){ return r.observe_tier === 'weak'; }).length;
var longCount = visible.filter(function(r){ return recSide(r) === 'long'; }).length;
var shortCount = visible.filter(function(r){ return recSide(r) === 'short'; }).length;
var allCls = 'stat-chip' + (!currentFilter ? ' filterable active' : ' filterable'); var allCls = 'stat-chip' + (!currentFilter ? ' filterable active' : ' filterable');
var bCls = 'stat-chip' + (currentFilter === 'buy_now' ? ' filterable active' : ' filterable'); var bCls = 'stat-chip' + (currentFilter === 'buy_now' ? ' filterable active' : ' filterable');
var oCls = 'stat-chip observe-chip' + (currentFilter === 'observe' ? ' filterable active' : ' filterable'); var oCls = 'stat-chip observe-chip' + (currentFilter === 'observe' ? ' filterable active' : ' filterable');
var wCls = 'stat-chip weak-chip' + (currentFilter === 'weak_observe' ? ' filterable active' : ' filterable'); var wCls = 'stat-chip weak-chip' + (currentFilter === 'weak_observe' ? ' filterable active' : ' filterable');
var lCls = 'stat-chip' + (currentSideFilter === 'long' ? ' filterable active' : ' filterable');
var sCls = 'stat-chip' + (currentSideFilter === 'short' ? ' filterable active' : ' filterable');
$('liveStats').innerHTML = $('liveStats').innerHTML =
'<div class="stats-main">' + '<div class="stats-main">' +
'<div class="'+allCls+'" onclick="setFilter(\'\')"><span class="dot all"></span><span>全部候选</span><span class="val">'+total+'</span></div>' + '<div class="'+allCls+'" onclick="setFilter(\'\')"><span class="dot all"></span><span>全部候选</span><span class="val">'+total+'</span></div>' +
'<div class="'+bCls+'" onclick="setFilter(\'buy_now\')"><span class="dot buy"></span><span>入场窗口</span><span class="val">'+buy+'</span></div>' + '<div class="'+bCls+'" onclick="setFilter(\'buy_now\')"><span class="dot buy"></span><span>入场窗口</span><span class="val">'+buy+'</span></div>' +
'<div class="'+oCls+'" onclick="setFilter(\'observe\')"><span class="dot obs"></span><span>重点观察</span><span class="val">'+observeStrong+'</span></div>' + '<div class="'+oCls+'" onclick="setFilter(\'observe\')"><span class="dot obs"></span><span>重点观察</span><span class="val">'+observeStrong+'</span></div>' +
'<div class="'+wCls+'" onclick="setFilter(\'weak_observe\')"><span class="dot weak"></span><span>弱观察</span><span class="val">'+observeWeak+'</span></div>' + '<div class="'+wCls+'" onclick="setFilter(\'weak_observe\')"><span class="dot weak"></span><span>弱观察</span><span class="val">'+observeWeak+'</span></div>' +
'<div class="'+lCls+'" onclick="setSideFilter(\'long\')"><span class="dot buy"></span><span>做多</span><span class="val">'+longCount+'</span></div>' +
'<div class="'+sCls+'" onclick="setSideFilter(\'short\')"><span class="dot weak"></span><span>做空</span><span class="val">'+shortCount+'</span></div>' +
'<div class="stat-chip filterable" onclick="setSideFilter(\'\')"><span class="dot all"></span><span>全部方向</span></div>' +
'</div>'; '</div>';
} }
@ -844,6 +872,7 @@ function renderRecCard(r) {
return (r.observe_tier === 'weak') ? {label:'弱观察', cls:'weak', short:'弱观察'} : {label:'观察中', cls:'obs', short:'观察'}; return (r.observe_tier === 'weak') ? {label:'弱观察', cls:'weak', short:'弱观察'} : {label:'观察中', cls:'obs', short:'观察'};
} }
var ep = r.entry_plan || {}; var ep = r.entry_plan || {};
var side = recSide(r);
var levelKey = r.opportunity_level || ep.opportunity_level || 'structure_watch'; var levelKey = r.opportunity_level || ep.opportunity_level || 'structure_watch';
var levelLabel = r.opportunity_level_label || ep.opportunity_level_label || '结构观察'; var levelLabel = r.opportunity_level_label || ep.opportunity_level_label || '结构观察';
var horizon = r.holding_horizon || ep.holding_horizon || ''; var horizon = r.holding_horizon || ep.holding_horizon || '';
@ -854,6 +883,10 @@ function renderRecCard(r) {
var entryMethod = ep.entry_method || ''; var entryMethod = ep.entry_method || '';
var signalText = sigs.join(' '); var signalText = sigs.join(' ');
var phase = opportunityPhase(r, entryMethod, signalText); var phase = opportunityPhase(r, entryMethod, signalText);
if (side === 'short') {
if (phase.cls === 'buy') phase.label = '做空窗口';
if (phase.cls === 'wait') phase.label = '等反抽';
}
var isBuy = r.execution_status === 'buy_now' || r.display_bucket === 'realtime', isWait = r.execution_status === 'wait_pullback' || r.lifecycle_state === 'waiting_entry', isWeakObserve = r.observe_tier === 'weak'; var isBuy = r.execution_status === 'buy_now' || r.display_bucket === 'realtime', isWait = r.execution_status === 'wait_pullback' || r.lifecycle_state === 'waiting_entry', isWeakObserve = r.observe_tier === 'weak';
var isExecuted = !!r.entry_triggered || r.display_bucket === 'position' || r.execution_status === 'holding' || r.execution_status === 'completed'; var isExecuted = !!r.entry_triggered || r.display_bucket === 'position' || r.execution_status === 'holding' || r.execution_status === 'completed';
var isTradePlan = isBuy || isWait || isExecuted || r.entry_triggered; var isTradePlan = isBuy || isWait || isExecuted || r.entry_triggered;
@ -890,19 +923,19 @@ function renderRecCard(r) {
return '<span class="sig '+cls+'">'+displaySignalText(s)+'</span>'; return '<span class="sig '+cls+'">'+displaySignalText(s)+'</span>';
}).join(''); }).join('');
var score = r.rec_score||0, st = scoreTier(score), ver = r.strategy_version||''; 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箱体突破回踩',volume_ignition_1h_v1:'1H放量突破启动',compression_breakout_4h_v1:'4H压缩蓄力突破',intraday_momentum_15m_v1:'15m日内动量延续'}[r.strategy_code||''] || r.strategy_code || ''); var strategyLabel = r.strategy_name || ({main_composite_v1:'综合确认策略',box_retest_1h_v1:'1H箱体突破回踩',box_retest_4h_v1:'4H箱体突破回踩',breakdown_retest_short_1h_v1:'1H破位反抽做空',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 hasQualityGate = ep.entry_quality_gate && Array.isArray(ep.entry_quality_gate.reasons) && ep.entry_quality_gate.reasons.length;
var entryLabel = isWait ? '回踩参考' : (hasQualityGate ? '失效参考' : '参考价位'); var entryLabel = isWait ? (side === 'short' ? '反抽参考' : '回踩参考') : (hasQualityGate ? '失效参考' : '参考价位');
var entryRef = (isWait || hasQualityGate) ? (ep.entry_price || r.entry_price || 0) : (r.entry_price || ep.entry_price || 0); var entryRef = (isWait || hasQualityGate) ? (ep.entry_price || r.entry_price || 0) : (r.entry_price || ep.entry_price || 0);
var changeRef = entryRef || r.entry_price || 0; var changeRef = entryRef || r.entry_price || 0;
var changeLabel = isExecuted ? '持仓盈亏' : (isWait ? '较回踩参考' : (isBuy ? '较触发价' : '较参考价')); var changeLabel = isExecuted ? '持仓盈亏' : (isWait ? (side === 'short' ? '较反抽参考' : '较回踩参考') : (isBuy ? '较触发价' : '较参考价'));
var changePct = price && changeRef ? ((price - changeRef) / changeRef * 100) : null; var changePct = sideChangePct(price, changeRef, side);
var changeCls = changePct!=null?(changePct>0?'up':changePct<0?'down':'zero'):'zero'; var changeCls = changePct!=null?(changePct>0?'up':changePct<0?'down':'zero'):'zero';
var changeSign = changePct!=null&&changePct>0?'+':''; var changeSign = changePct!=null&&changePct>0?'+':'';
var changeHtml = changePct!=null ? '<span class="price-change '+changeCls+'" title="当前价相对'+changeLabel+'不是24h涨跌"><span class="pc-label">'+changeLabel+'</span><span class="pc-value">'+changeSign+changePct.toFixed(1)+'%</span></span>' : ''; var changeHtml = changePct!=null ? '<span class="price-change '+changeCls+'" title="当前价相对'+changeLabel+'不是24h涨跌"><span class="pc-label">'+changeLabel+'</span><span class="pc-value">'+changeSign+changePct.toFixed(1)+'%</span></span>' : '';
var riskLine = ep.stop_loss || r.stop_loss || 0; var riskLine = ep.stop_loss || r.stop_loss || 0;
var spaceRef = ep.tp1 || r.tp1 || 0; var spaceRef = ep.tp1 || r.tp1 || 0;
var upsidePct = entryRef && spaceRef ? ((spaceRef / entryRef - 1) * 100) : 0; var upsidePct = entryRef && spaceRef ? (side === 'short' ? ((entryRef - spaceRef) / entryRef * 100) : ((spaceRef / entryRef - 1) * 100)) : 0;
function entryWindowSummary() { function entryWindowSummary() {
var w = r.entry_window || {}; var w = r.entry_window || {};
if (!isBuy || !w.status) return ''; if (!isBuy || !w.status) return '';
@ -914,9 +947,9 @@ function renderRecCard(r) {
} }
var weakNoteHtml = isWeakObserve ? '<div class="weak-note">'+cleanDisplayText(r.observe_reason || '信号强度不足,仅保留为低优先级观察,不构成实时机会。')+'</div>' : ''; var weakNoteHtml = isWeakObserve ? '<div class="weak-note">'+cleanDisplayText(r.observe_reason || '信号强度不足,仅保留为低优先级观察,不构成实时机会。')+'</div>' : '';
var decisionCls = isBuy ? 'buy' : (isWait ? 'wait' : (isWeakObserve ? 'weak' : 'observe')); var decisionCls = isBuy ? 'buy' : (isWait ? 'wait' : (isWeakObserve ? 'weak' : 'observe'));
var decisionTitle = isBuy ? '现在可买' : (isWait ? '等回踩,不追高' : (isWeakObserve ? '弱观察' : '观察')); var decisionTitle = isBuy ? (side === 'short' ? '现在可空' : '现在可买') : (isWait ? (side === 'short' ? '等反抽,不追空' : '等回踩,不追高') : (isWeakObserve ? '弱观察' : '观察'));
var decisionFocus = isBuy ? ('现价 '+fmtP(price)) : (isWait ? ('参考 '+fmtP(entryRef)) : (isWeakObserve ? '低优先级观察' : '等待确认')); var decisionFocus = isBuy ? ('现价 '+fmtP(price)) : (isWait ? ('参考 '+fmtP(entryRef)) : (isWeakObserve ? '低优先级观察' : '等待确认'));
var decisionReason = cleanDisplayText(isBuy ? (entryWindowSummary() || r.execution_reason || '入场窗口有效') : (r.execution_reason || (isWait ? '当前不追,等待回踩价附近再评估' : (r.observe_reason || r.state_reason || '未形成入场窗口')))); var decisionReason = cleanDisplayText(isBuy ? (entryWindowSummary() || r.execution_reason || (side === 'short' ? '做空窗口有效' : '入场窗口有效')) : (r.execution_reason || (isWait ? (side === 'short' ? '当前不追空,等待反抽价附近再评估' : '当前不追,等待回踩价附近再评估') : (r.observe_reason || r.state_reason || '未形成入场窗口'))));
var decisionHtml = '<div class="decision-strip '+decisionCls+'"><div class="decision-head"><span class="decision-label">当前结论</span><span class="decision-title">'+decisionTitle+'</span></div><div class="decision-body"><span class="decision-focus">'+decisionFocus+'</span><span class="decision-reason">'+decisionReason+'</span></div></div>'; var decisionHtml = '<div class="decision-strip '+decisionCls+'"><div class="decision-head"><span class="decision-label">当前结论</span><span class="decision-title">'+decisionTitle+'</span></div><div class="decision-body"><span class="decision-focus">'+decisionFocus+'</span><span class="decision-reason">'+decisionReason+'</span></div></div>';
var aiInsightHtml = ''; var aiInsightHtml = '';
var aiInsight = r.llm_insight && r.llm_insight.content ? r.llm_insight.content : null; var aiInsight = r.llm_insight && r.llm_insight.content ? r.llm_insight.content : null;
@ -951,7 +984,7 @@ function renderRecCard(r) {
var signalLevelHtml = '<div class="signal-level-strip '+cleanDisplayText(levelKey)+'"><div class="signal-level-title"><span class="signal-level-dot"></span><div><span class="signal-level-k">机会级别</span><span class="signal-level-v">'+cleanDisplayText(levelLabel)+'</span><span class="signal-level-sub">'+cleanDisplayText(horizon || levelFrameText(levelKey))+'</span></div></div><div><span class="signal-level-k">触发门槛</span><span class="signal-level-v">'+cleanDisplayText(entryModel || '等待当前触发')+'</span><span class="signal-level-sub">'+cleanDisplayText(levelBasis || phase.short || '当前触发 + 风险边界')+'</span></div></div>'; var signalLevelHtml = '<div class="signal-level-strip '+cleanDisplayText(levelKey)+'"><div class="signal-level-title"><span class="signal-level-dot"></span><div><span class="signal-level-k">机会级别</span><span class="signal-level-v">'+cleanDisplayText(levelLabel)+'</span><span class="signal-level-sub">'+cleanDisplayText(horizon || levelFrameText(levelKey))+'</span></div></div><div><span class="signal-level-k">触发门槛</span><span class="signal-level-v">'+cleanDisplayText(entryModel || '等待当前触发')+'</span><span class="signal-level-sub">'+cleanDisplayText(levelBasis || phase.short || '当前触发 + 风险边界')+'</span></div></div>';
var entryPlanHtml = ''; var entryPlanHtml = '';
if (isTradePlan) { if (isTradePlan) {
entryPlanHtml = '<div class="entry-plan"><div class="ep-item"><span class="ep-label">入场参考</span><span class="ep-val entry-ref">'+fmtP(entryRef)+'</span><span class="ep-sub">'+cleanDisplayText(entryLabel+' · '+(entryModel || '触发/计划价'))+'</span></div><div class="ep-item"><span class="ep-label">风险边界</span><span class="ep-val risk-line">'+fmtP(riskLine)+'</span><span class="ep-sub">'+cleanDisplayText(stopModel)+'</span></div><div class="ep-item"><span class="ep-label">上方空间</span><span class="ep-val space-ref">'+(upsidePct?('+'+upsidePct.toFixed(1)+'%'):'--')+'</span><span class="ep-sub">'+cleanDisplayText(tpModel)+' · '+fmtP(spaceRef)+'</span></div><div class="ep-item"><span class="ep-label">持有阶段</span><span class="ep-val level-ref">'+cleanDisplayText(horizon || phase.short)+'</span><span class="ep-sub">'+cleanDisplayText(levelLabel)+'</span></div></div>'; entryPlanHtml = '<div class="entry-plan"><div class="ep-item"><span class="ep-label">'+(side === 'short' ? '开空参考' : '入场参考')+'</span><span class="ep-val entry-ref">'+fmtP(entryRef)+'</span><span class="ep-sub">'+cleanDisplayText(entryLabel+' · '+(entryModel || '触发/计划价'))+'</span></div><div class="ep-item"><span class="ep-label">风险边界</span><span class="ep-val risk-line">'+fmtP(riskLine)+'</span><span class="ep-sub">'+cleanDisplayText(stopModel)+'</span></div><div class="ep-item"><span class="ep-label">'+(side === 'short' ? '下方空间' : '上方空间')+'</span><span class="ep-val space-ref">'+(upsidePct?('+'+upsidePct.toFixed(1)+'%'):'--')+'</span><span class="ep-sub">'+cleanDisplayText(tpModel)+' · '+fmtP(spaceRef)+'</span></div><div class="ep-item"><span class="ep-label">持有阶段</span><span class="ep-val level-ref">'+cleanDisplayText(horizon || phase.short)+'</span><span class="ep-sub">'+cleanDisplayText(levelLabel)+'</span></div></div>';
} else { } else {
entryPlanHtml = '<div class="entry-plan"><div class="ep-item"><span class="ep-label">当前参考</span><span class="ep-val entry-ref">'+fmtP(price)+'</span><span class="ep-sub">不是入场价</span></div><div class="ep-item"><span class="ep-label">观察重点</span><span class="ep-val space-ref">待触发</span><span class="ep-sub">'+cleanDisplayText(entryModel || '需15m/1H当前信号')+'</span></div><div class="ep-item"><span class="ep-label">绩效口径</span><span class="ep-val risk-line">不计入</span><span class="ep-sub">未形成交易机会</span></div><div class="ep-item"><span class="ep-label">观察阶段</span><span class="ep-val level-ref">'+cleanDisplayText(horizon || '观察池候选')+'</span><span class="ep-sub">'+cleanDisplayText(levelLabel)+'</span></div></div>'; entryPlanHtml = '<div class="entry-plan"><div class="ep-item"><span class="ep-label">当前参考</span><span class="ep-val entry-ref">'+fmtP(price)+'</span><span class="ep-sub">不是入场价</span></div><div class="ep-item"><span class="ep-label">观察重点</span><span class="ep-val space-ref">待触发</span><span class="ep-sub">'+cleanDisplayText(entryModel || '需15m/1H当前信号')+'</span></div><div class="ep-item"><span class="ep-label">绩效口径</span><span class="ep-val risk-line">不计入</span><span class="ep-sub">未形成交易机会</span></div><div class="ep-item"><span class="ep-label">观察阶段</span><span class="ep-val level-ref">'+cleanDisplayText(horizon || '观察池候选')+'</span><span class="ep-sub">'+cleanDisplayText(levelLabel)+'</span></div></div>';
} }
@ -961,7 +994,7 @@ function renderRecCard(r) {
var compactSignals = sigs.slice(0,3).map(function(s){ return '<span class="summary-chip">'+displaySignalText(s)+'</span>'; }).join(''); var compactSignals = sigs.slice(0,3).map(function(s){ return '<span class="summary-chip">'+displaySignalText(s)+'</span>'; }).join('');
var onchainChip = oc && (oc.event_count_24h || oc.onchain_score || oc.risk_score) ? '<span class="summary-chip">'+(ocRisk?'链上风险':'链上异动')+' '+(oc.event_count_24h||0)+'</span>' : ''; var onchainChip = oc && (oc.event_count_24h || oc.onchain_score || oc.risk_score) ? '<span class="summary-chip">'+(ocRisk?'链上风险':'链上异动')+' '+(oc.event_count_24h||0)+'</span>' : '';
var orderChip = r.paper_order && r.paper_order.id ? '<span class="summary-chip">挂单 '+(PAPER_ORDER_STATUS_LABELS[String(r.paper_order.status||'').toLowerCase()]||r.paper_order.status)+'</span>' : ''; var orderChip = r.paper_order && r.paper_order.id ? '<span class="summary-chip">挂单 '+(PAPER_ORDER_STATUS_LABELS[String(r.paper_order.status||'').toLowerCase()]||r.paper_order.status)+'</span>' : '';
return '<a class="card summary-card '+(isWeakObserve?'weak-observe':'')+'" href="'+detailHref+'"><div class="summary-top"><div class="summary-main"><div class="summary-symbol"><div class="coin-icon">'+base.slice(0,2).toUpperCase()+'</div><div><span class="coin-symbol">'+base+'</span><div class="summary-meta">'+fmtTime(r.rec_time)+' · '+cleanDisplayText(strategyLabel || levelLabel)+' · '+cleanDisplayText(horizon || levelFrameText(levelKey))+'</div></div></div></div><div class="summary-score"><span class="action-badge '+phase.cls+'">'+phase.label+'</span><span class="score-badge '+st.cls+'"><span class="score-num">'+score+'</span><span class="score-label">总分</span></span></div></div><div class="summary-price-row"><div class="summary-stat"><span>当前价</span><b>$'+priceFmt+'</b></div><div class="summary-stat"><span>'+changeLabel+'</span><b class="'+(changePct>=0?'green':'red')+'">'+(changePct==null?'--':changeSign+changePct.toFixed(1)+'%')+'</b></div><div class="summary-stat"><span>'+(isWait?'计划回踩':'计划入场')+'</span><b>'+fmtP(entryRef || price)+'</b></div><div class="summary-stat"><span>策略</span><b>'+cleanDisplayText(strategyLabel || '--')+'</b></div></div><div class="summary-decision '+decisionCls+'"><h3>'+decisionTitle+' · '+decisionFocus+'</h3><p>'+decisionReason+'</p></div><div class="summary-tags"><div class="summary-chips">'+(compactSignals||'<span class="summary-chip">暂无明确信号</span>')+onchainChip+orderChip+'</div><span class="detail-link">查看详情</span></div></a>'; return '<a class="card summary-card '+(isWeakObserve?'weak-observe':'')+'" href="'+detailHref+'"><div class="summary-top"><div class="summary-main"><div class="summary-symbol"><div class="coin-icon">'+base.slice(0,2).toUpperCase()+'</div><div><span class="coin-symbol">'+base+'</span><div class="summary-meta">'+fmtTime(r.rec_time)+' · '+cleanDisplayText(strategyLabel || levelLabel)+' · '+cleanDisplayText(horizon || levelFrameText(levelKey))+'</div></div></div></div><div class="summary-score">'+sideBadgeHtml(r)+'<span class="action-badge '+phase.cls+'">'+phase.label+'</span><span class="score-badge '+st.cls+'"><span class="score-num">'+score+'</span><span class="score-label">总分</span></span></div></div><div class="summary-price-row"><div class="summary-stat"><span>当前价</span><b>$'+priceFmt+'</b></div><div class="summary-stat"><span>'+changeLabel+'</span><b class="'+(changePct>=0?'green':'red')+'">'+(changePct==null?'--':changeSign+changePct.toFixed(1)+'%')+'</b></div><div class="summary-stat"><span>'+(isWait?(side === 'short'?'计划反抽':'计划回踩'):(side === 'short'?'计划开空':'计划入场'))+'</span><b>'+fmtP(entryRef || price)+'</b></div><div class="summary-stat"><span>策略</span><b>'+cleanDisplayText(strategyLabel || '--')+'</b></div></div><div class="summary-decision '+decisionCls+'"><h3>'+decisionTitle+' · '+decisionFocus+'</h3><p>'+decisionReason+'</p></div><div class="summary-tags"><div class="summary-chips">'+(compactSignals||'<span class="summary-chip">暂无明确信号</span>')+onchainChip+orderChip+'</div><span class="detail-link">查看详情</span></div></a>';
} catch (e) { } catch (e) {
console.error('renderRecCard hard fail', r && r.symbol, e); console.error('renderRecCard hard fail', r && r.symbol, e);
return renderLiveFallbackCard(r); return renderLiveFallbackCard(r);

File diff suppressed because one or more lines are too long