This commit is contained in:
aaron 2026-06-02 10:56:11 +08:00
parent 0443072679
commit ed90476942
6 changed files with 181 additions and 5 deletions

View File

@ -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,

View File

@ -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",

View File

@ -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 = {

View File

@ -263,7 +263,7 @@ function renderCanceledOrders(items){if(!items.length){$('canceledOrderRows').in
'<td><button class="row-action" type="button" onclick="deleteOrder('+Number(x.id)+',\''+esc(String(x.symbol||'')).replace(/'/g,'&#39;')+'\')">删除</button></td>'+
'</tr>'}).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='<button '+(openOffset===0?'disabled':'')+' onclick="loadOpenTrades('+(openOffset-LIMIT)+')">上一页</button><span>第 '+page+' / '+totalPages+' 页</span><button '+((openOffset+LIMIT>=openTotal)?'disabled':'')+' onclick="loadOpenTrades('+(openOffset+LIMIT)+')">下一页</button>'}
async function loadEvents(nextOffset){eventOffset=Math.max(0,nextOffset||0);$('eventRows').innerHTML='<div class="loading">加载中...</div>';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='<div class="empty">'+esc(e.message)+'</div>'}}
function eventLabel(t){return {open:'开仓',close:'平仓',trailing_activate:'移动止盈激活',trailing_move:'移动止盈上移'}[t]||t||'动作'}

View File

@ -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"

View File

@ -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")