diff --git a/application/account_new_risk_gate_support.py b/application/account_new_risk_gate_support.py index 2fa2189..fb56ac4 100644 --- a/application/account_new_risk_gate_support.py +++ b/application/account_new_risk_gate_support.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import math import os from collections.abc import Mapping @@ -15,6 +16,7 @@ NewRiskDisposition, evaluate_new_risk_admission, ) +from quant_platform_kit.risk.contracts import RuntimeRiskLimits from quant_platform_kit.risk.cycle_new_risk_health import ( CycleNewRiskHealthEvidence, apply_cycle_new_risk_health_axes, @@ -24,10 +26,14 @@ ) ACCOUNT_NEW_RISK_GATE_ENV = "ACCOUNT_NEW_RISK_GATE" +_MAX_DAILY_LOSS_ENV_KEYS = ("SCHWAB_MAX_DAILY_LOSS_USD", "MAX_DAILY_LOSS_USD") _DEFAULT_STRATEGY_PROFILE = "soxl_soxx_trend_income" _DEFAULT_DOMAIN = "us_equity" +# Minimal carrier for gate-only daily-loss axis; not a production RRL binding. +_DAILY_LOSS_LIMIT_CARRIER_SYMBOL = "SPY" + _cycle_snapshot: InjectedReconciliationSnapshot | None = None @@ -48,6 +54,14 @@ def _coerce_optional_float(value: object) -> float | None: return number +def _positive_limit_or_none(value: object) -> float | None: + """Accept only finite positive limits; never invent a production default.""" + number = _coerce_optional_float(value) + if number is None or number <= 0.0: + return None + return number + + def _resolve_equity_usd(portfolio: Mapping[str, Any], execution: Mapping[str, Any] | None) -> float | None: """Map Schwab account equity from broker liquidation value or plan portfolio.""" for key in ("total_equity", "total_strategy_equity"): @@ -200,6 +214,83 @@ def _resolve_drawdown_from_peak( return max(0.0, 1.0 - (equity_usd / peak_equity_usd)) +def _resolve_explicit_daily_loss_usd( + projection: Mapping[str, Any], + portfolio: Mapping[str, Any], + execution: Mapping[str, Any] | None, +) -> float | None: + """Pass through an explicit daily_loss_usd fact only; never invent one.""" + for source in (projection, portfolio, _mapping_or_empty(execution)): + if "daily_loss_usd" in source: + return _coerce_optional_float(source.get("daily_loss_usd")) + return None + + +def _max_daily_loss_from_runtime_target_json() -> float | None: + """Read max_daily_loss_usd from RUNTIME_TARGET_JSON when present; soft-omit on errors.""" + raw_target = os.environ.get("RUNTIME_TARGET_JSON") + if raw_target is None or not str(raw_target).strip(): + return None + try: + payload = json.loads(raw_target) + except (TypeError, ValueError): + return None + if not isinstance(payload, dict): + return None + policy = payload.get("runtime_risk_limits") + if not isinstance(policy, dict) or "max_daily_loss_usd" not in policy: + return None + return _positive_limit_or_none(policy.get("max_daily_loss_usd")) + + +def resolve_max_daily_loss_usd( + portfolio: Mapping[str, Any] | None = None, +) -> float | None: + """Resolve an explicit max_daily_loss_usd; omit the axis when unset. + + Priority: account_new_risk_snapshot / portfolio key → RUNTIME_TARGET_JSON → + SCHWAB_MAX_DAILY_LOSS_USD / MAX_DAILY_LOSS_USD. No approved production default. + """ + if portfolio is not None: + projection = _mapping_or_empty(portfolio.get("account_new_risk_snapshot")) + for source in (projection, portfolio): + if "max_daily_loss_usd" in source: + return _positive_limit_or_none(source.get("max_daily_loss_usd")) + policy_limit = _max_daily_loss_from_runtime_target_json() + if policy_limit is not None: + return policy_limit + for key in _MAX_DAILY_LOSS_ENV_KEYS: + raw = os.environ.get(key) + if raw is None or not str(raw).strip(): + continue + limit = _positive_limit_or_none(raw) + if limit is not None: + return limit + return None + + +def runtime_risk_limits_for_daily_loss_axis( + max_daily_loss_usd: float | None, +) -> RuntimeRiskLimits | None: + """Build admission-only limits carrying ``max_daily_loss_usd``, or omit. + + SPY/1.0 caps are a minimal legal RuntimeRiskLimits carrier for the gate only — + not a production RRL binding and not an exposure raise. + """ + if max_daily_loss_usd is None: + return None + symbol = _DAILY_LOSS_LIMIT_CARRIER_SYMBOL + return RuntimeRiskLimits( + allowed_symbols=(symbol,), + product_leverage_factors={symbol: 1}, + nominal_caps={symbol: 1.0}, + total_nominal_exposure_cap=1.0, + total_effective_exposure_cap=1.0, + max_positions=1, + max_daily_loss_usd=max_daily_loss_usd, + ) + + def build_snapshot_from_portfolio( portfolio: Mapping[str, Any], *, @@ -235,6 +326,7 @@ def build_snapshot_from_portfolio( if "realized_vol" in projection else _coerce_optional_float(portfolio.get("realized_vol")), production_drift_status=_resolve_production_drift_status(portfolio, projection), + daily_loss_usd=_resolve_explicit_daily_loss_usd(projection, portfolio, execution), ) @@ -245,7 +337,8 @@ def evaluate_portfolio_new_risk_admission( ) -> NewRiskAdmissionResult: try: snapshot = build_snapshot_from_portfolio(portfolio, execution=execution) - return evaluate_new_risk_admission(snapshot) + limits = runtime_risk_limits_for_daily_loss_axis(resolve_max_daily_loss_usd(portfolio)) + return evaluate_new_risk_admission(snapshot, limits) except AccountNewRiskGateError: return NewRiskAdmissionResult( disposition=NewRiskDisposition.NEW_RISK_PROHIBITED, @@ -291,7 +384,8 @@ def evaluate_cycle_new_risk_admission() -> NewRiskAdmissionResult: reason_codes=("EQUITY_UNKNOWN_FAIL_CLOSED",), ) try: - return evaluate_new_risk_admission(_cycle_snapshot) + limits = runtime_risk_limits_for_daily_loss_axis(resolve_max_daily_loss_usd()) + return evaluate_new_risk_admission(_cycle_snapshot, limits) except AccountNewRiskGateError: return NewRiskAdmissionResult( disposition=NewRiskDisposition.NEW_RISK_PROHIBITED, diff --git a/pyproject.toml b/pyproject.toml index 1878bd4..d4a8337 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ dependencies = [ "google-cloud-storage", "google-auth", "numpy", - "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@a90937621a7a05c9f72d0ca8a29be3fcb18a327c", + "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@1826204e2927f4812a69348dd9c3d5235265be88", "us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@4a3943883cd6b5bbfe32a559e56a91b40a81b7ce", ] @@ -61,5 +61,5 @@ include = [ [tool.uv] override-dependencies = [ - "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@a90937621a7a05c9f72d0ca8a29be3fcb18a327c", + "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@1826204e2927f4812a69348dd9c3d5235265be88", ] diff --git a/qsl.toml b/qsl.toml index 15c2d27..5cf67dd 100644 --- a/qsl.toml +++ b/qsl.toml @@ -5,7 +5,7 @@ upgrade_ring = "ring_d" allow_legacy = false [qsl.requires] -quant_platform_kit = "a90937621a7a05c9f72d0ca8a29be3fcb18a327c" +quant_platform_kit = "1826204e2927f4812a69348dd9c3d5235265be88" us_equity_strategies = "4a3943883cd6b5bbfe32a559e56a91b40a81b7ce" [qsl.compat] diff --git a/strategy_runtime.py b/strategy_runtime.py index 35b9bdf..40a4b99 100644 --- a/strategy_runtime.py +++ b/strategy_runtime.py @@ -243,7 +243,7 @@ def _build_runtime_risk_capabilities( "max_positions", "exit_parameters", } - optional_policy_keys = {"small_account_hold"} + optional_policy_keys = {"small_account_hold", "max_daily_loss_usd"} policy_keys = set(policy) if ( not expected_policy_keys.issubset(policy_keys) @@ -340,6 +340,9 @@ def _build_runtime_risk_capabilities( ) return {**capabilities, "runtime_risk_limits": object()}, "unavailable:runtime_binding_mismatch" try: + daily_loss_kwargs: dict[str, Any] = {} + if "max_daily_loss_usd" in policy: + daily_loss_kwargs["max_daily_loss_usd"] = policy.get("max_daily_loss_usd") limits = RuntimeRiskLimits( allowed_symbols=tuple(policy["allowed_symbols"]), product_leverage_factors=policy["product_leverage_factors"], @@ -347,6 +350,7 @@ def _build_runtime_risk_capabilities( total_nominal_exposure_cap=policy["total_nominal_exposure_cap"], total_effective_exposure_cap=policy["total_effective_exposure_cap"], max_positions=policy["max_positions"], + **daily_loss_kwargs, ) except (TypeError, ValueError): return {**capabilities, "runtime_risk_limits": object()}, "unavailable:invalid_runtime_risk_limits" diff --git a/tests/test_account_new_risk_gate.py b/tests/test_account_new_risk_gate.py index c506f9f..4cf1d06 100644 --- a/tests/test_account_new_risk_gate.py +++ b/tests/test_account_new_risk_gate.py @@ -41,6 +41,9 @@ class AccountNewRiskGateSupportTests(unittest.TestCase): def tearDown(self) -> None: set_cycle_snapshot(None) os.environ.pop(ACCOUNT_NEW_RISK_GATE_ENV, None) + os.environ.pop("SCHWAB_MAX_DAILY_LOSS_USD", None) + os.environ.pop("MAX_DAILY_LOSS_USD", None) + os.environ.pop("RUNTIME_TARGET_JSON", None) def test_missing_equity_prohibits_fail_closed(self) -> None: portfolio = {"market_values": {"SOXL": 0.0}, "liquid_cash": 100.0} @@ -121,6 +124,56 @@ def test_snapshot_maps_total_equity_from_portfolio(self) -> None: snapshot = build_snapshot_from_portfolio({"total_equity": 12_345.0}) self.assertEqual(snapshot.equity_usd, 12_345.0) + def test_explicit_daily_loss_at_limit_prohibits_buy(self) -> None: + portfolio = { + "total_equity": 50_000.0, + "metadata": {"total_equity_source": "broker_liquidation_value"}, + "account_new_risk_snapshot": { + "daily_loss_usd": 100.0, + "max_daily_loss_usd": 100.0, + }, + } + with patch( + "application.account_new_risk_gate_support.resolve_production_drift_status_from_store", + return_value=None, + ): + snapshot = build_snapshot_from_portfolio(portfolio) + result = evaluate_portfolio_new_risk_admission(portfolio) + self.assertEqual(snapshot.daily_loss_usd, 100.0) + self.assertEqual(result.disposition, NewRiskDisposition.NEW_RISK_PROHIBITED) + self.assertIn("DAILY_LOSS_LIMIT_EXCEEDED", result.reason_codes) + self.assertTrue(new_risk_buy_prohibited(result)) + + def test_unconfigured_daily_loss_limit_omits_axis(self) -> None: + portfolio = { + "total_equity": 50_000.0, + "metadata": {"total_equity_source": "broker_liquidation_value"}, + # daily_loss fact absent / invalid must not invent a prohibit when + # no max_daily_loss_usd is configured. + "account_new_risk_snapshot": {"daily_loss_usd": float("nan")}, + } + with patch( + "application.account_new_risk_gate_support.resolve_production_drift_status_from_store", + return_value=None, + ): + result = evaluate_portfolio_new_risk_admission(portfolio) + self.assertEqual(result.disposition, NewRiskDisposition.ALLOW_NEW_RISK) + self.assertNotIn("DAILY_LOSS_UNKNOWN_FAIL_CLOSED", result.reason_codes) + self.assertNotIn("DAILY_LOSS_LIMIT_EXCEEDED", result.reason_codes) + + def test_configured_limit_without_daily_loss_fact_fails_closed(self) -> None: + portfolio = { + "total_equity": 50_000.0, + "metadata": {"total_equity_source": "broker_liquidation_value"}, + "account_new_risk_snapshot": {"max_daily_loss_usd": 100.0}, + } + with patch( + "application.account_new_risk_gate_support.resolve_production_drift_status_from_store", + return_value=None, + ): + result = evaluate_portfolio_new_risk_admission(portfolio) + self.assertEqual(result.disposition, NewRiskDisposition.NEW_RISK_PROHIBITED) + self.assertIn("DAILY_LOSS_UNKNOWN_FAIL_CLOSED", result.reason_codes) def test_snapshot_maps_production_drift_status_from_account_new_risk_snapshot(self) -> None: snapshot = build_snapshot_from_portfolio( diff --git a/uv.lock b/uv.lock index 3f81574..218e818 100644 --- a/uv.lock +++ b/uv.lock @@ -17,7 +17,7 @@ resolution-markers = [ ] [manifest] -overrides = [{ name = "quant-platform-kit", git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=a90937621a7a05c9f72d0ca8a29be3fcb18a327c" }] +overrides = [{ name = "quant-platform-kit", git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=1826204e2927f4812a69348dd9c3d5235265be88" }] [[package]] name = "anyio" @@ -186,7 +186,7 @@ requires-dist = [ { name = "pytest", marker = "extra == 'test'" }, { name = "pytest-cov", marker = "extra == 'test'" }, { name = "pytz" }, - { name = "quant-platform-kit", git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=a90937621a7a05c9f72d0ca8a29be3fcb18a327c" }, + { name = "quant-platform-kit", git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=1826204e2927f4812a69348dd9c3d5235265be88" }, { name = "requests" }, { name = "ruff", marker = "extra == 'test'" }, { name = "schwab-py" }, @@ -1320,7 +1320,7 @@ wheels = [ [[package]] name = "quant-platform-kit" version = "1.0.0" -source = { git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=a90937621a7a05c9f72d0ca8a29be3fcb18a327c#a90937621a7a05c9f72d0ca8a29be3fcb18a327c" } +source = { git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=1826204e2927f4812a69348dd9c3d5235265be88#1826204e2927f4812a69348dd9c3d5235265be88" } [[package]] name = "requests"