1
This commit is contained in:
parent
0443072679
commit
ed90476942
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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,''')+'\')">删除</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||'动作'}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user