diff --git a/app/core/global_risk.py b/app/core/global_risk.py index dad68a6..9aa0b38 100644 --- a/app/core/global_risk.py +++ b/app/core/global_risk.py @@ -23,6 +23,68 @@ def _safe_int(value, default: int = 0) -> int: return default +def _side_from_rec(rec: dict | None = None) -> str: + rec = rec or {} + text = str(rec.get("side") or rec.get("direction") or "").strip().lower() + if text in {"short", "sell", "空", "空头", "做空", "空头启动"} or "空" in text: + return "short" + return "long" + + +def _directional_market_gate(regime: dict, side: str, base_risk_level: str, base_multiplier: float) -> dict: + """Translate broad market regime into side-aware entry risk. + + A risk-off market is a headwind for long entries, but it can be the + intended environment for a dedicated short strategy. Conversely, an + altcoin rotation is supportive for long opportunities and a squeeze risk + for shorts. + """ + side = "short" if str(side or "").lower() == "short" else "long" + regime_code = str((regime or {}).get("regime") or "unknown").strip().lower() + risk_level = str(base_risk_level or "medium").strip().lower() + multiplier = max(0.0, _safe_float(base_multiplier, 1.0)) + bias = "neutral" + reasons: list[str] = [] + + if regime_code == "risk_off": + if side == "short": + bias = "favorable" + risk_level = "medium" + multiplier = max(1.0, multiplier) + reasons.append("risk_off 对空头属于顺风环境,不作为空头挂单/开仓的拦截理由") + else: + bias = "adverse" + reasons.append("risk_off 对多头属于逆风环境,需要降仓或提高门槛") + elif regime_code in {"altcoin_rotation", "btc_main_uptrend", "meme_frenzy"}: + if side == "short": + bias = "adverse" + if regime_code == "meme_frenzy": + risk_level = "critical" + multiplier = min(multiplier, 0.25) + reasons.append("情绪过热期做空容易被轧空,只允许极高质量空头") + else: + risk_level = "high" if risk_level not in {"critical"} else risk_level + multiplier = min(multiplier, 0.5) + reasons.append("上涨轮动/主流带动期做空属于逆势,需要提高空头质量门槛") + else: + bias = "favorable" + multiplier = max(multiplier, 0.8) + reasons.append("当前市场对多头相对顺风,不额外惩罚多头入场") + elif regime_code == "sideways_chop": + bias = "neutral" + reasons.append("震荡期对多空都不是单边顺风,按原始风控轻仓精选") + + return { + "side": side, + "market_bias": bias, + "effective_risk_level": risk_level, + "effective_position_multiplier": multiplier, + "reasons": reasons, + "raw_risk_level": str(base_risk_level or "medium"), + "raw_position_multiplier": base_multiplier, + } + + def _portfolio_snapshot(conn, account_equity: float, additional_notional: float) -> dict: open_rows = conn.execute("SELECT notional_usdt, pnl_pct FROM paper_trades WHERE status='open'").fetchall() pending_notional = _safe_float(conn.execute("SELECT COALESCE(SUM(notional_usdt),0) FROM paper_orders WHERE status='pending'").fetchone()[0]) @@ -113,6 +175,7 @@ def evaluate_global_risk( account_equity = max(1.0, _safe_float(cfg.get("account_equity_usdt"), 20000.0)) portfolio = _portfolio_snapshot(conn, account_equity, additional_notional) concentration = _concentration_snapshot(conn, rec) + side = _side_from_rec(rec) rec_score = _safe_float((rec or {}).get("rec_score") or (rec or {}).get("score")) min_score_high = max(0.0, _safe_float(cfg.get("global_risk_high_min_rec_score"), 70.0)) min_score_critical = max(min_score_high, _safe_float(cfg.get("global_risk_critical_min_rec_score"), 80.0)) @@ -122,6 +185,10 @@ def evaluate_global_risk( reasons = list(regime.get("reasons") or []) risk_level = str(regime.get("risk_level") or "medium") position_multiplier = max(min_position_multiplier, _safe_float(regime.get("position_multiplier"), 1.0)) + directional_gate = _directional_market_gate(regime, side, risk_level, position_multiplier) + risk_level = str(directional_gate.get("effective_risk_level") or risk_level) + position_multiplier = max(min_position_multiplier, _safe_float(directional_gate.get("effective_position_multiplier"), position_multiplier)) + reasons.extend(directional_gate.get("reasons") or []) allow = True decision = "allow" @@ -181,6 +248,8 @@ def evaluate_global_risk( "allow_new_entries": allow, "decision": decision, "risk_level": risk_level, + "side": side, + "directional_market_bias": directional_gate, "position_multiplier": position_multiplier, "max_open_positions": max_open_positions, "min_score_when_high_risk": min_score_high, diff --git a/app/core/position_health.py b/app/core/position_health.py index 2c20479..274a0e2 100644 --- a/app/core/position_health.py +++ b/app/core/position_health.py @@ -140,10 +140,13 @@ def evaluate_position_health( health_score -= min(20.0, abs(pnl) * 3.0) risk_level = str((global_risk or {}).get("risk_level") or "").strip().lower() + directional_bias = "" + if isinstance((global_risk or {}).get("directional_market_bias"), dict): + directional_bias = str((global_risk or {}).get("directional_market_bias", {}).get("market_bias") or "").strip().lower() critical_exit_enabled = _cfg_bool(cfg, "position_guard_critical_exit_enabled", True) critical_min_age = max(0.0, _safe_float(cfg.get("position_guard_critical_min_age_hours"), 0.5)) critical_max_pnl = _safe_float(cfg.get("position_guard_critical_max_pnl_pct"), 1.0) - if critical_exit_enabled and risk_level == "critical" and age >= critical_min_age and pnl <= critical_max_pnl: + if critical_exit_enabled and risk_level == "critical" and directional_bias != "favorable" and age >= critical_min_age and pnl <= critical_max_pnl: detail["global_risk"] = global_risk or {} return PositionHealthDecision( action="close", diff --git a/app/db/paper_trading.py b/app/db/paper_trading.py index 4c226da..f158adc 100644 --- a/app/db/paper_trading.py +++ b/app/db/paper_trading.py @@ -153,7 +153,7 @@ def _weak_entries_check(conn, event_time: str, config: dict | None = None) -> tu since = (now - timedelta(hours=window_hours)).isoformat() rows = conn.execute( """ - SELECT symbol, opened_at, entry_price, max_price + SELECT symbol, side, opened_at, entry_price, max_price, min_price FROM paper_trades WHERE opened_at >= %s ORDER BY opened_at DESC, id DESC @@ -163,9 +163,15 @@ def _weak_entries_check(conn, event_time: str, config: dict | None = None) -> tu ).fetchall() samples = [] for row in rows: + side = normalize_side(row.get("side")) entry = _safe_float(row["entry_price"]) - max_pnl = (max(_safe_float(row["max_price"]), entry) / entry - 1) * 100 if entry > 0 else 0 - samples.append({"symbol": row["symbol"], "opened_at": row["opened_at"], "max_pnl_pct": round(max_pnl, 6)}) + if side == "short": + best_price = min(_safe_float(row.get("min_price")) or entry, entry) + max_pnl = (entry / best_price - 1) * 100 if entry > 0 and best_price > 0 else 0 + else: + best_price = max(_safe_float(row.get("max_price")) or entry, entry) + max_pnl = (best_price / entry - 1) * 100 if entry > 0 else 0 + samples.append({"symbol": row["symbol"], "side": side, "opened_at": row["opened_at"], "max_pnl_pct": round(max_pnl, 6)}) enough = len(samples) >= limit all_weak = enough and all(x["max_pnl_pct"] < threshold for x in samples) detail = { diff --git a/static/paper_trading.html b/static/paper_trading.html index ecb80f5..53bfeee 100644 --- a/static/paper_trading.html +++ b/static/paper_trading.html @@ -263,7 +263,7 @@ function renderCanceledOrders(items){if(!items.length){$('canceledOrderRows').in ''+ ''}).join('')} function orderStatus(x){return {filled:'已成交',expired:'已过期',canceled:'已取消',rejected:'已拒绝'}[x.status]||x.status||'--'} -function cancelReasonLabel(r){return {global_risk_rejected:'全局风控拒绝:市场/账户风险过高,未转持仓',risk_paused_at_touch:'触价时风控暂停:目标价已到但账户/市场风险不允许开仓',stop_loss_leverage_risk_exceeded:'止损杠杆风险超限:按当前止损和杠杆亏损过大',portfolio_drawdown_pause:'账户回撤保护:暂停新增仓位',weak_entries_pause:'近期弱入场过多:暂停新增仓位',recommendation_invalid:'原推荐已失效:机会过期/归档后撤单',too_far_from_entry:'价格远离计划价:继续等待意义不大',expired:'挂单超时:超过有效期未成交',upgraded_to_buy_now:'信号升级为入场窗口:旧挂单已撤销',canceled:'已取消',filled:'已成交',rejected:'已拒绝'}[r]||r||'--'} +function cancelReasonLabel(r){return {global_risk_rejected:'方向风控拒绝:市场方向、账户风险或仓位集中度不允许开仓',risk_paused_at_touch:'触价时方向风控暂停:目标价已到但该方向暂不允许开仓',stop_loss_leverage_risk_exceeded:'止损杠杆风险超限:按当前止损和杠杆亏损过大',portfolio_drawdown_pause:'账户回撤保护:暂停新增仓位',weak_entries_pause:'近期同类弱入场过多:暂停新增仓位',recommendation_invalid:'原机会已失效:机会过期/归档后撤单',too_far_from_entry:'价格远离计划价:继续等待意义不大',expired:'挂单超时:超过有效期未成交',upgraded_to_buy_now:'信号升级为入场窗口:旧挂单已撤销',canceled:'已取消',filled:'已成交',rejected:'已拒绝'}[r]||r||'--'} function renderOpenPager(){var page=Math.floor(openOffset/LIMIT)+1,totalPages=Math.max(1,Math.ceil(openTotal/LIMIT));$('openPageInfo').textContent='第 '+page+' / '+totalPages+' 页 · 共 '+openTotal+' 条';$('openPager').innerHTML='第 '+page+' / '+totalPages+' 页'} async function loadEvents(nextOffset){eventOffset=Math.max(0,nextOffset||0);$('eventRows').innerHTML='
加载中...
';try{var sym=$('eventSymbol').value||'';var typ=$('eventType').value||'';var d=await api('/api/paper-trading/events?limit='+EVENT_LIMIT+'&offset='+eventOffset+'&symbol='+encodeURIComponent(sym)+'&event_type='+encodeURIComponent(typ)+tradeQuery());eventTotal=d.total||0;renderEvents(d.items||[]);renderEventPager()}catch(e){$('eventRows').innerHTML='
'+esc(e.message)+'
'}} function eventLabel(t){return {open:'开仓',close:'平仓',trailing_activate:'移动止盈激活',trailing_move:'移动止盈上移'}[t]||t||'动作'} diff --git a/tests/test_market_regime.py b/tests/test_market_regime.py index ee0576b..59f8c26 100644 --- a/tests/test_market_regime.py +++ b/tests/test_market_regime.py @@ -78,3 +78,61 @@ def test_global_risk_blocks_same_sector_concentration(pg_conn, monkeypatch): assert result["allow_new_entries"] is False assert result["decision"] == "block_same_sector_concentration" + + +def test_global_risk_off_is_favorable_for_short_entries(pg_conn, monkeypatch): + monkeypatch.setattr( + "app.core.global_risk.get_crypto_market_overview", + lambda allow_live_fallback=False: _overview( + benchmarks={"BTC/USDT": {"change_24h": -3.4}, "ETH/USDT": {"change_24h": -4.2}}, + advance_decline_ratio=0.4, + crash_count_5pct=35, + ), + ) + + result = evaluate_global_risk( + conn=pg_conn, + config={ + "account_equity_usdt": 20000, + "global_risk_gate_enabled": True, + "global_risk_block_critical": False, + "global_risk_critical_min_rec_score": 80, + }, + rec={"symbol": "SHORT/USDT", "side": "short", "rec_score": 55}, + additional_notional=1000, + ) + + assert result["allow_new_entries"] is True + assert result["side"] == "short" + assert result["risk_level"] == "medium" + assert result["directional_market_bias"]["market_bias"] == "favorable" + assert "risk_off 对空头属于顺风环境" in " ".join(result["reasons"]) + + +def test_altcoin_rotation_is_adverse_for_short_entries(pg_conn, monkeypatch): + monkeypatch.setattr( + "app.core.global_risk.get_crypto_market_overview", + lambda allow_live_fallback=False: _overview( + benchmarks={"BTC/USDT": {"change_24h": 0.6}, "ETH/USDT": {"change_24h": 1.1}}, + advance_decline_ratio=1.4, + avg_change_24h=1.2, + hot_count_5pct=22, + crash_count_5pct=3, + ), + ) + + result = evaluate_global_risk( + conn=pg_conn, + config={ + "account_equity_usdt": 20000, + "global_risk_gate_enabled": True, + "global_risk_high_min_rec_score": 70, + }, + rec={"symbol": "SQUEEZE/USDT", "side": "short", "rec_score": 55}, + additional_notional=1000, + ) + + assert result["allow_new_entries"] is False + assert result["risk_level"] == "high" + assert result["decision"] == "block_high_weak_score" + assert result["directional_market_bias"]["market_bias"] == "adverse" diff --git a/tests/test_paper_trading.py b/tests/test_paper_trading.py index 8ae7bbb..e7411ab 100644 --- a/tests/test_paper_trading.py +++ b/tests/test_paper_trading.py @@ -624,6 +624,46 @@ def test_buy_now_uses_reduced_size_when_global_market_risk_is_critical(monkeypat assert list_paper_trades()["total"] == 1 +def test_short_buy_now_not_reduced_by_risk_off_market(monkeypatch): + monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1") + monkeypatch.setenv("ALPHAX_PAPER_GLOBAL_RISK_GATE_ENABLED", "1") + monkeypatch.setenv("ALPHAX_PAPER_GLOBAL_RISK_BLOCK_CRITICAL", "0") + monkeypatch.setenv("ALPHAX_PAPER_ENTRY_GATE_ENABLED", "0") + monkeypatch.setenv("ALPHAX_PAPER_TRADE_NOTIONAL_USDT", "100") + monkeypatch.setattr( + "app.core.global_risk.get_crypto_market_overview", + lambda allow_live_fallback=False: { + "sample_count": 200, + "benchmarks": {"BTC/USDT": {"change_24h": -3.5}, "ETH/USDT": {"change_24h": -4.1}}, + "advance_decline_ratio": 0.45, + "avg_change_24h": -2.2, + "hot_count_5pct": 2, + "crash_count_5pct": 36, + "funding": {"avg_funding_rate": -0.0001, "extreme_positive_count": 0}, + }, + ) + altcoin_db.init_db() + rec_id = altcoin_db.create_recommendation( + symbol="SHORTOFF/USDT", + rec_state="爆发", + rec_score=60, + entry_price=100, + stop_loss=105, + tp1=90, + signals=["1H破位反抽做空"], + direction="空头启动", + entry_plan={"side": "short", "entry_action": "可即刻买入", "entry_trigger_confirmed": True, "risk_reward_ok": True, "rr1": 2.0}, + ) + rec = next(r for r in altcoin_db.get_active_recommendations_deduped(actionable_only=False) if r["id"] == rec_id) + + result = sync_recommendation(rec, 100, event_time="2026-05-16T10:00:00") + + assert result["opened"] is True + assert result["side"] == "short" + assert result["global_risk"]["directional_market_bias"]["market_bias"] == "favorable" + assert result["notional_usdt"] == pytest.approx(100.0) + + def test_open_event_records_market_regime_and_score_components(monkeypatch): monkeypatch.setenv("ALPHAX_PAPER_TRADING_ENABLED", "1") monkeypatch.setenv("ALPHAX_PAPER_GLOBAL_RISK_GATE_ENABLED", "1")