From ed90476942cc8d14101a6bb07c5adea627f837f3 Mon Sep 17 00:00:00 2001 From: aaron <> Date: Tue, 2 Jun 2026 10:56:11 +0800 Subject: [PATCH] 1 --- app/core/global_risk.py | 69 +++++++++++++++++++++++++++++++++++++ app/core/position_health.py | 5 ++- app/db/paper_trading.py | 12 +++++-- static/paper_trading.html | 2 +- tests/test_market_regime.py | 58 +++++++++++++++++++++++++++++++ tests/test_paper_trading.py | 40 +++++++++++++++++++++ 6 files changed, 181 insertions(+), 5 deletions(-) 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 '