当前结论
'+esc(statusLabel(r))+'
原因
'+esc(clean(r.execution_reason||r.state_reason||dl.decision||'等待进一步确认'))+'
入场模型
'+esc(clean(ep.entry_model||ep.entry_method||'--'))+'
失效条件
'+esc(clean(ep.invalid_if||ep.stop_basis||'跌破风险边界或信号衰减'))+'
From 7299f0259b47ed86a328e7edee2d89b0c447fb78 Mon Sep 17 00:00:00 2001 From: aaron <> Date: Sun, 31 May 2026 22:47:03 +0800 Subject: [PATCH] 1 --- AGENTS.md | 6 +- app/core/opportunity_lifecycle.py | 84 +++++++++++++---- app/core/trade_direction.py | 40 ++++++++ app/db/recommendation_commands.py | 16 +++- app/db/recommendation_queries.py | 31 ++++++- app/services/altcoin_confirm.py | 147 ++++++++++++++++++++++++++---- app/services/altcoin_screener.py | 21 ++++- app/services/price_tracker.py | 22 +++-- app/strategies/short_breakdown.py | 67 +++++++++++++- app/web/routes_recommendations.py | 3 + static/app.html | 53 +++++++++-- static/opportunity_detail.html | 7 +- 12 files changed, 433 insertions(+), 64 deletions(-) create mode 100644 app/core/trade_direction.py diff --git a/AGENTS.md b/AGENTS.md index 46a54f1..8f86f67 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,6 +105,8 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组 - 核心认知:因子不等于策略。一个因子可以是先决条件、触发、确认、入场、风控或归因,但不能因为单个因子表现好就直接升级成完整策略。 - 完整策略必须至少包含:适用市场环境、交易宇宙、先决条件、核心触发、辅助确认、入场规则、止盈止损、失效条件、仓位/杠杆约束和独立复盘口径。 - 多策略链路必须保持独立:`main_composite_v1`、`box_retest_1h_v1`、`box_retest_4h_v1`、`volume_ignition_1h_v1`、`compression_breakout_4h_v1`、`intraday_momentum_15m_v1` 等策略可以共享行情、账户级风控和执行框架,但不能共享同一套入场门槛、RR、挂单距离和 paper trading 执行门禁。 +- 多/空方向是机会到交易的强制字段:发现、确认、推荐、跟踪、挂单、paper/live trading、复盘和 UI 都必须保留 `side=long/short`,不能在任一层默认覆盖成做多。`entry_plan.side` 是交易执行层的优先事实源,`direction` 只是面向用户的中文展示。 +- 同一个 `symbol` 可以同时存在多头机会和空头机会。推荐写入和列表去重必须按 `symbol + direction/side` 处理,不能只按币种去重,否则会互相覆盖。 - 策略级配置入口在 `app/core/strategy_registry.py`,`StrategyDefinition.entry_gate_config` 控制确认/跟踪/展示派生的买点质量闸门,`StrategyDefinition.paper_config` 控制该策略进入 paper trading 的入场、挂单和动态杠杆门槛。 - 新增策略时必须注册稳定 `strategy_code`,并明确自己的 `entry_gate_config` / `paper_config`。不要把新策略的特殊门槛写进全局 `paper_trading_config()` 或 `DEFAULT_ENTRY_GATE`,否则会影响其他策略的信号生成和成交样本。 - `apply_entry_quality_gate()` 必须传入或从 `entry_plan.strategy_code` 派生策略身份;`paper_trader.py` 中开仓、挂单、挂单成交和挂单维护应通过策略级配置合并后的参数执行。 @@ -128,6 +130,7 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组 - `strategy_version` 只表示版本,不应替代策略身份;后续推荐、挂单和交易账本都应补充 `strategy_code`、`strategy_signal_id`、`strategy_snapshot_json` 和 `factor_roles_json`。 - 现有综合确认策略在迁移期标记为 `main_composite_v1`,它只是平等策略之一,用于避免无策略来源的推荐继续进入 paper trading。 - 当前已拆出的独立策略包括:`box_retest_1h_v1` / `box_retest_4h_v1` 箱体突破回踩、`volume_ignition_1h_v1` 1H放量突破启动、`compression_breakout_4h_v1` 4H压缩蓄力突破、`intraday_momentum_15m_v1` 15m日内动量延续。它们可以共享交易宇宙和行情数据,但必须保留各自的触发、入场、失效和 paper trading 门禁。 +- 空头策略不能简单反转多头策略。当前第一版空头机会是 `breakdown_retest_short_1h_v1`,核心剧本是“1H箱体下破 -> 反抽箱体下沿/均线 -> 反抽失败 -> 等反抽或开空”,并使用独立 `strategy_code`、`factor_roles`、RR/止损几何和复盘口径。 - 新增策略必须先 observe-only 或 paper-only 积累样本,再进入灰度/发布;不能因为某个因子短期表现好就直接同步真实交易。 ### 4.1.3 链上数据源 @@ -189,7 +192,8 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组 - `app/core/position_health.py` - 已开仓仓位的健康检查中心。它不是入场规则,而是持仓后判断“机会是否仍按原计划运行”:超时未启动会收紧保护价或提前退出,浮盈大幅回吐且未触发移动止盈会提前退出,市场进入 critical 时未受保护的微盈利/弱势仓位可提前退出。 - 交易执行能力必须按“决策核心 -> 执行适配 -> 账本记录”分层:移动止盈、仓位失效保护、动态杠杆、仓位 sizing、订单触发、账户风控都应有可被 paper trading 和 live trading 复用的核心模块。不要把这些规则直接写死在 `app/db/paper_trading.py`、`routes_live_trading.py` 或页面 JS 中。 -- 交易执行层已支持 `side=long/short`:paper trading 开仓、平仓、PnL、TP/SL、移动止盈、持仓健康和挂单触价都按 side 处理。当前已注册第一版独立空头策略 `breakdown_retest_short_1h_v1`,但这不代表发现/确认层已经全量接入空头扫描;新增做空必须继续使用独立 `strategy_code`、触发条件、失效条件和复盘口径,不能把多头策略简单反向。 +- 交易执行层已支持 `side=long/short`:paper trading 开仓、平仓、PnL、TP/SL、移动止盈、持仓健康和挂单触价都按 side 处理。当前发现/确认层已接入第一版独立空头策略 `breakdown_retest_short_1h_v1`;后续新增做空策略也必须继续使用独立 `strategy_code`、触发条件、失效条件和复盘口径,不能把多头策略简单反向。 +- 多空交易数学必须走共享核心模块:`app/core/trade_math.py`、`app/core/order_lifecycle.py`、`app/core/trade_direction.py`。不要在页面、route、paper/live 适配层里重新手写“多头涨了盈利/空头跌了盈利”的判断,避免后续实盘同步和复盘口径分裂。 - 多策略基础设施当前内置 `main_composite_v1`、`box_retest_4h_v1`、`box_retest_1h_v1`、`volume_ignition_1h_v1`、`compression_breakout_4h_v1`、`intraday_momentum_15m_v1`、`breakdown_retest_short_1h_v1`。`box_breakout_pullback_4h` / `box_breakout_pullback_1h`、`vp_fly_1h_current`、`short_tf_15m_ignition`、`breakdown_retest_1h_short` 等只是因子,只有和入场确认、风控、失效条件组成完整剧本后,才作为对应策略信号写入 `strategy_signals`。 - 确认层也会应用同一市场风控语义:`risk_level=critical` 且 `position_multiplier=0` 时,强势发现仍可记录为观察,但不能输出 `buy_now` 或新挂单动作;已有活跃可交易推荐会被降级为观察并写入 `market_risk_gate`。 diff --git a/app/core/opportunity_lifecycle.py b/app/core/opportunity_lifecycle.py index 0182aad..8af5148 100644 --- a/app/core/opportunity_lifecycle.py +++ b/app/core/opportunity_lifecycle.py @@ -10,7 +10,9 @@ import re from typing import Any, Dict, Iterable, Tuple from app.core.opportunity_level import OPPORTUNITY_LEVELS +from app.core.order_lifecycle import order_rr from app.core.strategy_registry import normalize_strategy_code, strategy_entry_gate_config +from app.core.trade_direction import normalize_trade_side DEFAULT_ENTRY_GATE = { "enabled": True, @@ -147,6 +149,16 @@ def calc_rr_target_entry(stop_loss: float, tp1: float, min_rr: float) -> float: return round((tp1 + min_rr * stop_loss) / (1 + min_rr), 8) +def calc_short_rr_target_entry(stop_loss: float, tp1: float, min_rr: float) -> float: + """最低允许开空价:在该价格或更高开空,RR1 才能达到 min_rr。""" + stop_loss = to_float(stop_loss) + tp1 = to_float(tp1) + min_rr = to_float(min_rr) + if stop_loss <= tp1 or tp1 <= 0 or min_rr <= 0: + return 0.0 + return round((tp1 + min_rr * stop_loss) / (1 + min_rr), 8) + + def detect_breakout_distance_pct(signals: Iterable[Any]) -> float: """从“站稳突破位 +66.7%”等信号中提取最大追高距离。""" max_pct = 0.0 @@ -285,6 +297,8 @@ def apply_entry_quality_gate( entry_plan = dict(entry_plan or {}) entry_plan.setdefault("strategy_code", strategy_code) + side = normalize_trade_side(entry_plan.get("side") or entry_plan.get("direction")) + entry_plan["side"] = side signals = normalize_signals(signals) market_context = market_context or {} derivatives_context = derivatives_context or {} @@ -300,8 +314,12 @@ def apply_entry_quality_gate( tp1 = to_float(entry_plan.get("tp1") or entry_plan.get("take_profit_1")) plan_entry_price = to_float(entry_plan.get("entry_price")) invalid_plan_geometry = False - if current_price > 0 and stop_loss > 0 and tp1 > 0 and current_price > stop_loss: - live_rr1 = round((tp1 - current_price) / (current_price - stop_loss), 2) + if current_price > 0 and stop_loss > 0 and tp1 > 0: + live_rr1 = order_rr(side, current_price, stop_loss, tp1) + if live_rr1 > 0: + live_rr1 = round(live_rr1, 2) + else: + live_rr1 = 0.0 entry_plan["rr1_live"] = live_rr1 entry_plan["rr1_live_price"] = round(current_price, 8) # 当前价已经明显低于确认时价格时,旧 rr1/risk_reward_ok 会失真。 @@ -310,10 +328,17 @@ def apply_entry_quality_gate( risk_reward_ok = live_rr1 >= _cfg_value(cfg, "min_rr_buy_now") entry_plan["risk_reward_ok_live"] = risk_reward_ok if action_status in ("可即刻买入", "等回踩") and plan_entry_price > 0: - if stop_loss > 0 and stop_loss >= plan_entry_price: + if side == "short": + if stop_loss > 0 and stop_loss <= plan_entry_price: + invalid_plan_geometry = True + reasons.append("空头计划无效:止损价不高于计划开空价,转为观察") + if tp1 > 0 and tp1 >= plan_entry_price: + invalid_plan_geometry = True + reasons.append("空头计划无效:TP1不低于计划开空价,转为观察") + elif stop_loss > 0 and stop_loss >= plan_entry_price: invalid_plan_geometry = True reasons.append("多头计划无效:止损价不低于计划入场价,转为观察") - if tp1 > 0 and tp1 <= plan_entry_price: + if side != "short" and tp1 > 0 and tp1 <= plan_entry_price: invalid_plan_geometry = True reasons.append("多头计划无效:TP1不高于计划入场价,转为观察") entry_action = str(entry_plan.get("entry_action") or "").strip() @@ -363,16 +388,26 @@ def apply_entry_quality_gate( reasons.append(f"{entry_plan.get('opportunity_level_label') or opportunity_level or '该机会'}级别需要低周期触发后才允许买入") if not current_entry_trigger: reasons.append("缺少当前15min触发,禁止现价买入") - if bearish_flow_risk: + if side != "short" and bearish_flow_risk: reasons.append("出现空头加速/放量阴线风险,禁止现价买入") if current_price > 0: plan_entry_price = to_float(entry_plan.get("entry_price")) # 价格已经回到/跌破计划参考价,且实时 RR 已达标时,应转为入场窗口,不能继续显示“等回踩”。 - if plan_entry_price > 0 and current_price <= plan_entry_price * 1.003 and risk_reward_ok is not False and rr1 >= _cfg_value(cfg, "min_rr_buy_now"): + target_touched = ( + plan_entry_price > 0 + and ( + (side == "short" and current_price >= plan_entry_price * 0.997) + or (side != "short" and current_price <= plan_entry_price * 1.003) + ) + ) + if target_touched and risk_reward_ok is not False and rr1 >= _cfg_value(cfg, "min_rr_buy_now"): entry_plan["entry_trigger_confirmed"] = True entry_plan["entry_action"] = "可即刻买入" # 缺少止损/目标价时 rr1 默认 999,不能因为字段不全拦截测试/候选信号;有显式 rr1 时才按硬门槛降级。 - if entry_action == "等回踩" and not entry_plan.get("entry_trigger_confirmed") and current_price > to_float(entry_plan.get("entry_price")) * 1.003: + if entry_action == "等回踩" and not entry_plan.get("entry_trigger_confirmed") and ( + (side == "short" and current_price < to_float(entry_plan.get("entry_price")) * 0.997) + or (side != "short" and current_price > to_float(entry_plan.get("entry_price")) * 1.003) + ): reasons.append("原计划为等回踩,当前尚未严格触达计划价") if breakout_distance > _cfg_value(cfg, "breakout_distance_wait_pct"): reasons.append(f"离突破位+{breakout_distance:.1f}%,现价追高降级") @@ -384,7 +419,10 @@ def apply_entry_quality_gate( target_action = "观察" reasons.append(f"买点分{entry_score:.1f}低于挂单门槛{_cfg_value(cfg, 'min_entry_score_wait_pullback')},不生成回踩挂单") if plan_entry_price > 0: - wait_deviation_pct = round((current_price / plan_entry_price - 1) * 100, 2) + if side == "short": + wait_deviation_pct = round((plan_entry_price / current_price - 1) * 100, 2) if current_price > 0 else 0 + else: + wait_deviation_pct = round((current_price / plan_entry_price - 1) * 100, 2) entry_plan["wait_pullback_deviation_pct"] = wait_deviation_pct lifecycle_plan_type = ((entry_plan.get("opportunity_lifecycle") or {}).get("plan_type") or "").strip() # 回踩参考已经被有效击穿,继续挂“等回踩”会误导;先降为观察。 @@ -403,7 +441,7 @@ def apply_entry_quality_gate( target_action = "等回踩" if level_max_action == "wait_pullback" and not bearish_flow_risk else "观察" if level_max_action == "observe" or (level_max_action == "wait_pullback" and not current_entry_trigger): reasons.append(f"{entry_plan.get('opportunity_level_label') or opportunity_level or '该机会'}级别最高只允许观察/等待,不能因到价直接升级为现价买入") - if bearish_flow_risk: + if side != "short" and bearish_flow_risk: reasons.append("出现空头加速/放量阴线风险,到价也不升级为现价买入") else: target_action = "可即刻买入" @@ -427,25 +465,37 @@ def apply_entry_quality_gate( if any("回踩参考已下破" in str(x) for x in reasons): target_action = "观察" elif any("回踩参考已到或更优" in str(x) for x in reasons) and not ( - level_max_action == "observe" or (level_max_action == "wait_pullback" and not current_entry_trigger) or bearish_flow_risk + level_max_action == "observe" or (level_max_action == "wait_pullback" and not current_entry_trigger) or (side != "short" and bearish_flow_risk) ): target_action = "可即刻买入" - elif action_status == "等回踩" and current_price > 0 and to_float(entry_plan.get("entry_price")) > 0 and current_price <= to_float(entry_plan.get("entry_price")) * 1.003 and (risk_reward_ok is False or rr1 < _cfg_value(cfg, "min_rr_buy_now")): + elif action_status == "等回踩" and current_price > 0 and to_float(entry_plan.get("entry_price")) > 0 and ( + (side == "short" and current_price >= to_float(entry_plan.get("entry_price")) * 0.997) + or (side != "short" and current_price <= to_float(entry_plan.get("entry_price")) * 1.003) + ) and (risk_reward_ok is False or rr1 < _cfg_value(cfg, "min_rr_buy_now")): target_action = "观察" reasons.append("回踩参考已到,但实时盈亏比不达标,转为观察") elif has_entry_score and action_status == "可即刻买入" and entry_score < _cfg_value(cfg, "min_entry_score_wait_pullback"): target_action = "观察" reasons.append("买点分不足以进入挂单池,转为观察") - elif action_status == "可即刻买入" and current_price > 0 and stop_loss > 0 and tp1 > stop_loss and (risk_reward_ok is False or rr1 < _cfg_value(cfg, "min_rr_buy_now")): - rr_target_entry = calc_rr_target_entry(stop_loss, tp1, _cfg_value(cfg, "min_rr_buy_now")) - if rr_target_entry > stop_loss and rr_target_entry < current_price: + elif action_status == "可即刻买入" and current_price > 0 and stop_loss > 0 and tp1 > 0 and (risk_reward_ok is False or rr1 < _cfg_value(cfg, "min_rr_buy_now")): + rr_target_entry = ( + calc_short_rr_target_entry(stop_loss, tp1, _cfg_value(cfg, "min_rr_buy_now")) + if side == "short" + else calc_rr_target_entry(stop_loss, tp1, _cfg_value(cfg, "min_rr_buy_now")) + ) + valid_wait = rr_target_entry > current_price if side == "short" else (rr_target_entry > stop_loss and rr_target_entry < current_price) + if valid_wait: target_action = "等回踩" entry_plan["entry_price"] = rr_target_entry - entry_plan["entry_method"] = f"等回踩至可买RR价 {rr_target_entry:.8g}" + entry_plan["entry_method"] = f"等反抽至可空RR价 {rr_target_entry:.8g}" if side == "short" else f"等回踩至可买RR价 {rr_target_entry:.8g}" entry_plan["entry_action"] = "等回踩" entry_plan["rr_target_entry"] = rr_target_entry - entry_plan["rr_target_reason"] = f"现价RR不足,需回落到该价或更低,RR1才≥{_cfg_value(cfg, 'min_rr_buy_now')}" - reasons.append(f"现价不买,等回落到{rr_target_entry:.8g}附近再评估") + entry_plan["rr_target_reason"] = ( + f"现价RR不足,需反抽到该价或更高,RR1才≥{_cfg_value(cfg, 'min_rr_buy_now')}" + if side == "short" + else f"现价RR不足,需回落到该价或更低,RR1才≥{_cfg_value(cfg, 'min_rr_buy_now')}" + ) + reasons.append(("现价不空,等反抽到" if side == "short" else "现价不买,等回落到") + f"{rr_target_entry:.8g}附近再评估") else: target_action = "观察" reasons.append("无法给出有效回踩买点,转为观察") diff --git a/app/core/trade_direction.py b/app/core/trade_direction.py new file mode 100644 index 0000000..9ce4dfc --- /dev/null +++ b/app/core/trade_direction.py @@ -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" diff --git a/app/db/recommendation_commands.py b/app/db/recommendation_commands.py index 01523d9..0f8d6ec 100644 --- a/app/db/recommendation_commands.py +++ b/app/db/recommendation_commands.py @@ -11,6 +11,7 @@ from app.core.opportunity_lifecycle import ( ) from app.core.signal_taxonomy import signal_codes as build_signal_codes, signal_labels as build_signal_labels from app.core.strategy_contract import signal_to_recommendation_context +from app.core.trade_direction import direction_label, trade_side_from_payload from app.core.strategy_registry import normalize_strategy_code from app.db.recommendation_state import ( derive_minimal_state_fields, @@ -78,6 +79,7 @@ def create_recommendation( factor_roles=None, ): """Create or merge the current recommendation record for one symbol.""" + entry_plan = dict(entry_plan or {}) raw_pct = round(rec_score * 100.0 / 30) if rec_score else 0 rec_score_pct = min(raw_pct, 100) strategy_context = signal_to_recommendation_context( @@ -89,9 +91,17 @@ def create_recommendation( fallback_symbol=symbol, fallback_score=rec_score_pct, signal_codes=build_signal_codes(build_signal_labels(signals if isinstance(signals, list) else normalize_signals(signals))), - entry_plan=entry_plan or {}, + entry_plan=entry_plan, market_context=market_context or {}, ) + side = trade_side_from_payload(entry_plan, strategy_context.get("strategy_snapshot"), direction) + entry_plan["side"] = side + direction = direction_label(side) + if isinstance(strategy_context.get("strategy_snapshot"), dict): + strategy_context["strategy_snapshot"]["direction"] = side + snapshot_plan = dict(strategy_context["strategy_snapshot"].get("entry_plan") or {}) + snapshot_plan["side"] = side + strategy_context["strategy_snapshot"]["entry_plan"] = {**entry_plan, **snapshot_plan} strategy_code = normalize_strategy_code(strategy_context.get("strategy_code")) strategy_signal_id = int(strategy_context.get("strategy_signal_id") or 0) strategy_snapshot = strategy_context.get("strategy_snapshot") or {} @@ -111,10 +121,10 @@ def create_recommendation( duplicate_cursor = conn.execute( """ SELECT * FROM recommendation - WHERE symbol=%s AND status='active' AND COALESCE(display_bucket,'watch_pool') != 'history' + WHERE symbol=%s AND status='active' AND direction=%s AND COALESCE(display_bucket,'watch_pool') != 'history' ORDER BY id DESC LIMIT 1 """, - (symbol,), + (symbol, direction), ) duplicate_row = duplicate_cursor.fetchone() if hasattr(duplicate_cursor, "fetchone") else None if duplicate_row and (entry_plan or duplicate_row["rec_state"] == rec_state): diff --git a/app/db/recommendation_queries.py b/app/db/recommendation_queries.py index 5b7aa3c..424d2db 100644 --- a/app/db/recommendation_queries.py +++ b/app/db/recommendation_queries.py @@ -10,6 +10,7 @@ from app.db.recommendation_state import ( is_actionable_execution_status as _is_actionable_execution_status, ) from app.core.strategy_registry import normalize_strategy_code, strategy_label +from app.core.trade_direction import normalize_trade_side, side_label from app.db.push_queries import get_recommendation_for_push, log_push, should_push from app.db.schema import get_conn from app.db.tracking_queries import update_recommendation_tracking @@ -148,6 +149,15 @@ def _decorate_recommendation(item: dict) -> dict: item["strategy_name"] = strategy_label(item["strategy_code"]) item["strategy_snapshot"] = _loads_json(item.get("strategy_snapshot_json"), {}) item["factor_roles"] = _loads_json(item.get("factor_roles_json"), {}) + side = normalize_trade_side( + item["entry_plan"].get("side") + or item["market_context"].get("side") + or item.get("direction") + or (item["strategy_snapshot"].get("direction") if isinstance(item.get("strategy_snapshot"), dict) else "") + ) + item["side"] = side + item["side_label"] = side_label(side) + item["entry_plan"]["side"] = side rec_result, rec_result_label = _classify_recommendation_result(item) item["recommendation_result"] = rec_result item["recommendation_result_label"] = rec_result_label @@ -171,6 +181,12 @@ def get_active_recommendations(actionable_only: bool = False): result = [] for row in rows: item = _derive_execution_fields(dict(row)) + entry_plan = _loads_json(item.get("entry_plan_json"), {}) + side = normalize_trade_side(entry_plan.get("side") or item.get("direction")) + item["side"] = side + item["side_label"] = side_label(side) + item["entry_plan"] = entry_plan + item["entry_plan"]["side"] = side if actionable_only and not _is_actionable_execution_status(item.get("execution_status")): continue result.append(item) @@ -260,6 +276,7 @@ def get_active_recommendations_deduped( limit: int = 0, offset: int = 0, with_meta: bool = False, + side: str = "", ): """同 symbol 只保留最新 active 推荐,并附带派生执行状态。""" conn = get_conn() @@ -281,6 +298,10 @@ def get_active_recommendations_deduped( if hours > 0: where += " AND rec_time >= %s" params.append((datetime.now() - timedelta(hours=hours)).isoformat()) + side = normalize_trade_side(side) if side else "" + if side: + where += " AND direction=%s" + params.append("空头启动" if side == "short" else "多头启动") try: limit = max(0, int(limit or 0)) @@ -323,7 +344,7 @@ def get_active_recommendations_deduped( SELECT symbol, MAX(id) AS max_id FROM recommendation WHERE {where} - GROUP BY symbol + GROUP BY symbol, direction ) latest ON latest.max_id = r.id ORDER BY r.rec_time DESC """, @@ -344,6 +365,8 @@ def get_active_recommendations_deduped( "executable_now": 0, "planned_entry": 0, "watch_pool": 0, + "long_count": 0, + "short_count": 0, } now = datetime.now() for row in rows: @@ -366,6 +389,10 @@ def get_active_recommendations_deduped( if actionable_only and not _is_actionable_execution_status(item.get("execution_status")): continue all_items.append(item) + if item.get("side") == "short": + summary["short_count"] += 1 + else: + summary["long_count"] += 1 if item.get("is_discovery_burst"): summary["discovery_burst"] += 1 if item.get("is_executable_now"): @@ -538,6 +565,8 @@ def get_opportunity_detail(symbol: str = "", rec_id: int = 0) -> dict | None: "display_bucket": "watch_pool", "observe_tier": "weak" if _safe_int(coin_state["score"]) < 4 else "strong", "source": "coin_state", + "side": "long", + "side_label": "多", } screening = [] diff --git a/app/services/altcoin_confirm.py b/app/services/altcoin_confirm.py index 6859b6f..bcc713d 100644 --- a/app/services/altcoin_confirm.py +++ b/app/services/altcoin_confirm.py @@ -41,7 +41,13 @@ from app.config.config_loader import ( get_strategy_params, ) from app.core.opportunity_lifecycle import apply_entry_quality_gate -from app.core.strategy_registry import BOX_RETEST_1H_STRATEGY, BOX_RETEST_4H_STRATEGY, MAIN_COMPOSITE_STRATEGY +from app.core.strategy_registry import ( + BOX_RETEST_1H_STRATEGY, + BOX_RETEST_4H_STRATEGY, + BREAKDOWN_RETEST_SHORT_1H_STRATEGY, + MAIN_COMPOSITE_STRATEGY, +) +from app.core.trade_direction import direction_label, normalize_trade_side from app.core.opportunity_level import ( attach_opportunity_level, classify_opportunity_level, @@ -60,6 +66,7 @@ from app.strategies.altcoin_breakout import ( build_volume_ignition_1h_signal, ) from app.strategies.box_retest_4h import build_box_retest_1h_signal, build_box_retest_4h_signal +from app.strategies.short_breakdown import build_breakdown_retest_short_1h_signal, detect_breakdown_retest_short_1h from app.config.config_loader import _get_section as _get_cfg_section from app.core.pa_engine import ( classify_candles, calc_atr, find_supply_demand_zones, @@ -90,6 +97,7 @@ def _strategy_context_for_recommendation(symbol: str, result: dict, entry_plan: """Build and persist a standard strategy signal when an independent strategy matches.""" bp_1h = result.get("box_breakout_pullback_1h") or (result.get("market_context") or {}).get("box_breakout_pullback_1h") or {} bp_4h = result.get("box_breakout_pullback_4h") or (result.get("market_context") or {}).get("box_breakout_pullback_4h") or {} + short_1h = result.get("short_breakdown_retest_1h") or (result.get("market_context") or {}).get("short_breakdown_retest_1h") or {} market_regime = result.get("market_regime") or (result.get("market_context") or {}).get("market_regime") or {} signal_candidates = [] signal_candidates.extend([ @@ -119,6 +127,17 @@ def _strategy_context_for_recommendation(symbol: str, result: dict, entry_plan: decision_log=result.get("decision_log") or {}, ) ) + if short_1h.get("detected"): + signal_candidates.append( + build_breakdown_retest_short_1h_signal( + symbol=symbol, + current_price=result.get("price") or 0, + detection=short_1h, + entry_plan=entry_plan or {}, + market_regime=market_regime, + decision_log=result.get("decision_log") or {}, + ) + ) saved_payloads = [] for signal in [item for item in signal_candidates if item]: saved_payloads.append(insert_strategy_signal(signal)) @@ -170,17 +189,20 @@ def symbol_recently_closed(symbol: str, hours: int = 8) -> bool: return ((row[0] or 0) + (paper_row[0] or 0)) > 0 -def _active_recommendation_id(symbol: str) -> int: +def _active_recommendation_id(symbol: str, side: str = "") -> int: """Return the current non-history recommendation id for merge/write diagnostics.""" conn = get_conn() try: row = conn.execute( """ SELECT id FROM recommendation - WHERE symbol=%s AND status='active' AND COALESCE(display_bucket,'watch_pool') != 'history' + WHERE symbol=%s + AND (%s = '' OR direction=%s) + AND status='active' + AND COALESCE(display_bucket,'watch_pool') != 'history' ORDER BY id DESC LIMIT 1 """, - (symbol,), + (symbol, direction_label(side) if side else "", direction_label(side) if side else ""), ).fetchone() return int(row["id"] if row else 0) finally: @@ -1165,6 +1187,13 @@ def confirm_burst(symbol, cand): # 提取cand数据(v1.7.0:用于辅助信号检测) cand_detail = json.loads(cand.get("detail_json", "{}")) leader_status = cand.get("leader_status", "") + trade_side = normalize_trade_side( + cand.get("side") + or cand.get("direction") + or cand_detail.get("side") + or (cand_detail.get("market_context") or {}).get("side") + or cand_detail.get("direction") + ) cand_change_24h = 0.0 try: cand_change_24h = float(cand.get("change_24h") or cand_detail.get("change_24h") or 0) @@ -1199,6 +1228,11 @@ def confirm_burst(symbol, cand): bp_1h = {"detected": False} bp_4h = {"detected": False} bp_daily = {"detected": False} + short_1h = ( + cand_detail.get("short_breakdown_retest_1h") + or (cand_detail.get("market_context") or {}).get("short_breakdown_retest_1h") + or {"detected": False} + ) upstream_sector_context = cand_detail.get("sector_context") or {} if upstream_sector_context.get("hot_sectors") or upstream_sector_context.get("leader_symbol"): @@ -1287,6 +1321,25 @@ def confirm_burst(symbol, cand): if t and _safe_age_bars(bp_1h.get("pullback_age_bars")) <= 2: current_trigger_times.append(t) + if trade_side == "short": + try: + if not short_1h.get("detected") and h1_df is not None and len(h1_df) >= 60: + short_1h = detect_breakdown_retest_short_1h(h1_df, change_24h=cand_change_24h) + except Exception: + short_1h = {"detected": False} + if short_1h.get("detected"): + signals.extend(short_1h.get("signals") or ["1H破位反抽做空结构"]) + score += factor_scorer.add_existing( + "breakdown_retest_1h_short", + short_1h.get("score", 0), + evidence="1H箱体下破后反抽失败", + value=short_1h, + cap=9, + ) + t = _event_time_from_age(h1_df, short_1h.get("pullback_age_bars")) + if t and _safe_age_bars(short_1h.get("pullback_age_bars")) <= 2: + current_trigger_times.append(t) + # ---- PA引擎:4H级别(阻力/支撑) ---- pa_4h = full_pa_analysis(h4_df, "4h") if h4_df is not None and len(h4_df) >= 30 else {} @@ -1524,7 +1577,7 @@ def confirm_burst(symbol, cand): ) if not fresh_ok: signals.append("⛔ 候选过期:无近6小时当前触发,避免旧结构反复确认") - confirmed = fresh_ok and (vp_fly_count >= 1) + confirmed = fresh_ok and ((vp_fly_count >= 1) if trade_side != "short" else bool(short_1h.get("detected") and short_1h.get("retest_rejected"))) if not confirmed and fresh_ok: # 旧放量/旧起爆不能直接确认;但如果当前 15min/日线/结构给出强分, # 允许作为“历史强背景 + 当前结构确认”。避免修复时效后把所有结构型机会一刀切为0。 @@ -1671,7 +1724,57 @@ def confirm_burst(symbol, cand): stop_cfg = confirm_stop_loss_params() if confirmed and atr_1h > 0: - if entry_action == "即刻买入" and pa_15min_result: + short_entry_plan = None + if trade_side == "short": + entry_action = "即刻买入" if short_1h.get("retest_rejected") else "等回踩" + retest_zone = short_1h.get("retest_zone") or [] + if entry_action == "即刻买入": + entry_price = round(float(price), 6) + entry_method = "空头1H破位反抽失败,当前可开空" + else: + zone_mid = sum(float(x or 0) for x in retest_zone[:2]) / 2 if len(retest_zone) >= 2 else float(short_1h.get("breakdown_level") or price) + entry_price = round(max(float(price), zone_mid), 6) + entry_method = f"等反抽到${entry_price:.4f}(箱体下沿反压)" + stop_loss = round(float(short_1h.get("stop_level") or price * 1.055), 6) + tp1 = round(float(short_1h.get("target_1") or max(price * 0.9, price - (stop_loss - price) * 1.8)), 6) + tp2 = round(max(tp1 * 0.94, price - (stop_loss - price) * 2.8), 6) + risk = stop_loss - price + reward1 = price - tp1 + reward2 = price - tp2 + rr1 = round(reward1 / risk, 2) if risk > 0 else 0 + rr2 = round(reward2 / risk, 2) if risk > 0 else 0 + level_meta = { + "opportunity_level": "structure_watch", + "opportunity_level_label": "结构机会", + "holding_horizon": "短线", + "entry_model": "1H箱体破位反抽", + "stop_model": "反抽高点上方", + "tp_model": "下方量度空间", + "max_action": "buy_now" if entry_action == "即刻买入" else "wait_pullback", + } + entry_plan = { + "side": "short", + "entry_price": entry_price, + "entry_method": entry_method, + "entry_action": entry_action, + "stop_loss": stop_loss, + "stop_pct": round(((stop_loss - price) / price) * 100, 1) if price > 0 else 0, + "tp1": tp1, + "tp2": tp2, + "rr1": rr1, + "rr2": rr2, + "atr_1h": round(float(atr_1h), 6), + "current_price": round(float(price), 6), + "risk_reward_ok": rr1 >= 1.3, + "pa_15min_summary": "空头结构以1H反抽失败为主,15m仅作执行辅助", + "pa_1h_exhaustion": "low", + "stop_basis": "short_retest_high", + "tp_basis": "box_breakdown_measured_move", + } + entry_plan = attach_opportunity_level(entry_plan, level_meta) + short_entry_plan = dict(entry_plan) + gate_strategy_code = BREAKDOWN_RETEST_SHORT_1H_STRATEGY + elif entry_action == "即刻买入" and pa_15min_result: entry_price = round(float(price), 6) entry_method = "🟢15min即刻入场(突破进行中)" elif entry_action == "等回踩" and pa_15min_result.get("wait_price", 0) > 0: @@ -1783,11 +1886,15 @@ def confirm_burst(symbol, cand): } entry_plan = attach_opportunity_level(entry_plan, level_meta) - gate_strategy_code = ( - BOX_RETEST_1H_STRATEGY if bp_1h.get("detected") - else BOX_RETEST_4H_STRATEGY if bp_4h.get("detected") - else MAIN_COMPOSITE_STRATEGY - ) + if trade_side == "short" and short_entry_plan is not None: + entry_plan = short_entry_plan + gate_strategy_code = BREAKDOWN_RETEST_SHORT_1H_STRATEGY + else: + gate_strategy_code = ( + BOX_RETEST_1H_STRATEGY if bp_1h.get("detected") + else BOX_RETEST_4H_STRATEGY if bp_4h.get("detected") + else MAIN_COMPOSITE_STRATEGY + ) entry_plan.setdefault("strategy_code", gate_strategy_code) # v1.7.5 买点质量闸门:确认强势 ≠ 允许现价追买。 @@ -1870,6 +1977,8 @@ def confirm_burst(symbol, cand): market_context["trigger_context"] = trigger_context market_context["box_breakout_pullback_1h"] = bp_1h if 'bp_1h' in locals() else {} market_context["box_breakout_pullback_4h"] = bp_4h if 'bp_4h' in locals() else {} + market_context["short_breakdown_retest_1h"] = short_1h if 'short_1h' in locals() else {} + market_context["side"] = trade_side market_context["factor_score_breakdown"] = factor_score_breakdown market_context["onchain_context"] = onchain_context market_context["market_regime"] = market_regime @@ -1897,6 +2006,7 @@ def confirm_burst(symbol, cand): }, ) if entry_plan: + entry_plan["side"] = trade_side entry_plan["factor_score_breakdown"] = factor_score_breakdown entry_plan["onchain_context"] = onchain_context entry_plan["market_regime"] = market_regime @@ -1917,6 +2027,8 @@ def confirm_burst(symbol, cand): "pa_1d": pa_1d, "box_breakout_pullback_1h": bp_1h if 'bp_1h' in locals() else {}, "box_breakout_pullback_4h": bp_4h if 'bp_4h' in locals() else {}, + "short_breakdown_retest_1h": short_1h if 'short_1h' in locals() else {}, + "side": trade_side, "m30_aligned": m30_aligned, "entry_action": (entry_plan or {}).get("entry_action") or entry_action, "market_context": market_context, @@ -1937,9 +2049,11 @@ def _watch_candidate_plan(symbol, result, cand_detail): market_context = result.get("market_context") or {} signals = list(result.get("signals") or []) price = float(result.get("price") or 0) + side = normalize_trade_side(result.get("side") or market_context.get("side") or cand_detail.get("side")) level_meta = classify_opportunity_level( signals=signals, entry_plan={ + "side": side, "entry_action": "观察", "entry_price": price, "current_price": price, @@ -1958,6 +2072,7 @@ def _watch_candidate_plan(symbol, result, cand_detail): "max_action": "observe", } plan = { + "side": side, "entry_action": "观察", "entry_price": price, "current_price": price, @@ -2085,9 +2200,6 @@ def main(compact: bool = False, verbose: bool = False, limit: int | None = None, # 飞书只是通知层:确认阶段不再绕过 recommendation 状态机直接推送。 # 先完成 create_recommendation + DB 状态派生,再用同一条状态结果决定是否通知。 - # 🟢 只做做多!方向永远多头 - rec_direction = get_strategy_direction() - # 🔴 v1.7.7 冷却期:刚止盈/止损的币不立即重新推荐 # Sahara案例:17:24止盈(+5%)→17:40重新推荐→现价跌-3% cooldown_hours = 8 if symbol_recently_closed(symbol, hours=8) else 0 @@ -2108,14 +2220,15 @@ def main(compact: bool = False, verbose: bool = False, limit: int | None = None, continue ep = result["entry_plan"] + rec_direction = direction_label(ep.get("side") or result.get("side")) rec_entry_price = ep.get("entry_price") or result["price"] - if ep.get("entry_action") in ("等回踩", "观察") and result.get("price"): + if normalize_trade_side(ep.get("side")) != "short" and ep.get("entry_action") in ("等回踩", "观察") and result.get("price"): plan_stop = float(ep.get("stop_loss") or 0) plan_tp1 = float(ep.get("tp1") or 0) plan_entry = float(ep.get("entry_price") or 0) if plan_entry > 0 and (plan_stop >= plan_entry or (plan_tp1 > 0 and plan_tp1 <= plan_entry)): rec_entry_price = result["price"] - previous_rec_id = _active_recommendation_id(symbol) + previous_rec_id = _active_recommendation_id(symbol, ep.get("side") or result.get("side")) strategy_ctx = _strategy_context_for_recommendation(symbol, result, ep) if strategy_ctx.get("strategy_code"): ep["strategy_code"] = strategy_ctx["strategy_code"] @@ -2189,7 +2302,7 @@ def main(compact: bool = False, verbose: bool = False, limit: int | None = None, signals=result.get("signals", []), is_meme=int(is_meme_coin(symbol)), entry_plan=watch_plan, - direction=get_strategy_direction(), + direction=direction_label(watch_plan.get("side") or result.get("side")), market_context=result.get("market_context"), derivatives_context=result.get("derivatives_context"), sector_context=result.get("sector_context"), diff --git a/app/services/altcoin_screener.py b/app/services/altcoin_screener.py index 5226120..3634a59 100644 --- a/app/services/altcoin_screener.py +++ b/app/services/altcoin_screener.py @@ -67,6 +67,8 @@ from app.db.universe_audit import ( record_universe_decisions, ) from app.db.short_tf_signals import record_short_tf_samples +from app.core.trade_direction import direction_label +from app.strategies.short_breakdown import detect_breakdown_retest_short_1h exchange = ccxt.binance({"enableRateLimit": True}) REPO_ROOT = Path(__file__).resolve().parents[2] @@ -1163,6 +1165,7 @@ def layer1_coarse_filter(): bb_data = None static_accumulation = None short_tf_ignition = None + short_breakdown_1h = {"detected": False} # 1H量价齐飞检测(核心) kline_attempt_symbols.add(symbol) @@ -1212,6 +1215,13 @@ def layer1_coarse_filter(): anomalies.append(f"1H放量({vp_data['max_vol_ratio']}x)但无量价齐飞(量价背离)") anomaly_score += 1 # 量价背离最低分 + short_breakdown_1h = detect_breakdown_retest_short_1h(h1_df, change_24h=change) + if short_breakdown_1h.get("detected"): + quality = float(short_breakdown_1h.get("quality") or 0) + if quality >= 0.48: + anomalies.extend(short_breakdown_1h.get("signals") or ["1H破位反抽做空结构"]) + anomaly_score += int(short_breakdown_1h.get("score") or 4) + # 布林收窄检测(4H级别) if h4_df is not None and len(h4_df) >= 20: bb_data = detect_bollinger_squeeze(h4_df) @@ -1307,6 +1317,7 @@ def layer1_coarse_filter(): "bb_data": bb_data, "static_accumulation": static_accumulation, "short_tf_ignition": short_tf_ignition, + "short_breakdown_retest_1h": short_breakdown_1h, "h4_df": h4_df, "turnover_acceleration_1h": turnover_acc_1h, "turnover_acceleration_4h": turnover_acc_4h, @@ -1838,15 +1849,15 @@ def layer2_fine_filter(candidates): leader_symbol = info["leader"] leader_pct = info.get("leader_pct", 0) - # 🟢 只做做多!空头信号只记录不加分,方向永远多头 - # 空头起爆/空头加速只是衰减参考,不生成推荐 - direction = get_strategy_direction() - direction_num = 1 + side = "short" if (cand.get("short_breakdown_retest_1h") or {}).get("detected") else "long" + direction = direction_label(side) + direction_num = -1 if side == "short" else 1 qualified[symbol] = { "state": state, "score": score, "signals": signals, + "side": side, "direction": direction, "direction_num": direction_num, "sector": sector_str, @@ -1870,6 +1881,8 @@ def layer2_fine_filter(candidates): "trigger_context": {"trigger_status": _build_signal_recency(cand).get("status"), "current_triggers": _build_signal_recency(cand).get("current"), "stale_background": _build_signal_recency(cand).get("stale")}, "turnover_acceleration_1h": cand.get("turnover_acceleration_1h"), "turnover_acceleration_4h": cand.get("turnover_acceleration_4h"), + "side": side, + "short_breakdown_retest_1h": cand.get("short_breakdown_retest_1h") or {}, }, "derivatives_context": { "funding_rate": cand.get("funding_rate"), diff --git a/app/services/price_tracker.py b/app/services/price_tracker.py index 6fcccc0..4b4ef75 100644 --- a/app/services/price_tracker.py +++ b/app/services/price_tracker.py @@ -36,6 +36,8 @@ from app.core.pa_engine import ( ) from app.config.config_loader import load_rules from app.core.opportunity_lifecycle import apply_entry_quality_gate +from app.core.trade_math import pnl_pct as side_pnl_pct, should_stop_loss, should_take_profit +from app.core.trade_direction import normalize_trade_side from app.db.paper_trading import sync_recommendation as sync_paper_trade exchange = ccxt.binance({"enableRateLimit": True}) @@ -116,6 +118,7 @@ def analyze_tracking_signals(symbol, rec, current_price): tp1 = rec.get("tp1", entry_price * 1.03) tp2 = rec.get("tp2", entry_price * 1.05) entry_plan = rec.get("entry_plan") or {} + side = normalize_trade_side(entry_plan.get("side") or rec.get("side") or rec.get("direction")) # ---- 拉取1H数据做趋势分析 ---- h1_df = fetch_klines(symbol, "1h", limit=100) @@ -138,10 +141,10 @@ def analyze_tracking_signals(symbol, rec, current_price): sell_signals.append("趋势中度衰减,关注止盈") # ---- 止盈信号检测 ---- - pnl_pct = ((current_price / entry_price) - 1) * 100 if entry_price > 0 else 0 + pnl_pct = side_pnl_pct(side, entry_price, current_price) if entry_price > 0 else 0 # TP/SL 是策略交易生命周期,不再写成推荐信号动作。 - if tp1 > 0 and current_price >= tp1: + if tp1 > 0 and should_take_profit(side, current_price, tp1): sell_signals.append(f"策略交易目标价已到达(${tp1:.4f}),执行结果以交易账本为准") rules = load_rules() @@ -151,11 +154,12 @@ def analyze_tracking_signals(symbol, rec, current_price): # ---- 止损接近警告 ---- if stop_loss > 0: - loss_pct = ((current_price / stop_loss) - 1) * 100 + loss_pct = ((current_price / stop_loss) - 1) * 100 if side != "short" else ((stop_loss / current_price) - 1) * 100 if loss_pct < 3: # 当前价离止损不到3% sell_signals.append(f"⚠️ 接近止损!当前${current_price:.4f}离止损${stop_loss:.4f}仅{loss_pct:.1f}%") - if current_price <= stop_loss: - sell_signals.append(f"🔴 策略交易止损价已触达!${current_price:.4f}≤${stop_loss:.4f},执行结果以交易账本为准") + if should_stop_loss(side, current_price, stop_loss): + op = "≥" if side == "short" else "≤" + sell_signals.append(f"🔴 策略交易止损价已触达!${current_price:.4f}{op}${stop_loss:.4f},执行结果以交易账本为准") # ---- 趋势反转信号(PA行为检测,替代MACD) ---- if h1_df is not None and len(h1_df) >= 30 and atr_1h > 0: @@ -214,7 +218,7 @@ def analyze_tracking_signals(symbol, rec, current_price): if action_status == "持有" and current_action in ("等回踩", "🟡等回踩") and h4_df is not None and atr_1h > 0: pa_4h = full_pa_analysis(h4_df, "4h") h4_zones = pa_4h.get("zones", []) - direction = 1 # 做多方向 + direction = -1 if side == "short" else 1 # 重新做15min入场点分析 if m15_df is not None and len(m15_df) >= 20: @@ -225,15 +229,15 @@ def analyze_tracking_signals(symbol, rec, current_price): new_action = entry_result.get("action", "等回踩") if new_action == "即刻买入": - buy_signals.append(f"🟢 回踩确认完毕!可即刻入场(15min动K确认)") + buy_signals.append("🟢 反抽确认完毕!可开空(15min动K确认)" if side == "short" else "🟢 回踩确认完毕!可即刻入场(15min动K确认)") action_status = "可即刻买入" elif new_action == "等回踩": wait_price = entry_result.get("wait_price", 0) if wait_price > 0: # 检查当前价是否接近回踩目标 - dist_pct = ((current_price / wait_price) - 1) * 100 + dist_pct = ((current_price / wait_price) - 1) * 100 if side != "short" else ((wait_price / current_price) - 1) * 100 if abs(dist_pct) < 2: - buy_signals.append(f"🟢 当前价接近回踩目标!${current_price:.4f}≈${wait_price:.4f}") + buy_signals.append(f"🟢 当前价接近反抽目标!${current_price:.4f}≈${wait_price:.4f}" if side == "short" else f"🟢 当前价接近回踩目标!${current_price:.4f}≈${wait_price:.4f}") action_status = "可即刻买入" entry_update = { diff --git a/app/strategies/short_breakdown.py b/app/strategies/short_breakdown.py index 4ab6aa6..bc93e2a 100644 --- a/app/strategies/short_breakdown.py +++ b/app/strategies/short_breakdown.py @@ -20,6 +20,71 @@ def _safe_float(value, default=0.0) -> float: return default +def detect_breakdown_retest_short_1h(df, *, change_24h: float = 0.0) -> dict: + """Detect a 1H breakdown followed by a failed retest of the broken box. + + This is intentionally conservative: the setup needs a prior box, a close + below support, a retest near the broken support, and rejection back below it. + """ + if df is None or len(df) < 60: + return {"detected": False, "reason": "insufficient_data"} + try: + work = df.tail(72).copy() + prev = work.iloc[:-8] + recent = work.iloc[-8:] + current = float(work["close"].iloc[-1]) + box_low = float(prev["low"].tail(48).min()) + box_high = float(prev["high"].tail(48).max()) + if current <= 0 or box_low <= 0 or box_high <= box_low: + return {"detected": False, "reason": "invalid_box"} + box_width_pct = (box_high / box_low - 1) * 100 + if box_width_pct > 38: + return {"detected": False, "reason": "box_too_wide", "box_width_pct": round(box_width_pct, 2)} + closes = [float(x) for x in recent["close"].tolist()] + highs = [float(x) for x in recent["high"].tolist()] + opens = [float(x) for x in recent["open"].tolist()] + breakdown_closes = [x for x in closes if x < box_low * 0.992] + touched_retest = any(box_low * 0.988 <= h <= box_low * 1.035 for h in highs[-5:]) + latest_rejected = current < box_low * 0.992 and closes[-1] < opens[-1] + below_ema = current < float(work["close"].ewm(span=25, adjust=False).mean().iloc[-1]) + if not breakdown_closes or not touched_retest: + return { + "detected": False, + "reason": "no_breakdown_retest", + "box_low": round(box_low, 8), + "box_high": round(box_high, 8), + } + stop_level = max(max(highs[-5:]) * 1.012, box_low * 1.025) + risk = max(stop_level - current, current * 0.025) + target_1 = max(current - risk * 1.8, current * 0.72) + quality_score = 0 + quality_score += 3 if latest_rejected else 1 + quality_score += 2 if below_ema else 0 + quality_score += 2 if float(change_24h or 0) <= -3 else 0 + quality_score += 1 if box_width_pct <= 24 else 0 + quality = "优质" if quality_score >= 7 else "良好" if quality_score >= 5 else "普通" + return { + "detected": True, + "breakdown_level": round(box_low, 8), + "box_high": round(box_high, 8), + "box_width_pct": round(box_width_pct, 2), + "retest_zone": round(box_low, 8), + "stop_level": round(stop_level, 8), + "target_1": round(target_1, 8), + "quality": quality, + "score": quality_score, + "retest_rejected": latest_rejected, + "relative_weakness": bool(below_ema or float(change_24h or 0) <= -3), + "pullback_age_bars": 0, + "signals": [ + f"1H破位反抽做空(破位{box_low:.6g})", + "反抽失败确认" if latest_rejected else "等待反抽失败确认", + ], + } + except Exception as exc: + return {"detected": False, "reason": f"error:{exc}"} + + def build_breakdown_retest_short_1h_signal( *, symbol: str, @@ -110,4 +175,4 @@ def build_breakdown_retest_short_1h_signal( ) -__all__ = ["build_breakdown_retest_short_1h_signal"] +__all__ = ["build_breakdown_retest_short_1h_signal", "detect_breakdown_retest_short_1h"] diff --git a/app/web/routes_recommendations.py b/app/web/routes_recommendations.py index db6cf4c..f778d9c 100644 --- a/app/web/routes_recommendations.py +++ b/app/web/routes_recommendations.py @@ -76,6 +76,7 @@ async def api_recommendations( archive_filter: str = "", paged: bool = False, compact: bool = False, + side: str = "", altcoin_session: str = Cookie(default=""), ): require_api_user_with_subscription(altcoin_session) @@ -99,6 +100,7 @@ async def api_recommendations_active( offset: int = 0, paged: bool = False, compact: bool = False, + side: str = "", altcoin_session: str = Cookie(default=""), ): require_api_user_with_subscription(altcoin_session) @@ -110,6 +112,7 @@ async def api_recommendations_active( limit=limit, offset=offset, with_meta=(paged or compact), + side=side, ) return get_active_recommendations(actionable_only=actionable_only) diff --git a/static/app.html b/static/app.html index e78ea55..d65ecf2 100644 --- a/static/app.html +++ b/static/app.html @@ -97,6 +97,9 @@ .summary-tags { display:flex; align-items:center; justify-content:space-between; gap:10px; border-top:1px solid var(--hairline-soft); padding-top:10px; } .summary-chips { display:flex; gap:5px; flex-wrap:wrap; min-width:0; } .summary-chip { display:inline-flex; border:1px solid var(--hairline-soft); background:var(--surface); border-radius:999px; padding:4px 8px; color:var(--slate); font-size:11px; font-weight:850; max-width:150px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } +.side-badge { display:inline-flex; align-items:center; border-radius:999px; padding:3px 8px; font-size:11px; font-weight:950; border:1px solid var(--hairline-soft); } +.side-badge.long { color:var(--green); background:rgba(0,180,115,.08); border-color:rgba(0,180,115,.18); } +.side-badge.short { color:var(--red); background:rgba(238,61,76,.08); border-color:rgba(238,61,76,.18); } .detail-link { color:var(--blue); font-size:12px; font-weight:950; white-space:nowrap; } .card-bar { display: flex; align-items: center; justify-content: space-between; padding: 16px 18px 0; gap: 8px; } .coin-left { display: flex; align-items: center; gap: 10px; min-width: 0; } @@ -379,6 +382,7 @@ var curTab = 'live'; var latestVersion = ''; var currentVersion = ''; var currentFilter = ''; +var currentSideFilter = ''; var cachedLiveData = []; var liveOffset = 0; var liveLimit = 24; @@ -658,6 +662,17 @@ function opportunityUrl(r) { function goOpportunity(r) { location.href = opportunityUrl(r); } +function recSide(r) { + var side = String((r && (r.side || (r.entry_plan && r.entry_plan.side))) || '').toLowerCase(); + return side === 'short' ? 'short' : 'long'; +} +function sideText(r) { return recSide(r) === 'short' ? '空' : '多'; } +function sideBadgeHtml(r) { var s = recSide(r); return ''+(s === 'short' ? '空' : '多')+''; } +function sideChangePct(price, ref, side) { + price = Number(price || 0); ref = Number(ref || 0); + if (!price || !ref) return null; + return side === 'short' ? ((ref - price) / ref * 100) : ((price - ref) / ref * 100); +} // ====== LIVE ====== function isExpiredRec(r) { @@ -673,7 +688,7 @@ async function loadContent(reset) { liveLoading = true; try { var offset = (reset === false) ? liveOffset : 0; - var url = API+'/api/recommendations/active?with_tracking=true&actionable_only=false&hours=12&limit='+liveLimit+'&offset='+offset+'&compact=true'; + var url = API+'/api/recommendations/active?with_tracking=true&actionable_only=false&hours=12&limit='+liveLimit+'&offset='+offset+'&compact=true'+(currentSideFilter ? '&side='+encodeURIComponent(currentSideFilter) : ''); var resp = await fetch(url); var page = await resp.json(); var items = Array.isArray(page.items) ? page.items : (Array.isArray(page) ? page : []); @@ -744,6 +759,7 @@ function isRenderableLiveRec(r) { } function applyFilterAndRender() { var visible = cachedLiveData.filter(function(r){ return isRenderableLiveRec(r); }); + if (currentSideFilter) visible = visible.filter(function(r){ return recSide(r) === currentSideFilter; }); var weakCount = visible.filter(isWeakObserveRec).length; var filtered = visible.filter(function(r){ return !isWeakObserveRec(r); }); if (!currentFilter) { @@ -765,6 +781,11 @@ function setFilter(status) { refreshVisibleKlines(); } +function setSideFilter(side) { + currentSideFilter = side || ''; + loadContent(true); +} + function renderLiveStats(data) { var visible = []; try { @@ -776,16 +797,23 @@ function renderLiveStats(data) { var buy = visible.filter(function(r){ return r.execution_status === 'buy_now' || r.display_bucket === 'realtime'; }).length; var observeStrong = visible.filter(function(r){ return (r.display_bucket === 'watch_pool' || (r.execution_status !== 'buy_now' && r.display_bucket !== 'realtime')) && r.observe_tier !== 'weak'; }).length; var observeWeak = visible.filter(function(r){ return r.observe_tier === 'weak'; }).length; + var longCount = visible.filter(function(r){ return recSide(r) === 'long'; }).length; + var shortCount = visible.filter(function(r){ return recSide(r) === 'short'; }).length; var allCls = 'stat-chip' + (!currentFilter ? ' filterable active' : ' filterable'); var bCls = 'stat-chip' + (currentFilter === 'buy_now' ? ' filterable active' : ' filterable'); var oCls = 'stat-chip observe-chip' + (currentFilter === 'observe' ? ' filterable active' : ' filterable'); var wCls = 'stat-chip weak-chip' + (currentFilter === 'weak_observe' ? ' filterable active' : ' filterable'); + var lCls = 'stat-chip' + (currentSideFilter === 'long' ? ' filterable active' : ' filterable'); + var sCls = 'stat-chip' + (currentSideFilter === 'short' ? ' filterable active' : ' filterable'); $('liveStats').innerHTML = '
'+decisionReason+'
'+decisionReason+'
'+esc(statusLabel(r))+'
'+esc(clean(r.execution_reason||r.state_reason||dl.decision||'等待进一步确认'))+'
'+esc(clean(ep.entry_model||ep.entry_method||'--'))+'
'+esc(clean(ep.invalid_if||ep.stop_basis||'跌破风险边界或信号衰减'))+'
'+esc(statusLabel(r))+'
'+esc(clean(r.execution_reason||r.state_reason||dl.decision||'等待进一步确认'))+'
'+esc(clean(ep.entry_model||ep.entry_method||'--'))+'
'+esc(clean(ep.invalid_if||ep.stop_basis||'跌破风险边界或信号衰减'))+'