1
This commit is contained in:
parent
447edff0f6
commit
7299f0259b
@ -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 执行门禁。
|
||||
- 多/空方向是机会到交易的强制字段:发现、确认、推荐、跟踪、挂单、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 的入场、挂单和动态杠杆门槛。
|
||||
- 新增策略时必须注册稳定 `strategy_code`,并明确自己的 `entry_gate_config` / `paper_config`。不要把新策略的特殊门槛写进全局 `paper_trading_config()` 或 `DEFAULT_ENTRY_GATE`,否则会影响其他策略的信号生成和成交样本。
|
||||
- `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`。
|
||||
- 现有综合确认策略在迁移期标记为 `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 门禁。
|
||||
- 空头策略不能简单反转多头策略。当前第一版空头机会是 `breakdown_retest_short_1h_v1`,核心剧本是“1H箱体下破 -> 反抽箱体下沿/均线 -> 反抽失败 -> 等反抽或开空”,并使用独立 `strategy_code`、`factor_roles`、RR/止损几何和复盘口径。
|
||||
- 新增策略必须先 observe-only 或 paper-only 积累样本,再进入灰度/发布;不能因为某个因子短期表现好就直接同步真实交易。
|
||||
|
||||
### 4.1.3 链上数据源
|
||||
@ -189,7 +192,8 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组
|
||||
- `app/core/position_health.py`
|
||||
- 已开仓仓位的健康检查中心。它不是入场规则,而是持仓后判断“机会是否仍按原计划运行”:超时未启动会收紧保护价或提前退出,浮盈大幅回吐且未触发移动止盈会提前退出,市场进入 critical 时未受保护的微盈利/弱势仓位可提前退出。
|
||||
- 交易执行能力必须按“决策核心 -> 执行适配 -> 账本记录”分层:移动止盈、仓位失效保护、动态杠杆、仓位 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`。
|
||||
- 确认层也会应用同一市场风控语义:`risk_level=critical` 且 `position_multiplier=0` 时,强势发现仍可记录为观察,但不能输出 `buy_now` 或新挂单动作;已有活跃可交易推荐会被降级为观察并写入 `market_risk_gate`。
|
||||
|
||||
|
||||
@ -10,7 +10,9 @@ import re
|
||||
from typing import Any, Dict, Iterable, Tuple
|
||||
|
||||
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.trade_direction import normalize_trade_side
|
||||
|
||||
DEFAULT_ENTRY_GATE = {
|
||||
"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)
|
||||
|
||||
|
||||
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:
|
||||
"""从“站稳突破位 +66.7%”等信号中提取最大追高距离。"""
|
||||
max_pct = 0.0
|
||||
@ -285,6 +297,8 @@ def apply_entry_quality_gate(
|
||||
|
||||
entry_plan = dict(entry_plan or {})
|
||||
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)
|
||||
market_context = market_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"))
|
||||
plan_entry_price = to_float(entry_plan.get("entry_price"))
|
||||
invalid_plan_geometry = False
|
||||
if current_price > 0 and stop_loss > 0 and tp1 > 0 and current_price > stop_loss:
|
||||
live_rr1 = round((tp1 - current_price) / (current_price - stop_loss), 2)
|
||||
if current_price > 0 and stop_loss > 0 and tp1 > 0:
|
||||
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_price"] = round(current_price, 8)
|
||||
# 当前价已经明显低于确认时价格时,旧 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")
|
||||
entry_plan["risk_reward_ok_live"] = risk_reward_ok
|
||||
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
|
||||
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
|
||||
reasons.append("多头计划无效:TP1不高于计划入场价,转为观察")
|
||||
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 '该机会'}级别需要低周期触发后才允许买入")
|
||||
if not current_entry_trigger:
|
||||
reasons.append("缺少当前15min触发,禁止现价买入")
|
||||
if bearish_flow_risk:
|
||||
if side != "short" and bearish_flow_risk:
|
||||
reasons.append("出现空头加速/放量阴线风险,禁止现价买入")
|
||||
if current_price > 0:
|
||||
plan_entry_price = to_float(entry_plan.get("entry_price"))
|
||||
# 价格已经回到/跌破计划参考价,且实时 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_action"] = "可即刻买入"
|
||||
# 缺少止损/目标价时 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("原计划为等回踩,当前尚未严格触达计划价")
|
||||
if breakout_distance > _cfg_value(cfg, "breakout_distance_wait_pct"):
|
||||
reasons.append(f"离突破位+{breakout_distance:.1f}%,现价追高降级")
|
||||
@ -384,7 +419,10 @@ def apply_entry_quality_gate(
|
||||
target_action = "观察"
|
||||
reasons.append(f"买点分{entry_score:.1f}低于挂单门槛{_cfg_value(cfg, 'min_entry_score_wait_pullback')},不生成回踩挂单")
|
||||
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
|
||||
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 "观察"
|
||||
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 '该机会'}级别最高只允许观察/等待,不能因到价直接升级为现价买入")
|
||||
if bearish_flow_risk:
|
||||
if side != "short" and bearish_flow_risk:
|
||||
reasons.append("出现空头加速/放量阴线风险,到价也不升级为现价买入")
|
||||
else:
|
||||
target_action = "可即刻买入"
|
||||
@ -427,25 +465,37 @@ def apply_entry_quality_gate(
|
||||
if any("回踩参考已下破" in str(x) for x in reasons):
|
||||
target_action = "观察"
|
||||
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 = "可即刻买入"
|
||||
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 = "观察"
|
||||
reasons.append("回踩参考已到,但实时盈亏比不达标,转为观察")
|
||||
elif has_entry_score and action_status == "可即刻买入" and entry_score < _cfg_value(cfg, "min_entry_score_wait_pullback"):
|
||||
target_action = "观察"
|
||||
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")):
|
||||
rr_target_entry = calc_rr_target_entry(stop_loss, tp1, _cfg_value(cfg, "min_rr_buy_now"))
|
||||
if rr_target_entry > stop_loss and rr_target_entry < current_price:
|
||||
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_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 = "等回踩"
|
||||
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["rr_target_entry"] = rr_target_entry
|
||||
entry_plan["rr_target_reason"] = f"现价RR不足,需回落到该价或更低,RR1才≥{_cfg_value(cfg, 'min_rr_buy_now')}"
|
||||
reasons.append(f"现价不买,等回落到{rr_target_entry:.8g}附近再评估")
|
||||
entry_plan["rr_target_reason"] = (
|
||||
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:
|
||||
target_action = "观察"
|
||||
reasons.append("无法给出有效回踩买点,转为观察")
|
||||
|
||||
40
app/core/trade_direction.py
Normal file
40
app/core/trade_direction.py
Normal 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"
|
||||
@ -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.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.db.recommendation_state import (
|
||||
derive_minimal_state_fields,
|
||||
@ -78,6 +79,7 @@ def create_recommendation(
|
||||
factor_roles=None,
|
||||
):
|
||||
"""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
|
||||
rec_score_pct = min(raw_pct, 100)
|
||||
strategy_context = signal_to_recommendation_context(
|
||||
@ -89,9 +91,17 @@ def create_recommendation(
|
||||
fallback_symbol=symbol,
|
||||
fallback_score=rec_score_pct,
|
||||
signal_codes=build_signal_codes(build_signal_labels(signals if isinstance(signals, list) else normalize_signals(signals))),
|
||||
entry_plan=entry_plan or {},
|
||||
entry_plan=entry_plan,
|
||||
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_signal_id = int(strategy_context.get("strategy_signal_id") or 0)
|
||||
strategy_snapshot = strategy_context.get("strategy_snapshot") or {}
|
||||
@ -111,10 +121,10 @@ def create_recommendation(
|
||||
duplicate_cursor = conn.execute(
|
||||
"""
|
||||
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
|
||||
""",
|
||||
(symbol,),
|
||||
(symbol, direction),
|
||||
)
|
||||
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):
|
||||
|
||||
@ -10,6 +10,7 @@ from app.db.recommendation_state import (
|
||||
is_actionable_execution_status as _is_actionable_execution_status,
|
||||
)
|
||||
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.schema import get_conn
|
||||
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_snapshot"] = _loads_json(item.get("strategy_snapshot_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)
|
||||
item["recommendation_result"] = rec_result
|
||||
item["recommendation_result_label"] = rec_result_label
|
||||
@ -171,6 +181,12 @@ def get_active_recommendations(actionable_only: bool = False):
|
||||
result = []
|
||||
for row in rows:
|
||||
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")):
|
||||
continue
|
||||
result.append(item)
|
||||
@ -260,6 +276,7 @@ def get_active_recommendations_deduped(
|
||||
limit: int = 0,
|
||||
offset: int = 0,
|
||||
with_meta: bool = False,
|
||||
side: str = "",
|
||||
):
|
||||
"""同 symbol 只保留最新 active 推荐,并附带派生执行状态。"""
|
||||
conn = get_conn()
|
||||
@ -281,6 +298,10 @@ def get_active_recommendations_deduped(
|
||||
if hours > 0:
|
||||
where += " AND rec_time >= %s"
|
||||
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:
|
||||
limit = max(0, int(limit or 0))
|
||||
@ -323,7 +344,7 @@ def get_active_recommendations_deduped(
|
||||
SELECT symbol, MAX(id) AS max_id
|
||||
FROM recommendation
|
||||
WHERE {where}
|
||||
GROUP BY symbol
|
||||
GROUP BY symbol, direction
|
||||
) latest ON latest.max_id = r.id
|
||||
ORDER BY r.rec_time DESC
|
||||
""",
|
||||
@ -344,6 +365,8 @@ def get_active_recommendations_deduped(
|
||||
"executable_now": 0,
|
||||
"planned_entry": 0,
|
||||
"watch_pool": 0,
|
||||
"long_count": 0,
|
||||
"short_count": 0,
|
||||
}
|
||||
now = datetime.now()
|
||||
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")):
|
||||
continue
|
||||
all_items.append(item)
|
||||
if item.get("side") == "short":
|
||||
summary["short_count"] += 1
|
||||
else:
|
||||
summary["long_count"] += 1
|
||||
if item.get("is_discovery_burst"):
|
||||
summary["discovery_burst"] += 1
|
||||
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",
|
||||
"observe_tier": "weak" if _safe_int(coin_state["score"]) < 4 else "strong",
|
||||
"source": "coin_state",
|
||||
"side": "long",
|
||||
"side_label": "多",
|
||||
}
|
||||
|
||||
screening = []
|
||||
|
||||
@ -41,7 +41,13 @@ from app.config.config_loader import (
|
||||
get_strategy_params,
|
||||
)
|
||||
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 (
|
||||
attach_opportunity_level,
|
||||
classify_opportunity_level,
|
||||
@ -60,6 +66,7 @@ from app.strategies.altcoin_breakout import (
|
||||
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.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.core.pa_engine import (
|
||||
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."""
|
||||
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 {}
|
||||
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 {}
|
||||
signal_candidates = []
|
||||
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 {},
|
||||
)
|
||||
)
|
||||
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 = []
|
||||
for signal in [item for item in signal_candidates if item]:
|
||||
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
|
||||
|
||||
|
||||
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."""
|
||||
conn = get_conn()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"""
|
||||
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
|
||||
""",
|
||||
(symbol,),
|
||||
(symbol, direction_label(side) if side else "", direction_label(side) if side else ""),
|
||||
).fetchone()
|
||||
return int(row["id"] if row else 0)
|
||||
finally:
|
||||
@ -1165,6 +1187,13 @@ def confirm_burst(symbol, cand):
|
||||
# 提取cand数据(v1.7.0:用于辅助信号检测)
|
||||
cand_detail = json.loads(cand.get("detail_json", "{}"))
|
||||
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
|
||||
try:
|
||||
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_4h = {"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 {}
|
||||
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:
|
||||
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 = 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:
|
||||
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:
|
||||
# 旧放量/旧起爆不能直接确认;但如果当前 15min/日线/结构给出强分,
|
||||
# 允许作为“历史强背景 + 当前结构确认”。避免修复时效后把所有结构型机会一刀切为0。
|
||||
@ -1671,7 +1724,57 @@ def confirm_burst(symbol, cand):
|
||||
stop_cfg = confirm_stop_loss_params()
|
||||
|
||||
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_method = "🟢15min即刻入场(突破进行中)"
|
||||
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)
|
||||
|
||||
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
|
||||
)
|
||||
if trade_side == "short" and short_entry_plan is not None:
|
||||
entry_plan = short_entry_plan
|
||||
gate_strategy_code = BREAKDOWN_RETEST_SHORT_1H_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)
|
||||
|
||||
# v1.7.5 买点质量闸门:确认强势 ≠ 允许现价追买。
|
||||
@ -1870,6 +1977,8 @@ def confirm_burst(symbol, cand):
|
||||
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_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["onchain_context"] = onchain_context
|
||||
market_context["market_regime"] = market_regime
|
||||
@ -1897,6 +2006,7 @@ def confirm_burst(symbol, cand):
|
||||
},
|
||||
)
|
||||
if entry_plan:
|
||||
entry_plan["side"] = trade_side
|
||||
entry_plan["factor_score_breakdown"] = factor_score_breakdown
|
||||
entry_plan["onchain_context"] = onchain_context
|
||||
entry_plan["market_regime"] = market_regime
|
||||
@ -1917,6 +2027,8 @@ def confirm_burst(symbol, cand):
|
||||
"pa_1d": pa_1d,
|
||||
"box_breakout_pullback_1h": bp_1h if 'bp_1h' 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,
|
||||
"entry_action": (entry_plan or {}).get("entry_action") or entry_action,
|
||||
"market_context": market_context,
|
||||
@ -1937,9 +2049,11 @@ def _watch_candidate_plan(symbol, result, cand_detail):
|
||||
market_context = result.get("market_context") or {}
|
||||
signals = list(result.get("signals") or [])
|
||||
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(
|
||||
signals=signals,
|
||||
entry_plan={
|
||||
"side": side,
|
||||
"entry_action": "观察",
|
||||
"entry_price": price,
|
||||
"current_price": price,
|
||||
@ -1958,6 +2072,7 @@ def _watch_candidate_plan(symbol, result, cand_detail):
|
||||
"max_action": "observe",
|
||||
}
|
||||
plan = {
|
||||
"side": side,
|
||||
"entry_action": "观察",
|
||||
"entry_price": price,
|
||||
"current_price": price,
|
||||
@ -2085,9 +2200,6 @@ def main(compact: bool = False, verbose: bool = False, limit: int | None = None,
|
||||
# 飞书只是通知层:确认阶段不再绕过 recommendation 状态机直接推送。
|
||||
# 先完成 create_recommendation + DB 状态派生,再用同一条状态结果决定是否通知。
|
||||
|
||||
# 🟢 只做做多!方向永远多头
|
||||
rec_direction = get_strategy_direction()
|
||||
|
||||
# 🔴 v1.7.7 冷却期:刚止盈/止损的币不立即重新推荐
|
||||
# Sahara案例:17:24止盈(+5%)→17:40重新推荐→现价跌-3%
|
||||
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
|
||||
|
||||
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"]
|
||||
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_tp1 = float(ep.get("tp1") 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)):
|
||||
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)
|
||||
if strategy_ctx.get("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", []),
|
||||
is_meme=int(is_meme_coin(symbol)),
|
||||
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"),
|
||||
derivatives_context=result.get("derivatives_context"),
|
||||
sector_context=result.get("sector_context"),
|
||||
|
||||
@ -67,6 +67,8 @@ from app.db.universe_audit import (
|
||||
record_universe_decisions,
|
||||
)
|
||||
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})
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
@ -1163,6 +1165,7 @@ def layer1_coarse_filter():
|
||||
bb_data = None
|
||||
static_accumulation = None
|
||||
short_tf_ignition = None
|
||||
short_breakdown_1h = {"detected": False}
|
||||
|
||||
# 1H量价齐飞检测(核心)
|
||||
kline_attempt_symbols.add(symbol)
|
||||
@ -1212,6 +1215,13 @@ def layer1_coarse_filter():
|
||||
anomalies.append(f"1H放量({vp_data['max_vol_ratio']}x)但无量价齐飞(量价背离)")
|
||||
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级别)
|
||||
if h4_df is not None and len(h4_df) >= 20:
|
||||
bb_data = detect_bollinger_squeeze(h4_df)
|
||||
@ -1307,6 +1317,7 @@ def layer1_coarse_filter():
|
||||
"bb_data": bb_data,
|
||||
"static_accumulation": static_accumulation,
|
||||
"short_tf_ignition": short_tf_ignition,
|
||||
"short_breakdown_retest_1h": short_breakdown_1h,
|
||||
"h4_df": h4_df,
|
||||
"turnover_acceleration_1h": turnover_acc_1h,
|
||||
"turnover_acceleration_4h": turnover_acc_4h,
|
||||
@ -1838,15 +1849,15 @@ def layer2_fine_filter(candidates):
|
||||
leader_symbol = info["leader"]
|
||||
leader_pct = info.get("leader_pct", 0)
|
||||
|
||||
# 🟢 只做做多!空头信号只记录不加分,方向永远多头
|
||||
# 空头起爆/空头加速只是衰减参考,不生成推荐
|
||||
direction = get_strategy_direction()
|
||||
direction_num = 1
|
||||
side = "short" if (cand.get("short_breakdown_retest_1h") or {}).get("detected") else "long"
|
||||
direction = direction_label(side)
|
||||
direction_num = -1 if side == "short" else 1
|
||||
|
||||
qualified[symbol] = {
|
||||
"state": state,
|
||||
"score": score,
|
||||
"signals": signals,
|
||||
"side": side,
|
||||
"direction": direction,
|
||||
"direction_num": direction_num,
|
||||
"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")},
|
||||
"turnover_acceleration_1h": cand.get("turnover_acceleration_1h"),
|
||||
"turnover_acceleration_4h": cand.get("turnover_acceleration_4h"),
|
||||
"side": side,
|
||||
"short_breakdown_retest_1h": cand.get("short_breakdown_retest_1h") or {},
|
||||
},
|
||||
"derivatives_context": {
|
||||
"funding_rate": cand.get("funding_rate"),
|
||||
|
||||
@ -36,6 +36,8 @@ from app.core.pa_engine import (
|
||||
)
|
||||
from app.config.config_loader import load_rules
|
||||
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
|
||||
|
||||
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)
|
||||
tp2 = rec.get("tp2", entry_price * 1.05)
|
||||
entry_plan = rec.get("entry_plan") or {}
|
||||
side = normalize_trade_side(entry_plan.get("side") or rec.get("side") or rec.get("direction"))
|
||||
|
||||
# ---- 拉取1H数据做趋势分析 ----
|
||||
h1_df = fetch_klines(symbol, "1h", limit=100)
|
||||
@ -138,10 +141,10 @@ def analyze_tracking_signals(symbol, rec, current_price):
|
||||
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 是策略交易生命周期,不再写成推荐信号动作。
|
||||
if tp1 > 0 and current_price >= tp1:
|
||||
if tp1 > 0 and should_take_profit(side, current_price, tp1):
|
||||
sell_signals.append(f"策略交易目标价已到达(${tp1:.4f}),执行结果以交易账本为准")
|
||||
|
||||
rules = load_rules()
|
||||
@ -151,11 +154,12 @@ def analyze_tracking_signals(symbol, rec, current_price):
|
||||
|
||||
# ---- 止损接近警告 ----
|
||||
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%
|
||||
sell_signals.append(f"⚠️ 接近止损!当前${current_price:.4f}离止损${stop_loss:.4f}仅{loss_pct:.1f}%")
|
||||
if current_price <= stop_loss:
|
||||
sell_signals.append(f"🔴 策略交易止损价已触达!${current_price:.4f}≤${stop_loss:.4f},执行结果以交易账本为准")
|
||||
if should_stop_loss(side, current_price, stop_loss):
|
||||
op = "≥" if side == "short" else "≤"
|
||||
sell_signals.append(f"🔴 策略交易止损价已触达!${current_price:.4f}{op}${stop_loss:.4f},执行结果以交易账本为准")
|
||||
|
||||
# ---- 趋势反转信号(PA行为检测,替代MACD) ----
|
||||
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:
|
||||
pa_4h = full_pa_analysis(h4_df, "4h")
|
||||
h4_zones = pa_4h.get("zones", [])
|
||||
direction = 1 # 做多方向
|
||||
direction = -1 if side == "short" else 1
|
||||
|
||||
# 重新做15min入场点分析
|
||||
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", "等回踩")
|
||||
|
||||
if new_action == "即刻买入":
|
||||
buy_signals.append(f"🟢 回踩确认完毕!可即刻入场(15min动K确认)")
|
||||
buy_signals.append("🟢 反抽确认完毕!可开空(15min动K确认)" if side == "short" else "🟢 回踩确认完毕!可即刻入场(15min动K确认)")
|
||||
action_status = "可即刻买入"
|
||||
elif new_action == "等回踩":
|
||||
wait_price = entry_result.get("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:
|
||||
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 = "可即刻买入"
|
||||
|
||||
entry_update = {
|
||||
|
||||
@ -20,6 +20,71 @@ def _safe_float(value, default=0.0) -> float:
|
||||
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(
|
||||
*,
|
||||
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"]
|
||||
|
||||
@ -76,6 +76,7 @@ async def api_recommendations(
|
||||
archive_filter: str = "",
|
||||
paged: bool = False,
|
||||
compact: bool = False,
|
||||
side: str = "",
|
||||
altcoin_session: str = Cookie(default=""),
|
||||
):
|
||||
require_api_user_with_subscription(altcoin_session)
|
||||
@ -99,6 +100,7 @@ async def api_recommendations_active(
|
||||
offset: int = 0,
|
||||
paged: bool = False,
|
||||
compact: bool = False,
|
||||
side: str = "",
|
||||
altcoin_session: str = Cookie(default=""),
|
||||
):
|
||||
require_api_user_with_subscription(altcoin_session)
|
||||
@ -110,6 +112,7 @@ async def api_recommendations_active(
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
with_meta=(paged or compact),
|
||||
side=side,
|
||||
)
|
||||
return get_active_recommendations(actionable_only=actionable_only)
|
||||
|
||||
|
||||
@ -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-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; }
|
||||
.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; }
|
||||
.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; }
|
||||
@ -379,6 +382,7 @@ var curTab = 'live';
|
||||
var latestVersion = '';
|
||||
var currentVersion = '';
|
||||
var currentFilter = '';
|
||||
var currentSideFilter = '';
|
||||
var cachedLiveData = [];
|
||||
var liveOffset = 0;
|
||||
var liveLimit = 24;
|
||||
@ -658,6 +662,17 @@ function opportunityUrl(r) {
|
||||
function goOpportunity(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 ======
|
||||
function isExpiredRec(r) {
|
||||
@ -673,7 +688,7 @@ async function loadContent(reset) {
|
||||
liveLoading = true;
|
||||
try {
|
||||
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 page = await resp.json();
|
||||
var items = Array.isArray(page.items) ? page.items : (Array.isArray(page) ? page : []);
|
||||
@ -744,6 +759,7 @@ function isRenderableLiveRec(r) {
|
||||
}
|
||||
function applyFilterAndRender() {
|
||||
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 filtered = visible.filter(function(r){ return !isWeakObserveRec(r); });
|
||||
if (!currentFilter) {
|
||||
@ -765,6 +781,11 @@ function setFilter(status) {
|
||||
refreshVisibleKlines();
|
||||
}
|
||||
|
||||
function setSideFilter(side) {
|
||||
currentSideFilter = side || '';
|
||||
loadContent(true);
|
||||
}
|
||||
|
||||
function renderLiveStats(data) {
|
||||
var visible = [];
|
||||
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 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 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 bCls = 'stat-chip' + (currentFilter === 'buy_now' ? ' 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 lCls = 'stat-chip' + (currentSideFilter === 'long' ? ' filterable active' : ' filterable');
|
||||
var sCls = 'stat-chip' + (currentSideFilter === 'short' ? ' filterable active' : ' filterable');
|
||||
$('liveStats').innerHTML =
|
||||
'<div class="stats-main">' +
|
||||
'<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="'+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="'+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>';
|
||||
}
|
||||
|
||||
@ -844,6 +872,7 @@ function renderRecCard(r) {
|
||||
return (r.observe_tier === 'weak') ? {label:'弱观察', cls:'weak', short:'弱观察'} : {label:'观察中', cls:'obs', short:'观察'};
|
||||
}
|
||||
var ep = r.entry_plan || {};
|
||||
var side = recSide(r);
|
||||
var levelKey = r.opportunity_level || ep.opportunity_level || 'structure_watch';
|
||||
var levelLabel = r.opportunity_level_label || ep.opportunity_level_label || '结构观察';
|
||||
var horizon = r.holding_horizon || ep.holding_horizon || '';
|
||||
@ -854,6 +883,10 @@ function renderRecCard(r) {
|
||||
var entryMethod = ep.entry_method || '';
|
||||
var signalText = sigs.join(' ');
|
||||
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 isExecuted = !!r.entry_triggered || r.display_bucket === 'position' || r.execution_status === 'holding' || r.execution_status === 'completed';
|
||||
var isTradePlan = isBuy || isWait || isExecuted || r.entry_triggered;
|
||||
@ -890,19 +923,19 @@ 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箱体突破回踩',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 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 changeRef = entryRef || r.entry_price || 0;
|
||||
var changeLabel = isExecuted ? '持仓盈亏' : (isWait ? '较回踩参考' : (isBuy ? '较触发价' : '较参考价'));
|
||||
var changePct = price && changeRef ? ((price - changeRef) / changeRef * 100) : null;
|
||||
var changeLabel = isExecuted ? '持仓盈亏' : (isWait ? (side === 'short' ? '较反抽参考' : '较回踩参考') : (isBuy ? '较触发价' : '较参考价'));
|
||||
var changePct = sideChangePct(price, changeRef, side);
|
||||
var changeCls = changePct!=null?(changePct>0?'up':changePct<0?'down':'zero'):'zero';
|
||||
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 riskLine = ep.stop_loss || r.stop_loss || 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() {
|
||||
var w = r.entry_window || {};
|
||||
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 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 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 aiInsightHtml = '';
|
||||
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 entryPlanHtml = '';
|
||||
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 {
|
||||
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 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>' : '';
|
||||
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) {
|
||||
console.error('renderRecCard hard fail', r && r.symbol, e);
|
||||
return renderLiveFallbackCard(r);
|
||||
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user