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 执行门禁。
|
- 多策略链路必须保持独立:`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`。
|
||||||
|
|
||||||
|
|||||||
@ -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("无法给出有效回踩买点,转为观察")
|
||||||
|
|||||||
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.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):
|
||||||
|
|||||||
@ -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 = []
|
||||||
|
|||||||
@ -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"),
|
||||||
|
|||||||
@ -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"),
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
@ -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"]
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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
Loading…
Reference in New Issue
Block a user