From 447edff0f6fb6ec402057e5ba81a0086e7a54566 Mon Sep 17 00:00:00 2001 From: aaron <> Date: Sun, 31 May 2026 19:00:46 +0800 Subject: [PATCH] update --- AGENTS.md | 5 +- app/core/factor_roles.py | 3 + app/core/signal_taxonomy.py | 6 ++ app/core/strategy_registry.py | 23 ++++++ app/core/trade_math.py | 112 ++++++++++++++++++++++++++++ app/core/trailing_stop.py | 18 ++++- app/db/paper_trading.py | 85 ++++++++++++++-------- app/db/strategy_insights.py | 25 ++++++- app/strategies/short_breakdown.py | 113 +++++++++++++++++++++++++++++ app/web/routes_paper_trading.py | 9 ++- static/paper_trading.html | 61 +++++++++++++--- tests/test_multi_strategy_infra.py | 28 +++++++ tests/test_paper_trading.py | 56 ++++++++++++++ tests/test_trade_math.py | 30 ++++++++ tests/test_trailing_stop.py | 32 ++++++++ 15 files changed, 557 insertions(+), 49 deletions(-) create mode 100644 app/core/trade_math.py create mode 100644 app/strategies/short_breakdown.py create mode 100644 tests/test_trade_math.py diff --git a/AGENTS.md b/AGENTS.md index 33e7ac6..46a54f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -180,6 +180,8 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组 - 市场环境识别中心,第一版基于市场快照、BTC/ETH 涨跌、山寨涨跌广度、强势/大跌数量和 funding 热度识别 `risk_off`、`btc_main_uptrend`、`altcoin_rotation`、`sideways_chop`、`meme_frenzy`、`unknown`。 - `app/core/global_risk.py` - paper trading 全局风控门禁。单币机会进入开仓或挂单成交前,需要先检查市场环境和账户风险;critical 禁止新开仓,high 只允许高质量机会。 +- `app/core/trade_math.py` + - 多空通用交易数学中心。负责 side 归一、开仓/平仓滑点价、PnL、止损/止盈/移动止盈触发、保护价收紧比较。paper/live 不应各自实现一套多空收益和触发判断。 - `app/core/order_lifecycle.py` - 挂单生命周期决策中心。负责限价单是否触价、是否过期、是否远离入场、RR 与入场距离计算;不写 DB、不取消订单、不调用交易所。paper/live 适配层只能消费它的 `OrderLifecycleDecision`。 - `app/core/trailing_stop.py` @@ -187,7 +189,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 中。 -- 多策略基础设施当前内置 `main_composite_v1`、`box_retest_4h_v1`、`box_retest_1h_v1`、`volume_ignition_1h_v1`、`compression_breakout_4h_v1`、`intraday_momentum_15m_v1`。`box_breakout_pullback_4h` / `box_breakout_pullback_1h`、`vp_fly_1h_current`、`short_tf_15m_ignition` 等只是因子,只有和入场确认、风控、失效条件组成完整剧本后,才作为对应策略信号写入 `strategy_signals`。 +- 交易执行层已支持 `side=long/short`:paper trading 开仓、平仓、PnL、TP/SL、移动止盈、持仓健康和挂单触价都按 side 处理。当前已注册第一版独立空头策略 `breakdown_retest_short_1h_v1`,但这不代表发现/确认层已经全量接入空头扫描;新增做空必须继续使用独立 `strategy_code`、触发条件、失效条件和复盘口径,不能把多头策略简单反向。 +- 多策略基础设施当前内置 `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`。 ## 5. 数据与状态中心 diff --git a/app/core/factor_roles.py b/app/core/factor_roles.py index 4ad7648..c47c00c 100644 --- a/app/core/factor_roles.py +++ b/app/core/factor_roles.py @@ -31,6 +31,9 @@ VALID_FACTOR_ROLES = { DEFAULT_FACTOR_ROLES: dict[str, str] = { "box_breakout_pullback_4h": TRIGGER, "box_breakout_pullback_1h": TRIGGER, + "breakdown_retest_1h_short": TRIGGER, + "retest_reject_15m_short": ENTRY, + "market_risk_off_short": CONFIRMATION, "vp_fly_1h_current": TRIGGER, "volume_consecutive_1h": CONFIRMATION, "volume_divergence_1h": RISK, diff --git a/app/core/signal_taxonomy.py b/app/core/signal_taxonomy.py index d7974cd..a3115e3 100644 --- a/app/core/signal_taxonomy.py +++ b/app/core/signal_taxonomy.py @@ -24,6 +24,9 @@ SIGNAL_CODE_LABELS = { "compression_surge_4h": "4H压缩放量", "box_breakout_pullback_1h": "1H箱体突破回踩", "box_breakout_pullback_4h": "4H箱体突破回踩", + "breakdown_retest_1h_short": "1H破位反抽做空", + "retest_reject_15m_short": "15min反抽失败", + "market_risk_off_short": "弱势环境空头确认", "ignition_1h_current": "1H当前起爆点", "ignition_4h_current": "4H当前起爆点", "ignition_d1_current": "日线当前起爆点", @@ -67,6 +70,9 @@ _PATTERNS = [ ("static_accum_4h", ("静K蓄力", "静K旁路")), ("box_breakout_pullback_1h", ("1H", "箱体", "突破", "回踩")), ("box_breakout_pullback_4h", ("4H", "箱体", "突破", "回踩")), + ("breakdown_retest_1h_short", ("1H", "破位", "反抽", "做空")), + ("retest_reject_15m_short", ("15min", "反抽失败")), + ("market_risk_off_short", ("risk_off", "空头")), ("higher_lows_4h", ("底部抬高",)), ("compression_surge_4h", ("压缩放量",)), ("ignition_stale", ("历史起爆点", "起爆点已过期", "旧起爆")), diff --git a/app/core/strategy_registry.py b/app/core/strategy_registry.py index 495c67d..e58fac3 100644 --- a/app/core/strategy_registry.py +++ b/app/core/strategy_registry.py @@ -11,6 +11,7 @@ BOX_RETEST_4H_STRATEGY = "box_retest_4h_v1" VOLUME_IGNITION_1H_STRATEGY = "volume_ignition_1h_v1" COMPRESSION_BREAKOUT_4H_STRATEGY = "compression_breakout_4h_v1" INTRADAY_MOMENTUM_15M_STRATEGY = "intraday_momentum_15m_v1" +BREAKDOWN_RETEST_SHORT_1H_STRATEGY = "breakdown_retest_short_1h_v1" @dataclass(frozen=True) @@ -139,6 +140,28 @@ STRATEGY_DEFINITIONS: dict[str, StrategyDefinition] = { "dynamic_leverage_min": 3, }, ), + BREAKDOWN_RETEST_SHORT_1H_STRATEGY: StrategyDefinition( + strategy_code=BREAKDOWN_RETEST_SHORT_1H_STRATEGY, + strategy_name="1H破位反抽做空", + description="箱体或关键均线破位后反抽失败的空头策略;只用于独立空头样本,不与多头突破策略共享入场门槛。", + mode="paper_only", + entry_gate_config={ + "direction": "short", + "min_entry_score_buy_now": 2, + "min_entry_score_wait_pullback": 1, + "min_rr_buy_now": 1.5, + "breakdown_distance_wait_pct": 10, + "max_retest_deviation_pct": 8, + }, + paper_config={ + "entry_min_rr": 1.8, + "order_min_rr": 1.8, + "order_min_distance_to_entry_pct": 0, + "order_require_current_trigger": True, + "dynamic_leverage_enabled": True, + "dynamic_leverage_min": 2, + }, + ), } diff --git a/app/core/trade_math.py b/app/core/trade_math.py new file mode 100644 index 0000000..4e0ecfe --- /dev/null +++ b/app/core/trade_math.py @@ -0,0 +1,112 @@ +"""Side-aware execution math shared by paper and live trading adapters.""" + +from __future__ import annotations + + +def safe_float(value, default: float = 0.0) -> float: + try: + if value is None or value == "": + return default + return float(value) + except Exception: + return default + + +def normalize_side(side: str | None) -> str: + return "short" if str(side or "").strip().lower() == "short" else "long" + + +def open_price(side: str, current_price: float, slippage_pct: float = 0.0) -> float: + price = safe_float(current_price) + slip = max(0.0, safe_float(slippage_pct)) + if price <= 0: + return 0.0 + if normalize_side(side) == "short": + return round(price * (1 - slip / 100), 12) + return round(price * (1 + slip / 100), 12) + + +def close_price(side: str, current_price: float, slippage_pct: float = 0.0) -> float: + price = safe_float(current_price) + slip = max(0.0, safe_float(slippage_pct)) + if price <= 0: + return 0.0 + if normalize_side(side) == "short": + return round(price * (1 + slip / 100), 12) + return round(price * (1 - slip / 100), 12) + + +def pnl_pct(side: str, entry_price: float, current_price: float) -> float: + entry = safe_float(entry_price) + price = safe_float(current_price) + if entry <= 0 or price <= 0: + return 0.0 + if normalize_side(side) == "short": + return round((entry / price - 1) * 100, 4) + return round((price / entry - 1) * 100, 4) + + +def stop_loss_distance_pct(side: str, entry_price: float, stop_loss: float) -> float: + entry = safe_float(entry_price) + stop = safe_float(stop_loss) + if entry <= 0 or stop <= 0: + return 0.0 + if normalize_side(side) == "short": + return max(0.0, (stop / entry - 1) * 100) + return max(0.0, (1 - stop / entry) * 100) + + +def should_stop_loss(side: str, current_price: float, stop_loss: float) -> bool: + price = safe_float(current_price) + stop = safe_float(stop_loss) + if price <= 0 or stop <= 0: + return False + if normalize_side(side) == "short": + return price >= stop + return price <= stop + + +def should_take_profit(side: str, current_price: float, target: float) -> bool: + price = safe_float(current_price) + tp = safe_float(target) + if price <= 0 or tp <= 0: + return False + if normalize_side(side) == "short": + return price <= tp + return price >= tp + + +def should_hit_trailing_stop(side: str, current_price: float, trailing_stop: float) -> bool: + price = safe_float(current_price) + stop = safe_float(trailing_stop) + if price <= 0 or stop <= 0: + return False + if normalize_side(side) == "short": + return price >= stop + return price <= stop + + +def tighter_stop(side: str, current_stop: float, candidate_stop: float) -> float: + current = safe_float(current_stop) + candidate = safe_float(candidate_stop) + if candidate <= 0: + return current + if current <= 0: + return candidate + if normalize_side(side) == "short": + return min(current, candidate) + return max(current, candidate) + + +__all__ = [ + "close_price", + "normalize_side", + "open_price", + "pnl_pct", + "safe_float", + "should_hit_trailing_stop", + "should_stop_loss", + "should_take_profit", + "stop_loss_distance_pct", + "tighter_stop", +] diff --git a/app/core/trailing_stop.py b/app/core/trailing_stop.py index 428c5dd..46c7c17 100644 --- a/app/core/trailing_stop.py +++ b/app/core/trailing_stop.py @@ -9,6 +9,8 @@ from __future__ import annotations from dataclasses import dataclass, field +from app.core.trade_math import normalize_side, tighter_stop + def _safe_float(value, default: float = 0.0) -> float: try: @@ -158,12 +160,20 @@ def evaluate_trailing_stop( detail={"reason": "below_activation"}, ) + side = normalize_side(position.get("side")) distance_pct = _safe_float(profile.get("distance_pct"), cfg.get("distance_pct")) - protection_floor = entry_price * (1 + _safe_float(cfg.get("min_lock_profit_pct")) / 100) - candidate = current_price * (1 - distance_pct / 100) - new_trail = round(max(current_trail, protection_floor, candidate), 12) + if side == "short": + protection_floor = entry_price * (1 - _safe_float(cfg.get("min_lock_profit_pct")) / 100) + candidate = current_price * (1 + distance_pct / 100) + new_trail = round(tighter_stop(side, current_trail, min(protection_floor, candidate)), 12) + else: + protection_floor = entry_price * (1 + _safe_float(cfg.get("min_lock_profit_pct")) / 100) + candidate = current_price * (1 - distance_pct / 100) + new_trail = round(tighter_stop(side, current_trail, max(protection_floor, candidate)), 12) activated = current_trail <= 0 and new_trail > 0 - moved = current_trail > 0 and new_trail > current_trail + 1e-12 + moved = current_trail > 0 and ( + new_trail < current_trail - 1e-12 if side == "short" else new_trail > current_trail + 1e-12 + ) if not activated and not moved: return TrailingStopDecision( action="hold", diff --git a/app/db/paper_trading.py b/app/db/paper_trading.py index 6277a60..d8af9cc 100644 --- a/app/db/paper_trading.py +++ b/app/db/paper_trading.py @@ -16,6 +16,17 @@ from app.core.order_lifecycle import ( ) from app.core.position_health import evaluate_position_health from app.core.trailing_stop import evaluate_trailing_stop, normalize_trailing_config +from app.core.trade_math import ( + close_price as side_close_price, + normalize_side, + open_price as side_open_price, + pnl_pct as side_pnl_pct, + should_hit_trailing_stop, + should_stop_loss, + should_take_profit, + stop_loss_distance_pct as side_stop_loss_distance_pct, + tighter_stop, +) from app.core.strategy_registry import normalize_strategy_code, strategy_label, strategy_paper_config from app.db.schema import get_conn from app.db.system_logs import record_system_error @@ -317,26 +328,20 @@ def _parse_time(value: str): return None -def _open_price(current_price: float, config: dict | None = None) -> float: - return round(current_price * (1 + default_slippage_pct(config) / 100), 12) +def _open_price(current_price: float, config: dict | None = None, side: str = "long") -> float: + return side_open_price(side, current_price, default_slippage_pct(config)) -def _close_price(current_price: float, config: dict | None = None) -> float: - return round(current_price * (1 - default_slippage_pct(config) / 100), 12) +def _close_price(current_price: float, config: dict | None = None, side: str = "long") -> float: + return side_close_price(side, current_price, default_slippage_pct(config)) -def _trade_pnl_pct(entry_price: float, current_price: float) -> float: - if entry_price <= 0 or current_price <= 0: - return 0.0 - return round((current_price / entry_price - 1) * 100, 4) +def _trade_pnl_pct(entry_price: float, current_price: float, side: str = "long") -> float: + return side_pnl_pct(side, entry_price, current_price) def _stop_loss_distance_pct(side: str, entry_price: float, stop_loss: float) -> float: - if entry_price <= 0 or stop_loss <= 0: - return 0.0 - if str(side or "long").lower() == "short": - return max(0.0, (stop_loss / entry_price - 1) * 100) - return max(0.0, (1 - stop_loss / entry_price) * 100) + return side_stop_loss_distance_pct(side, entry_price, stop_loss) def _stop_loss_leverage_risk_pct(side: str, entry_price: float, stop_loss: float, leverage: float) -> float: @@ -637,9 +642,9 @@ def _open_trade(conn, rec: dict, current_price: float, event_time: str, config: rec_id = _safe_int(rec.get("id")) symbol = str(rec.get("symbol") or "").strip().upper() plan = _entry_plan(rec) - entry_price = _open_price(current_price, cfg) + side = normalize_side(plan.get("side") or rec.get("side")) + entry_price = _open_price(current_price, cfg, side) notional = default_notional_usdt(cfg) - side = str(plan.get("side") or rec.get("side") or "long").strip().lower() or "long" leverage = default_leverage(cfg) stop_loss = _safe_float(plan.get("stop_loss") or rec.get("stop_loss")) tp1 = _safe_float(plan.get("tp1") or plan.get("take_profit_1") or rec.get("tp1")) @@ -735,13 +740,14 @@ def _open_trade(conn, rec: dict, current_price: float, event_time: str, config: max_price, min_price, current_price, pnl_pct, fee_usdt, source_status, source_action, strategy_version, strategy_code, strategy_signal_id, strategy_snapshot_json, factor_roles_json, created_at, updated_at - ) VALUES (%s,%s,'long','open',%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,0,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) + ) VALUES (%s,%s,%s,'open',%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,0,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) ON CONFLICT(recommendation_id) DO NOTHING RETURNING id """, ( rec_id, symbol, + side, now, entry_price, qty, @@ -1062,7 +1068,8 @@ def _fill_paper_order(conn, order: dict, rec: dict, current_price: float, event_ result["paper_order"] = {"filled": True, "order_id": order["id"], "fill_price": fill_price} _push_order_filled_card(order, result, event_time) stop_loss = _safe_float(rec.get("stop_loss") or _entry_plan(rec).get("stop_loss") or order.get("stop_loss")) - if stop_loss > 0 and current_price <= stop_loss: + side = normalize_side(order.get("side") or _entry_plan(rec).get("side") or rec.get("side")) + if should_stop_loss(side, current_price, stop_loss): trade = conn.execute("SELECT * FROM paper_trades WHERE id=%s", (result["trade_id"],)).fetchone() if trade: close_result = _close_trade(conn, dict(trade), current_price, "stop_loss_same_tick", event_time) @@ -1199,8 +1206,9 @@ def _sync_wait_pullback_order(conn, rec: dict, current_price: float, event_time: def _close_trade(conn, trade: dict, current_price: float, reason: str, event_time: str, detail: dict | None = None) -> dict: entry_price = _safe_float(trade.get("entry_price")) - exit_price = _close_price(current_price) - pnl_pct = _trade_pnl_pct(entry_price, exit_price) + side = normalize_side(trade.get("side")) + exit_price = _close_price(current_price, side=side) + pnl_pct = _trade_pnl_pct(entry_price, exit_price, side) notional = _safe_float(trade.get("notional_usdt")) open_fee = _safe_float(trade.get("fee_usdt")) close_fee = round(notional * default_fee_rate(), 8) @@ -1284,7 +1292,9 @@ def _apply_position_health_guard( if action == "tighten_stop": guard_stop = _safe_float(decision.get("guard_stop")) current_trail = _safe_float(trade.get("trailing_stop")) - if guard_stop > 0 and guard_stop > current_trail + 1e-12: + side = normalize_side(trade.get("side")) + next_stop = tighter_stop(side, current_trail, guard_stop) + if guard_stop > 0 and abs(next_stop - current_trail) > 1e-12: _record_event( conn, trade["id"], @@ -1297,7 +1307,7 @@ def _apply_position_health_guard( {"position_health": decision}, event_time, ) - return {"updated": True, "tightened": True, "trailing_stop": guard_stop, "position_health": decision} + return {"updated": True, "tightened": True, "trailing_stop": next_stop, "position_health": decision} return {"updated": True, "tightened": False, "position_health": decision} @@ -1366,23 +1376,24 @@ def _update_trailing_stop(conn, trade: dict, current_price: float, pnl_pct: floa def _update_open_trade(conn, trade: dict, current_price: float, event_time: str) -> dict: entry_price = _safe_float(trade.get("entry_price")) + side = normalize_side(trade.get("side")) old_max = _safe_float(trade.get("max_price")) or entry_price old_min = _safe_float(trade.get("min_price")) or entry_price new_max = max(old_max, current_price) new_min = min(old_min, current_price) - pnl_pct = _trade_pnl_pct(entry_price, current_price) + pnl_pct = _trade_pnl_pct(entry_price, current_price, side) stop_loss = _safe_float(trade.get("stop_loss")) trailing_stop = _safe_float(trade.get("trailing_stop")) tp2 = _safe_float(trade.get("tp2")) tp1 = _safe_float(trade.get("tp1")) reason = "" - if stop_loss > 0 and current_price <= stop_loss: + if should_stop_loss(side, current_price, stop_loss): reason = "stop_loss" - elif trailing_stop > 0 and current_price <= trailing_stop: + elif should_hit_trailing_stop(side, current_price, trailing_stop): reason = "trailing_stop" - elif tp2 > 0 and current_price >= tp2: + elif should_take_profit(side, current_price, tp2): reason = "tp2" - elif tp1 > 0 and current_price >= tp1: + elif should_take_profit(side, current_price, tp1): reason = "tp1" if reason: @@ -1394,7 +1405,7 @@ def _update_open_trade(conn, trade: dict, current_price: float, event_time: str) if guard_result.get("closed"): return guard_result if guard_result.get("tightened"): - trailing_stop = max(trailing_stop, _safe_float(guard_result.get("trailing_stop"))) + trailing_stop = tighter_stop(side, trailing_stop, _safe_float(guard_result.get("trailing_stop"))) conn.execute( """ @@ -1797,7 +1808,7 @@ def get_paper_trading_performance(days: int = 30) -> dict: } -def list_paper_trades(limit: int = 50, offset: int = 0, status: str = "", strategy_code: str = "") -> dict: +def list_paper_trades(limit: int = 50, offset: int = 0, status: str = "", strategy_code: str = "", side: str = "") -> dict: limit = max(1, min(_safe_int(limit, 50), 200)) offset = max(0, _safe_int(offset, 0)) status = str(status or "").strip() @@ -1811,6 +1822,10 @@ def list_paper_trades(limit: int = 50, offset: int = 0, status: str = "", strate if strategy_code: clauses.append("strategy_code=%s") params.append(normalize_strategy_code(strategy_code)) + side = str(side or "").strip().lower() + if side in {"long", "short"}: + clauses.append("LOWER(COALESCE(side, 'long'))=%s") + params.append(side) where = "WHERE " + " AND ".join(clauses) if clauses else "" conn = get_conn() try: @@ -1838,7 +1853,7 @@ def list_paper_trades(limit: int = 50, offset: int = 0, status: str = "", strate } -def list_paper_orders(limit: int = 50, offset: int = 0, status: str = "", strategy_code: str = "") -> dict: +def list_paper_orders(limit: int = 50, offset: int = 0, status: str = "", strategy_code: str = "", side: str = "") -> dict: limit = max(1, min(_safe_int(limit, 50), 200)) offset = max(0, _safe_int(offset, 0)) status = str(status or "").strip() @@ -1852,6 +1867,10 @@ def list_paper_orders(limit: int = 50, offset: int = 0, status: str = "", strate if strategy_code: clauses.append("strategy_code=%s") params.append(normalize_strategy_code(strategy_code)) + side = str(side or "").strip().lower() + if side in {"long", "short"}: + clauses.append("LOWER(COALESCE(side, 'long'))=%s") + params.append(side) where = "WHERE " + " AND ".join(clauses) if clauses else "" conn = get_conn() try: @@ -1890,7 +1909,7 @@ def list_paper_orders(limit: int = 50, offset: int = 0, status: str = "", strate } -def list_paper_trade_events(limit: int = 80, offset: int = 0, symbol: str = "", event_type: str = "", strategy_code: str = "") -> dict: +def list_paper_trade_events(limit: int = 80, offset: int = 0, symbol: str = "", event_type: str = "", strategy_code: str = "", side: str = "") -> dict: limit = max(1, min(_safe_int(limit, 80), 200)) offset = max(0, _safe_int(offset, 0)) symbol = str(symbol or "").strip().upper() @@ -1907,6 +1926,10 @@ def list_paper_trade_events(limit: int = 80, offset: int = 0, symbol: str = "", if strategy_code: where.append("COALESCE(NULLIF(e.strategy_code, ''), t.strategy_code)=%s") params.append(normalize_strategy_code(strategy_code)) + side = str(side or "").strip().lower() + if side in {"long", "short"}: + where.append("LOWER(COALESCE(t.side, 'long'))=%s") + params.append(side) where_sql = "WHERE " + " AND ".join(where) if where else "" conn = get_conn() try: @@ -1930,6 +1953,7 @@ def list_paper_trade_events(limit: int = 80, offset: int = 0, symbol: str = "", t.margin_usdt, t.leverage, t.exit_reason, + t.side AS trade_side, t.opened_at, t.closed_at, t.strategy_code AS trade_strategy_code, @@ -1951,6 +1975,7 @@ def list_paper_trade_events(limit: int = 80, offset: int = 0, symbol: str = "", item["strategy_code"] = normalize_strategy_code(item.get("strategy_code") or item.get("trade_strategy_code")) item["strategy_name"] = strategy_label(item["strategy_code"]) item["strategy_signal_id"] = _safe_int(item.get("strategy_signal_id") or item.get("trade_strategy_signal_id")) + item["side"] = normalize_side(item.get("trade_side")) items.append(item) return { "items": items, diff --git a/app/db/strategy_insights.py b/app/db/strategy_insights.py index 3e6596f..4792eb6 100644 --- a/app/db/strategy_insights.py +++ b/app/db/strategy_insights.py @@ -54,6 +54,15 @@ def _pct(part, total): return round(float(part or 0) / float(total or 0) * 100, 2) if total else 0.0 +def _strategy_direction(definition) -> str: + direction = str((definition.entry_gate_config or {}).get("direction") or "long").strip().lower() + return "short" if direction == "short" else "long" + + +def _direction_label(direction: str) -> str: + return "空" if str(direction or "").lower() == "short" else "多" + + def evaluate_strategy_decision(metrics: dict) -> dict: """Turn strategy metrics into an explicit lifecycle recommendation. @@ -145,6 +154,8 @@ def get_strategy_evaluation(days: int = 30) -> dict: "description": definition.description, "mode": definition.mode, "status": definition.status, + "direction": _strategy_direction(definition), + "direction_label": _direction_label(_strategy_direction(definition)), "signal_count": 0, "candidate_signal_count": 0, "observe_signal_count": 0, @@ -158,6 +169,8 @@ def get_strategy_evaluation(days: int = 30) -> dict: "filled_order_count": 0, "canceled_order_count": 0, "trade_count": 0, + "long_trade_count": 0, + "short_trade_count": 0, "open_trade_count": 0, "closed_trade_count": 0, "win_count": 0, @@ -172,12 +185,15 @@ def get_strategy_evaluation(days: int = 30) -> dict: normalized = normalize_strategy_code(code) if normalized not in metrics: definition = strategy_definition(normalized) + direction = _strategy_direction(definition) metrics[normalized] = { "strategy_code": normalized, "strategy_name": definition.strategy_name, "description": definition.description, "mode": definition.mode, "status": definition.status, + "direction": direction, + "direction_label": _direction_label(direction), "signal_count": 0, "candidate_signal_count": 0, "observe_signal_count": 0, @@ -191,6 +207,8 @@ def get_strategy_evaluation(days: int = 30) -> dict: "filled_order_count": 0, "canceled_order_count": 0, "trade_count": 0, + "long_trade_count": 0, + "short_trade_count": 0, "open_trade_count": 0, "closed_trade_count": 0, "win_count": 0, @@ -260,7 +278,7 @@ def get_strategy_evaluation(days: int = 30) -> dict: for row in conn.execute( """ - SELECT strategy_code, status, realized_pnl_pct, realized_pnl_usdt, pnl_pct + SELECT strategy_code, side, status, realized_pnl_pct, realized_pnl_usdt, pnl_pct FROM paper_trades WHERE opened_at >= %s """, @@ -268,7 +286,12 @@ def get_strategy_evaluation(days: int = 30) -> dict: ).fetchall(): b = bucket(row.get("strategy_code")) status = row.get("status") or "" + side = str(row.get("side") or "long").strip().lower() b["trade_count"] += 1 + if side == "short": + b["short_trade_count"] += 1 + else: + b["long_trade_count"] += 1 if status == "open": b["open_trade_count"] += 1 if status == "closed": diff --git a/app/strategies/short_breakdown.py b/app/strategies/short_breakdown.py new file mode 100644 index 0000000..4ab6aa6 --- /dev/null +++ b/app/strategies/short_breakdown.py @@ -0,0 +1,113 @@ +"""Independent short strategy builders. + +Short setups are not inverted long setups. They need their own trigger, +confirmation, invalidation and review lineage. +""" + +from __future__ import annotations + +from app.core.factor_roles import CONFIRMATION, ENTRY, RISK, TRIGGER +from app.core.strategy_contract import StrategySignal, current_strategy_version +from app.core.strategy_registry import BREAKDOWN_RETEST_SHORT_1H_STRATEGY + + +def _safe_float(value, default=0.0) -> float: + try: + if value is None or value == "": + return default + return float(value) + except Exception: + return default + + +def build_breakdown_retest_short_1h_signal( + *, + symbol: str, + current_price: float, + detection: dict, + entry_plan: dict | None = None, + market_regime: dict | None = None, + decision_log: dict | None = None, +) -> StrategySignal | None: + """Build a short signal for breakdown -> failed retest setups. + + Expected detection fields are intentionally simple so scanner/confirm + implementations can evolve without changing the strategy contract: + detected, breakdown_level, retest_zone, stop_level, target_1, quality, + retest_rejected, score. + """ + if not (detection or {}).get("detected"): + return None + entry_plan = dict(entry_plan or {}) + market_regime = market_regime or {} + quality = str(detection.get("quality") or "") + retest_rejected = bool(detection.get("retest_rejected")) + current_price = _safe_float(current_price) + retest_zone = _safe_float(detection.get("retest_zone") or detection.get("breakdown_level")) + distance_pct = abs(current_price / retest_zone - 1) * 100 if current_price > 0 and retest_zone > 0 else 0.0 + risk_level = str(market_regime.get("risk_level") or "medium").lower() + reasons = [] + status = "candidate" + if quality not in {"良好", "优质"}: + status = "observe" + reasons.append(f"反抽质量 {quality or '未知'},不直接做空") + if not retest_rejected: + status = "observe" + reasons.append("尚未确认反抽失败") + if risk_level in {"low", "medium"} and not bool(detection.get("relative_weakness")): + status = "observe" + reasons.append("市场并非明显弱势,且缺少相对弱势确认") + if distance_pct > 8: + status = "observe" + reasons.append(f"当前价离反抽区 {distance_pct:.1f}%,不追空") + + entry_plan.setdefault("side", "short") + entry_plan.setdefault("entry_action", "可即刻买入" if status == "candidate" else "观察") + entry_plan.setdefault("entry_price", current_price) + entry_plan.setdefault("stop_loss", detection.get("stop_level")) + entry_plan.setdefault("tp1", detection.get("target_1")) + entry_plan.setdefault("risk_reward_ok", True) + + score = _safe_float(detection.get("score")) + confidence = min(100.0, max(0.0, score * 8 + (12 if retest_rejected else 0))) + return StrategySignal( + strategy_code=BREAKDOWN_RETEST_SHORT_1H_STRATEGY, + strategy_version=current_strategy_version(), + symbol=symbol, + direction="short", + status=status, + confidence=confidence, + score=score, + trigger={ + "factor_code": "breakdown_retest_1h_short", + "factor_label": "1H破位反抽做空", + "breakdown_level": detection.get("breakdown_level"), + "retest_zone": retest_zone, + "stop_level": detection.get("stop_level"), + "target_1": detection.get("target_1"), + "quality": quality, + "retest_rejected": retest_rejected, + "distance_to_retest_zone_pct": round(distance_pct, 4), + "risk_level": risk_level, + }, + factor_roles={ + "breakdown_retest_1h_short": TRIGGER, + "retest_reject_15m_short": ENTRY, + "market_risk_off_short": CONFIRMATION, + "false_breakout": RISK, + "funding_extreme": RISK, + }, + entry_plan=entry_plan, + risk_plan={ + "invalid_if": ["重新站回破位区", "反抽放量突破", "BTC/ETH快速转强", "RR不足"], + "risk_reasons": reasons, + }, + decision_log=decision_log or { + "module": BREAKDOWN_RETEST_SHORT_1H_STRATEGY, + "decision": status, + "reasons": reasons, + }, + ) + + +__all__ = ["build_breakdown_retest_short_1h_signal"] diff --git a/app/web/routes_paper_trading.py b/app/web/routes_paper_trading.py index d311466..6ce4ad6 100644 --- a/app/web/routes_paper_trading.py +++ b/app/web/routes_paper_trading.py @@ -42,10 +42,11 @@ async def api_paper_trading_trades( offset: int = 0, status: str = "", strategy_code: str = "", + side: str = "", altcoin_session: str = Cookie(default=""), ): require_admin(altcoin_session) - return list_paper_trades(limit=limit, offset=offset, status=status, strategy_code=strategy_code) + return list_paper_trades(limit=limit, offset=offset, status=status, strategy_code=strategy_code, side=side) @router.get("/api/paper-trading/orders") @@ -54,10 +55,11 @@ async def api_paper_trading_orders( offset: int = 0, status: str = "", strategy_code: str = "", + side: str = "", altcoin_session: str = Cookie(default=""), ): require_admin(altcoin_session) - return list_paper_orders(limit=limit, offset=offset, status=status, strategy_code=strategy_code) + return list_paper_orders(limit=limit, offset=offset, status=status, strategy_code=strategy_code, side=side) @router.get("/api/paper-trading/events") @@ -67,10 +69,11 @@ async def api_paper_trading_events( symbol: str = "", event_type: str = "", strategy_code: str = "", + side: str = "", altcoin_session: str = Cookie(default=""), ): require_admin(altcoin_session) - return list_paper_trade_events(limit=limit, offset=offset, symbol=symbol, event_type=event_type, strategy_code=strategy_code) + return list_paper_trade_events(limit=limit, offset=offset, symbol=symbol, event_type=event_type, strategy_code=strategy_code, side=side) @router.post("/api/paper-trading/report") diff --git a/static/paper_trading.html b/static/paper_trading.html index 72dae8b..1c75db5 100644 --- a/static/paper_trading.html +++ b/static/paper_trading.html @@ -4,6 +4,7 @@ {% endblock %} {% block content %} @@ -17,6 +18,11 @@ +