From 15900b9f539ecaba8d741f3b97bc56a14dbfb3a5 Mon Sep 17 00:00:00 2001 From: aaron <> Date: Thu, 4 Jun 2026 10:11:35 +0800 Subject: [PATCH] =?UTF-8?q?=C2=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/global_risk.py | 2 ++ app/db/paper_trading.py | 19 +++++++---- tests/test_paper_trading.py | 64 ++++++++++++++++++++++++++++++++++--- 3 files changed, 74 insertions(+), 11 deletions(-) diff --git a/app/core/global_risk.py b/app/core/global_risk.py index fac5f0e..1131741 100644 --- a/app/core/global_risk.py +++ b/app/core/global_risk.py @@ -264,6 +264,8 @@ def evaluate_global_risk( "directional_market_bias": directional_gate, "position_multiplier": position_multiplier, "max_open_positions": max_open_positions, + "critical_drawdown_pct": max_drawdown_critical, + "high_drawdown_pct": max_drawdown_high, "min_score_when_high_risk": min_score_high, "min_score_when_critical_risk": min_score_critical, "reasons": reasons, diff --git a/app/db/paper_trading.py b/app/db/paper_trading.py index aecd2d0..dd93567 100644 --- a/app/db/paper_trading.py +++ b/app/db/paper_trading.py @@ -1017,20 +1017,27 @@ def _cancel_paper_order(conn, order: dict, reason: str, event_time: str) -> dict def _touch_global_risk_cancel_reason(global_detail: dict | None) -> str: - """Only hard account/portfolio gates should cancel an already-touched order.""" + """Only hard account/portfolio gates should cancel an already-touched order. + + A touched pullback/retest order already waited for a planned price. Broad + market risk is noisy for altcoins and should mostly resize or annotate the + fill, not cancel it. Cancel only when account/portfolio constraints say the + system cannot add exposure. + """ detail = global_detail if isinstance(global_detail, dict) else {} decision = str(detail.get("decision") or "").strip() - position_multiplier = _safe_float(detail.get("position_multiplier"), 1.0) - if position_multiplier <= 0: - return "touch_position_multiplier_zero" - if decision == "block_critical": - return "touch_critical_risk" + portfolio = detail.get("portfolio") if isinstance(detail.get("portfolio"), dict) else {} + reasons_text = " ".join(str(x) for x in (detail.get("reasons") or [])) + drawdown = _safe_float(portfolio.get("unrealized_drawdown_pct")) + critical_drawdown = _safe_float(detail.get("critical_drawdown_pct") or detail.get("max_drawdown_critical")) if decision == "block_max_open_positions": return "touch_max_open_positions" if decision == "block_same_direction_concentration": return "touch_same_direction_concentration" if decision == "block_same_sector_concentration": return "touch_same_sector_concentration" + if "账户浮亏" in reasons_text or (critical_drawdown > 0 and drawdown >= critical_drawdown): + return "touch_position_multiplier_zero" return "" diff --git a/tests/test_paper_trading.py b/tests/test_paper_trading.py index b456375..0c216d3 100644 --- a/tests/test_paper_trading.py +++ b/tests/test_paper_trading.py @@ -816,7 +816,7 @@ def test_pending_paper_order_reconciles_from_latest_price_cache(monkeypatch): assert order["fill_price"] == pytest.approx(95) -def test_touched_wait_pullback_order_cancels_when_global_risk_pauses(monkeypatch): +def test_touched_wait_pullback_order_ignores_market_only_critical_pause(monkeypatch): monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1") monkeypatch.setenv("ALPHAX_PAPER_GLOBAL_RISK_GATE_ENABLED", "1") monkeypatch.setattr( @@ -827,6 +827,8 @@ def test_touched_wait_pullback_order_cancels_when_global_risk_pauses(monkeypatch "risk_level": "critical", "position_multiplier": 0, "reasons": ["critical 市场环境下推荐分不足"], + "portfolio": {"unrealized_drawdown_pct": 0.0}, + "critical_drawdown_pct": 6.0, }, ) altcoin_db.init_db() @@ -854,15 +856,67 @@ def test_touched_wait_pullback_order_cancels_when_global_risk_pauses(monkeypatch } created = sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00") - paused = sync_recommendation(rec, 94.9, event_time="2026-05-16T10:05:00") + filled = sync_recommendation(rec, 94.9, event_time="2026-05-16T10:05:00") assert created["reason"] == "paper_order_created" - assert paused["reason"] == "paper_order_touch_position_multiplier_zero" + assert filled.get("opened") is True + assert filled["global_risk"]["touch_soft_gate_overridden"] is True + assert filled["global_risk"]["touch_soft_gate_reason"] == "block_critical" + assert list_paper_trades()["total"] == 1 + assert list_paper_orders(status="filled")["total"] == 1 + assert list_paper_orders(status="canceled")["total"] == 0 + + +def test_touched_wait_pullback_order_still_cancels_on_account_hard_limit(monkeypatch): + monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1") + monkeypatch.setenv("ALPHAX_PAPER_GLOBAL_RISK_GATE_ENABLED", "1") + monkeypatch.setattr( + "app.db.paper_trading.evaluate_global_risk", + lambda **kwargs: { + "allow_new_entries": False, + "decision": "block_max_open_positions", + "risk_level": "high", + "position_multiplier": 1, + "reasons": ["持仓数量已达到上限"], + "portfolio": {"unrealized_drawdown_pct": 0.0}, + "critical_drawdown_pct": 6.0, + }, + ) + altcoin_db.init_db() + rec_id = altcoin_db.create_recommendation( + symbol="HARDLIMIT/USDT", + rec_state="蓄力", + rec_score=80, + entry_price=95, + stop_loss=90, + tp1=105, + tp2=112, + signals=["等待回踩"], + entry_plan={"entry_action": "等回踩", "entry_price": 95, "stop_loss": 90, "tp1": 105, "risk_reward_ok": True, "rr1": 2.0}, + ) + rec = { + "id": rec_id, + "symbol": "HARDLIMIT/USDT", + "rec_score": 80, + "execution_status": "wait_pullback", + "action_status": "等回踩", + "entry_price": 95, + "stop_loss": 90, + "tp1": 105, + "tp2": 112, + "entry_plan": {"entry_action": "等回踩", "entry_price": 95, "stop_loss": 90, "tp1": 105, "risk_reward_ok": True, "rr1": 2.0}, + } + + created = sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00") + canceled = sync_recommendation(rec, 94.9, event_time="2026-05-16T10:05:00") + + assert created["reason"] == "paper_order_created" + assert canceled["reason"] == "paper_order_touch_max_open_positions" assert list_paper_trades()["total"] == 0 assert list_paper_orders(status="pending")["total"] == 0 canceled = list_paper_orders(status="canceled")["items"][0] - assert canceled["symbol"] == "RISKPAUSE/USDT" - assert canceled["cancel_reason"] == "touch_position_multiplier_zero" + assert canceled["symbol"] == "HARDLIMIT/USDT" + assert canceled["cancel_reason"] == "touch_max_open_positions" def test_touched_order_soft_global_risk_gate_fills_instead_of_canceling(monkeypatch):