Make risk guard symbol aware
This commit is contained in:
@@ -114,30 +114,39 @@ def risk_guard_snapshot(
|
||||
"position_size_multiplier": 1.0,
|
||||
"reasons": [],
|
||||
}
|
||||
active_trades = _active_universe_trades(settings, closed_trades)
|
||||
reasons: list[str] = []
|
||||
consecutive_losses = _consecutive_losses(closed_trades)
|
||||
degraded_reasons: list[str] = []
|
||||
consecutive_losses = _consecutive_losses(active_trades)
|
||||
if consecutive_losses >= settings.risk_max_consecutive_losses:
|
||||
reasons.append("consecutive_losses")
|
||||
today_pnl = _today_pnl(closed_trades)
|
||||
degraded_reasons.append("consecutive_losses")
|
||||
today_pnl = _today_pnl(active_trades)
|
||||
if today_pnl <= -abs(settings.max_daily_drawdown_usdt):
|
||||
reasons.append("daily_loss_limit")
|
||||
window = max(4, settings.risk_recent_trade_window)
|
||||
recent_stats = _trade_stats(closed_trades[:window])
|
||||
recent_stats = _trade_stats(active_trades[:window])
|
||||
if recent_stats["trades"] >= max(4, min(window, 8)):
|
||||
if recent_stats["profit_factor"] < settings.risk_min_recent_profit_factor:
|
||||
reasons.append("recent_profit_factor_below_min")
|
||||
degraded_reasons.append("recent_profit_factor_below_min")
|
||||
if recent_stats["avg_net_percent"] <= 0:
|
||||
reasons.append("recent_expectancy_non_positive")
|
||||
degraded_reasons.append("recent_expectancy_non_positive")
|
||||
latest_drawdown = float((latest_equity or {}).get("drawdown", 0.0) or 0.0)
|
||||
if latest_drawdown >= abs(settings.max_daily_drawdown_usdt):
|
||||
reasons.append("equity_drawdown_limit")
|
||||
block = bool({"consecutive_losses", "daily_loss_limit", "equity_drawdown_limit"} & set(reasons))
|
||||
multiplier = 0.0 if block else (settings.risk_reduce_multiplier if reasons else 1.0)
|
||||
symbol_stats = _symbol_guard_stats(settings, active_trades)
|
||||
blocked_symbols = sorted(row["symbol"] for row in symbol_stats if row["block_new_entries"])
|
||||
block = bool(reasons)
|
||||
all_reasons = reasons + degraded_reasons
|
||||
multiplier = 0.0 if block else (settings.risk_reduce_multiplier if degraded_reasons else 1.0)
|
||||
return {
|
||||
"enabled": True,
|
||||
"block_new_entries": block,
|
||||
"position_size_multiplier": round(max(0.0, min(1.0, multiplier)), 4),
|
||||
"reasons": reasons,
|
||||
"reasons": all_reasons,
|
||||
"global_reasons": reasons,
|
||||
"degraded_reasons": degraded_reasons,
|
||||
"blocked_symbols": blocked_symbols,
|
||||
"symbols": symbol_stats,
|
||||
"consecutive_losses": consecutive_losses,
|
||||
"today_pnl": round(today_pnl, 6),
|
||||
"recent": recent_stats,
|
||||
@@ -183,6 +192,41 @@ def _group_stats(trades: list[dict[str, Any]], key_fn) -> list[dict[str, Any]]:
|
||||
return sorted(rows, key=lambda row: (row["net_pnl"], row["trades"]), reverse=True)
|
||||
|
||||
|
||||
def _active_universe_trades(settings: Settings, trades: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
symbols = {symbol.upper() for symbol in settings.symbols}
|
||||
if not symbols:
|
||||
return trades
|
||||
return [trade for trade in trades if str(trade.get("symbol", "")).upper() in symbols]
|
||||
|
||||
|
||||
def _symbol_guard_stats(settings: Settings, trades: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
expectancy_min_samples = max(6, min(settings.risk_recent_trade_window, 10))
|
||||
loss_streak_min_samples = max(3, settings.risk_max_consecutive_losses)
|
||||
rows: list[dict[str, Any]] = []
|
||||
for symbol in settings.symbols:
|
||||
symbol_trades = [trade for trade in trades if str(trade.get("symbol", "")).upper() == symbol.upper()]
|
||||
recent = symbol_trades[: settings.risk_recent_trade_window]
|
||||
stats = _trade_stats(recent)
|
||||
losses = _consecutive_losses(recent)
|
||||
reasons: list[str] = []
|
||||
if stats["trades"] >= expectancy_min_samples:
|
||||
if stats["profit_factor"] < settings.risk_min_recent_profit_factor and stats["avg_net_percent"] <= 0:
|
||||
reasons.append("symbol_expectancy_negative")
|
||||
if stats["trades"] >= loss_streak_min_samples:
|
||||
if losses >= settings.risk_max_consecutive_losses:
|
||||
reasons.append("symbol_consecutive_losses")
|
||||
rows.append(
|
||||
{
|
||||
"symbol": symbol.upper(),
|
||||
"block_new_entries": bool(reasons),
|
||||
"reasons": reasons,
|
||||
"consecutive_losses": losses,
|
||||
**stats,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _trade_summary(trade: dict[str, Any]) -> dict[str, Any]:
|
||||
diagnostics = trade.get("entry_diagnostics") if isinstance(trade.get("entry_diagnostics"), dict) else {}
|
||||
forecast = diagnostics.get("forecast") if isinstance(diagnostics.get("forecast"), dict) else {}
|
||||
|
||||
@@ -171,6 +171,23 @@ class CryptoSpotBot:
|
||||
)
|
||||
)
|
||||
continue
|
||||
symbol_guard = self._risk_guard_for_symbol(risk_guard, symbol)
|
||||
if symbol_guard.get("block_new_entries"):
|
||||
self.storage.insert_signal(
|
||||
Signal(
|
||||
symbol,
|
||||
"HOLD",
|
||||
0.0,
|
||||
"risk_guard: symbol blocked",
|
||||
{
|
||||
"strategy_mode": self.settings.strategy_mode,
|
||||
"risk_guard": risk_guard,
|
||||
"symbol_guard": symbol_guard,
|
||||
"checks": {"risk_guard_symbol_ok": False},
|
||||
},
|
||||
)
|
||||
)
|
||||
continue
|
||||
llm = {}
|
||||
if (
|
||||
self.settings.llm_advisor_enabled
|
||||
@@ -211,6 +228,17 @@ class CryptoSpotBot:
|
||||
prices,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _risk_guard_for_symbol(risk_guard: dict, symbol: str) -> dict:
|
||||
rows = risk_guard.get("symbols")
|
||||
if not isinstance(rows, list):
|
||||
return {}
|
||||
symbol_upper = symbol.upper()
|
||||
for row in rows:
|
||||
if isinstance(row, dict) and str(row.get("symbol", "")).upper() == symbol_upper:
|
||||
return row
|
||||
return {}
|
||||
|
||||
def _with_exposure_context(self, rules: dict) -> dict:
|
||||
enriched = dict(rules)
|
||||
current_exposure = self.broker.exposure()
|
||||
|
||||
@@ -741,6 +741,7 @@ HTML = r"""
|
||||
${panel('Risk guard', kvTable([
|
||||
['Block entries', risk.block_new_entries ? 'yes' : 'no'],
|
||||
['Size multiplier', num(risk.position_size_multiplier, 4)],
|
||||
['Blocked symbols', (risk.blocked_symbols || []).join(', ') || 'none'],
|
||||
['Consecutive losses', String(risk.consecutive_losses ?? 0)],
|
||||
['Today PnL', money(risk.today_pnl)],
|
||||
['Recent PF', num(risk.recent?.profit_factor, 3)],
|
||||
@@ -753,6 +754,7 @@ HTML = r"""
|
||||
['Remote equity', money(reconciliation?.account?.total_equity)]
|
||||
]) + discrepanciesHtml(reconciliation?.discrepancies || []))}
|
||||
</div>
|
||||
${panel('Risk by symbol', symbolRiskTable(risk.symbols || []))}
|
||||
${panel('Data quality by symbol', qualityTable(quality.symbols || []))}
|
||||
${panel('Probability calibration', calibrationTable(analytics?.probability_calibration?.buckets || []))}
|
||||
${panel('Failed checks', simpleTable(Object.entries(drift.failed_checks || {}).map(([key, value]) => ({ check: key, count: value })), ['check', 'count']))}
|
||||
@@ -876,6 +878,19 @@ HTML = r"""
|
||||
})), ['symbol', 'status', 'score', 'candles', 'issues']);
|
||||
}
|
||||
|
||||
function symbolRiskTable(rows) {
|
||||
return simpleTable(rows.map(row => ({
|
||||
symbol: row.symbol,
|
||||
block: row.block_new_entries ? 'yes' : 'no',
|
||||
trades: row.trades,
|
||||
win: num((row.win_rate || 0) * 100, 1) + '%',
|
||||
avg: signed(row.avg_net_percent, 3) + '%',
|
||||
pf: num(row.profit_factor, 3),
|
||||
losses: row.consecutive_losses,
|
||||
reasons: (row.reasons || []).join(', ') || 'none'
|
||||
})), ['symbol', 'block', 'trades', 'win', 'avg', 'pf', 'losses', 'reasons']);
|
||||
}
|
||||
|
||||
function calibrationTable(rows) {
|
||||
return simpleTable(rows.map(row => ({
|
||||
bucket: row.bucket,
|
||||
|
||||
@@ -6,7 +6,7 @@ from crypto_spot_bot.models import Candle, Ticker, Trade, utc_now
|
||||
from crypto_spot_bot.storage import Storage
|
||||
|
||||
|
||||
def test_risk_guard_blocks_after_consecutive_losses(make_settings, tmp_path) -> None:
|
||||
def test_risk_guard_reduces_size_after_consecutive_losses(make_settings, tmp_path) -> None:
|
||||
settings = make_settings(tmp_path, risk_max_consecutive_losses=2)
|
||||
storage = Storage(settings.database_path)
|
||||
now = utc_now()
|
||||
@@ -28,9 +28,78 @@ def test_risk_guard_blocks_after_consecutive_losses(make_settings, tmp_path) ->
|
||||
|
||||
guard = risk_guard_snapshot(settings, storage.closed_trades(), storage.latest_equity())
|
||||
|
||||
assert guard["block_new_entries"] is True
|
||||
assert guard["block_new_entries"] is False
|
||||
assert "consecutive_losses" in guard["reasons"]
|
||||
assert guard["position_size_multiplier"] == 0.0
|
||||
assert guard["position_size_multiplier"] == settings.risk_reduce_multiplier
|
||||
|
||||
|
||||
def test_risk_guard_blocks_only_bad_symbol(make_settings, tmp_path) -> None:
|
||||
settings = make_settings(tmp_path, risk_max_consecutive_losses=3, symbols=["BTCUSDT", "ETHUSDT"])
|
||||
storage = Storage(settings.database_path)
|
||||
now = utc_now()
|
||||
for _ in range(3):
|
||||
storage.insert_trade(
|
||||
Trade(
|
||||
id=None,
|
||||
symbol="BTCUSDT",
|
||||
side="SELL",
|
||||
qty=1.0,
|
||||
entry_price=100.0,
|
||||
exit_price=99.0,
|
||||
net_pnl=-1.0,
|
||||
opened_at=now,
|
||||
closed_at=now,
|
||||
entry_diagnostics={"forecast": {"probability_up": 0.64, "model": "torch_gru"}},
|
||||
)
|
||||
)
|
||||
storage.insert_trade(
|
||||
Trade(
|
||||
id=None,
|
||||
symbol="ETHUSDT",
|
||||
side="SELL",
|
||||
qty=1.0,
|
||||
entry_price=100.0,
|
||||
exit_price=102.0,
|
||||
net_pnl=2.0,
|
||||
opened_at=now,
|
||||
closed_at=now,
|
||||
entry_diagnostics={"forecast": {"probability_up": 0.64, "model": "torch_gru"}},
|
||||
)
|
||||
)
|
||||
|
||||
guard = risk_guard_snapshot(settings, storage.closed_trades(), storage.latest_equity())
|
||||
|
||||
assert guard["block_new_entries"] is False
|
||||
assert guard["blocked_symbols"] == ["BTCUSDT"]
|
||||
symbol_rows = {row["symbol"]: row for row in guard["symbols"]}
|
||||
assert symbol_rows["BTCUSDT"]["block_new_entries"] is True
|
||||
assert symbol_rows["ETHUSDT"]["block_new_entries"] is False
|
||||
|
||||
|
||||
def test_risk_guard_ignores_trades_outside_active_universe(make_settings, tmp_path) -> None:
|
||||
settings = make_settings(tmp_path, risk_max_consecutive_losses=2, symbols=["BTCUSDT", "ETHUSDT"])
|
||||
storage = Storage(settings.database_path)
|
||||
now = utc_now()
|
||||
for _ in range(4):
|
||||
storage.insert_trade(
|
||||
Trade(
|
||||
id=None,
|
||||
symbol="HYPEUSDT",
|
||||
side="SELL",
|
||||
qty=1.0,
|
||||
entry_price=100.0,
|
||||
exit_price=99.0,
|
||||
net_pnl=-1.0,
|
||||
opened_at=now,
|
||||
closed_at=now,
|
||||
)
|
||||
)
|
||||
|
||||
guard = risk_guard_snapshot(settings, storage.closed_trades(), storage.latest_equity())
|
||||
|
||||
assert guard["block_new_entries"] is False
|
||||
assert guard["reasons"] == []
|
||||
assert guard["blocked_symbols"] == []
|
||||
|
||||
|
||||
def test_data_quality_flags_missing_candle_gap() -> None:
|
||||
|
||||
Reference in New Issue
Block a user