From 3c5b60ac2ebd2cab72abe593424f0ca6b9e4ef32 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 18 Sep 2026 08:22:13 +0800 Subject: [PATCH 1/2] fix(risk): notify Attention on NEW_RISK and pin QPK bd8d06e Page ACTION/HALT via publish_attention_telegram_transition after the account gate, with process-local dedup. Pin QPK for mandate budgets. Co-authored-by: Cursor --- .github/workflows/ci.yml | 2 +- application/account_new_risk_gate_support.py | 103 +++++++++++++++++++ application/execution_service.py | 16 ++- pyproject.toml | 4 +- qsl.toml | 2 +- tests/test_account_new_risk_gate.py | 47 +++++++++ uv.lock | 6 +- 7 files changed, 172 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9220f19..faf195c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,7 +59,7 @@ jobs: - name: Check QPK pin consistency run: | set -euo pipefail - QPK_EXPECTED_PIN=f982aea79476cadd54d074f7c0447c1111658968 uv run --no-sync python scripts/check_qpk_pin_consistency.py + QPK_EXPECTED_PIN=bd8d06e50f88927eb525c2c585752435ab24492a uv run --no-sync python scripts/check_qpk_pin_consistency.py - name: Ensure uv.lock matches pyproject.toml run: uv lock --check diff --git a/application/account_new_risk_gate_support.py b/application/account_new_risk_gate_support.py index 726438d..931f234 100644 --- a/application/account_new_risk_gate_support.py +++ b/application/account_new_risk_gate_support.py @@ -354,6 +354,109 @@ def apply_combined_scale(value: float, scale: float | None) -> float: return value * scale + +_ATTENTION_PLATFORM = "ibkr" +_OPERATIONAL_UNCERTAIN_REASONS = frozenset( + { + "EQUITY_UNKNOWN_FAIL_CLOSED", + "SNAPSHOT_VALIDATION_FAIL_CLOSED", + "RECONCILIATION_NOT_VERIFIED", + "CIRCUIT_BREAKER_OPEN", + "UNKNOWN_PENDING_ORDERS", + } +) +_attention_sent_keys: set[str] = set() + + +def _resolve_attention_strategy_profile(portfolio: Mapping[str, Any]) -> str: + projection = _mapping_or_empty(portfolio.get("account_new_risk_snapshot")) + for source in (projection, portfolio, _mapping_or_empty(portfolio.get("metadata"))): + value = source.get("strategy_profile") + if isinstance(value, str) and value.strip(): + return value.strip() + return str(os.environ.get("STRATEGY_PROFILE") or "").strip() or _DEFAULT_STRATEGY_PROFILE + + +def _resolve_attention_account_alias( + portfolio: Mapping[str, Any], + execution: Mapping[str, Any] | None, +) -> str: + for source in ( + portfolio, + _mapping_or_empty(portfolio.get("metadata")), + _mapping_or_empty(execution), + ): + for key in ("account_alias", "account_hash", "account_id", "account"): + value = source.get(key) + if value is not None and str(value).strip(): + text = str(value).strip() + return text[-8:] if len(text) > 8 else text + for env_key in ("IBKR_ACCOUNT_ID", "ACCOUNT_ALIAS"): + env_alias = str(os.environ.get(env_key) or "").strip() + if env_alias: + return env_alias[-8:] if len(env_alias) > 8 else env_alias + return "unknown" + + +def maybe_publish_attention_for_admission( + admission: NewRiskAdmissionResult, + *, + portfolio: Mapping[str, Any], + execution: Mapping[str, Any] | None = None, + snapshot: InjectedReconciliationSnapshot | None = None, + telegram_sender: Any | None = None, + log_message: Any = print, +) -> Mapping[str, int]: + """Publish ACTION/HALT attention when NEW_RISK / ops axes require a page. + + Dedupes on transition keys within the process. Never grants live, raises RRL, + or invents daily-loss facts. + """ + + try: + from quant_platform_kit.risk.attention import ( + AttentionAxes, + evaluate_attention, + resolve_mandate_dd_budget, + ) + from quant_platform_kit.risk.attention_notify import publish_attention_telegram_transition + except ImportError: + try: + log_message("attention_telegram_skipped reason=attention_api_unavailable") + except TypeError: + log_message("attention_telegram_skipped reason=attention_api_unavailable", flush=True) + return {"sent": 0, "skipped": 1, "failed": 0} + + reasons = tuple(admission.reason_codes or ()) + prohibited = new_risk_buy_prohibited(admission) + operational_uncertain = any(code in _OPERATIONAL_UNCERTAIN_REASONS for code in reasons) + profile = _resolve_attention_strategy_profile(portfolio) + drawdown = None if snapshot is None else snapshot.drawdown_from_peak + decision = evaluate_attention( + AttentionAxes( + new_risk_prohibited=True if prohibited else None, + operational_uncertain=True if operational_uncertain else None, + drawdown_from_peak=drawdown, + mandate_dd_budget=resolve_mandate_dd_budget(profile), + ) + ) + return publish_attention_telegram_transition( + decision=decision, + platform=_ATTENTION_PLATFORM, + account_alias=_resolve_attention_account_alias(portfolio, execution), + strategy_profile=profile, + previous_level=None, + already_sent_keys=list(_attention_sent_keys), + record_sent_key=_attention_sent_keys.add, + telegram_sender=telegram_sender, + log_message=log_message, + ) + + +def reset_attention_sent_keys_for_tests() -> None: + _attention_sent_keys.clear() + + def get_cycle_snapshot() -> InjectedReconciliationSnapshot | None: return _cycle_snapshot diff --git a/application/execution_service.py b/application/execution_service.py index 8f6eff9..e76e6fa 100644 --- a/application/execution_service.py +++ b/application/execution_service.py @@ -18,6 +18,7 @@ build_snapshot_from_portfolio, evaluate_account_values_new_risk_admission, is_account_new_risk_gate_enabled, + maybe_publish_attention_for_admission, new_risk_buy_prohibited, set_cycle_snapshot, ) @@ -1416,7 +1417,20 @@ def record_quote_snapshot(symbol, snapshot) -> None: account_new_risk_buy_blocked = new_risk_buy_prohibited(admission) account_new_risk_reason_codes = tuple(admission.reason_codes) portfolio = build_portfolio_from_account_values(account_values, signal_metadata=signal_metadata) - set_cycle_snapshot(build_snapshot_from_portfolio(portfolio)) + cycle_snapshot = build_snapshot_from_portfolio(portfolio) + set_cycle_snapshot(cycle_snapshot) + attention_counts = maybe_publish_attention_for_admission( + admission, + portfolio=portfolio, + snapshot=cycle_snapshot, + ) + print( + "[Attention notify] " + f"sent={attention_counts.get('sent', 0)} " + f"skipped={attention_counts.get('skipped', 0)} " + f"failed={attention_counts.get('failed', 0)}", + flush=True, + ) else: set_cycle_snapshot(None) diff --git a/pyproject.toml b/pyproject.toml index 78ab3bb..c355ee1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ dependencies = [ "google-cloud-secret-manager", "google-cloud-storage", "yfinance", - "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@f982aea79476cadd54d074f7c0447c1111658968", + "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@bd8d06e50f88927eb525c2c585752435ab24492a", "us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@4a3943883cd6b5bbfe32a559e56a91b40a81b7ce", "hk-equity-strategies @ git+https://github.com/QuantStrategyLab/HkEquityStrategies.git@709e5e1cde7841aed538d94eb26b552b46cb7806", ] @@ -64,5 +64,5 @@ include = [ [tool.uv] override-dependencies = [ - "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@f982aea79476cadd54d074f7c0447c1111658968", + "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@bd8d06e50f88927eb525c2c585752435ab24492a", ] diff --git a/qsl.toml b/qsl.toml index 9f1035a..673cf72 100644 --- a/qsl.toml +++ b/qsl.toml @@ -5,7 +5,7 @@ upgrade_ring = "ring_d" allow_legacy = false [qsl.requires] -quant_platform_kit = "f982aea79476cadd54d074f7c0447c1111658968" +quant_platform_kit = "bd8d06e50f88927eb525c2c585752435ab24492a" us_equity_strategies = "4a3943883cd6b5bbfe32a559e56a91b40a81b7ce" hk_equity_strategies = "709e5e1cde7841aed538d94eb26b552b46cb7806" diff --git a/tests/test_account_new_risk_gate.py b/tests/test_account_new_risk_gate.py index fd66877..08e654c 100644 --- a/tests/test_account_new_risk_gate.py +++ b/tests/test_account_new_risk_gate.py @@ -23,7 +23,9 @@ evaluate_cycle_new_risk_admission, evaluate_portfolio_new_risk_admission, is_account_new_risk_gate_enabled, + maybe_publish_attention_for_admission, new_risk_buy_prohibited, + reset_attention_sent_keys_for_tests, set_cycle_snapshot, ) from application.ibkr_order_execution import submit_order_intent @@ -32,6 +34,7 @@ @pytest.fixture(autouse=True) def _clear_cycle_snapshot(): set_cycle_snapshot(None) + reset_attention_sent_keys_for_tests() for key in ( "IBKR_MAX_DAILY_LOSS_USD", "MAX_DAILY_LOSS_USD", @@ -350,3 +353,47 @@ def test_cycle_gate_without_snapshot_is_fail_closed(): result = evaluate_cycle_new_risk_admission() assert result.disposition == NewRiskDisposition.NEW_RISK_PROHIBITED assert "EQUITY_UNKNOWN_FAIL_CLOSED" in result.reason_codes + +def test_attention_notify_on_new_risk_prohibit_dedupes(monkeypatch): + import sys + from pathlib import Path + + qpk = Path("/Users/lisiyi/Projects/.worktrees/qpk-attention-wire-20260918/src") + if qpk.exists() and str(qpk) not in sys.path: + sys.path.insert(0, str(qpk)) + + reset_attention_sent_keys_for_tests() + portfolio = { + "total_equity": 50_000.0, + "strategy_profile": "soxl_soxx_trend_income", + "account_id": "U1599999", + "account_new_risk_snapshot": {"production_drift_status": "critical"}, + } + admission = evaluate_portfolio_new_risk_admission(portfolio) + assert new_risk_buy_prohibited(admission) + snapshot = build_snapshot_from_portfolio(portfolio) + payloads: list[str] = [] + + def _sender(*, text: str, alert_key: str | None = None, **_kwargs) -> bool: + payloads.append(text) + return True + + counts = maybe_publish_attention_for_admission( + admission, + portfolio=portfolio, + snapshot=snapshot, + telegram_sender=_sender, + log_message=lambda *_a, **_k: None, + ) + assert counts.get("sent") == 1 + counts2 = maybe_publish_attention_for_admission( + admission, + portfolio=portfolio, + snapshot=snapshot, + telegram_sender=_sender, + log_message=lambda *_a, **_k: None, + ) + assert counts2.get("sent") == 0 + assert counts2.get("skipped") == 1 + assert len(payloads) == 1 + diff --git a/uv.lock b/uv.lock index f0c5603..c491737 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=f982aea79476cadd54d074f7c0447c1111658968" }] +overrides = [{ name = "quant-platform-kit", git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=bd8d06e50f88927eb525c2c585752435ab24492a" }] [[package]] name = "beautifulsoup4" @@ -791,7 +791,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=f982aea79476cadd54d074f7c0447c1111658968" }, + { name = "quant-platform-kit", git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=bd8d06e50f88927eb525c2c585752435ab24492a" }, { name = "requests" }, { name = "ruff", marker = "extra == 'test'" }, { name = "us-equity-strategies", git = "https://github.com/QuantStrategyLab/UsEquityStrategies.git?rev=4a3943883cd6b5bbfe32a559e56a91b40a81b7ce" }, @@ -1327,7 +1327,7 @@ wheels = [ [[package]] name = "quant-platform-kit" version = "1.0.0" -source = { git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=f982aea79476cadd54d074f7c0447c1111658968#f982aea79476cadd54d074f7c0447c1111658968" } +source = { git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=bd8d06e50f88927eb525c2c585752435ab24492a#bd8d06e50f88927eb525c2c585752435ab24492a" } [[package]] name = "requests" From 0f844a0da39f754e46ba59f0306455ae2819f7fb Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 18 Sep 2026 08:33:07 +0800 Subject: [PATCH 2/2] fix(risk): apply combined_scale to allocation targets Scale live targets via QPK apply_combined_scale_to_targets after admission; stop shrinking submit quantities. Keeps Attention paging. Co-authored-by: Cursor --- application/account_new_risk_gate_support.py | 13 ++++++++----- application/execution_service.py | 11 +++++++++++ application/ibkr_order_execution.py | 5 ----- tests/test_account_new_risk_gate.py | 18 +++++++++++++----- 4 files changed, 32 insertions(+), 15 deletions(-) diff --git a/application/account_new_risk_gate_support.py b/application/account_new_risk_gate_support.py index 931f234..9b766dc 100644 --- a/application/account_new_risk_gate_support.py +++ b/application/account_new_risk_gate_support.py @@ -347,11 +347,14 @@ def new_risk_buy_prohibited(result: NewRiskAdmissionResult) -> bool: return result.disposition == NewRiskDisposition.NEW_RISK_PROHIBITED -def apply_combined_scale(value: float, scale: float | None) -> float: - """Apply a valid reducing scale; missing or out-of-range values are a no-op.""" - if scale is None or not math.isfinite(scale) or not 0.0 < scale <= 1.0: - return value - return value * scale +def apply_combined_scale_to_target_weights( + target_weights: Mapping[str, Any] | None, + combined_scale: float | None, +) -> dict[str, float]: + """Shrink target weights by admission combined_scale; omit when scale missing.""" + from quant_platform_kit.risk.capital_risk_envelope import apply_combined_scale_to_targets + + return apply_combined_scale_to_targets(target_weights, combined_scale) diff --git a/application/execution_service.py b/application/execution_service.py index e76e6fa..12396ae 100644 --- a/application/execution_service.py +++ b/application/execution_service.py @@ -14,6 +14,7 @@ import pandas as pd from application.account_new_risk_gate_support import ( + apply_combined_scale_to_target_weights, build_portfolio_from_account_values, build_snapshot_from_portfolio, evaluate_account_values_new_risk_admission, @@ -1431,6 +1432,16 @@ def record_quote_snapshot(symbol, snapshot) -> None: f"failed={attention_counts.get('failed', 0)}", flush=True, ) + target_weights = apply_combined_scale_to_target_weights( + target_weights, + admission.combined_scale, + ) + if admission.combined_scale is not None: + print( + f"[Envelope scale] combined_scale={admission.combined_scale} " + "applied_to_target_weights", + flush=True, + ) else: set_cycle_snapshot(None) diff --git a/application/ibkr_order_execution.py b/application/ibkr_order_execution.py index 884d563..05b9ae1 100644 --- a/application/ibkr_order_execution.py +++ b/application/ibkr_order_execution.py @@ -6,7 +6,6 @@ from typing import Any, Callable from application.account_new_risk_gate_support import ( - apply_combined_scale, evaluate_cycle_new_risk_admission, is_account_new_risk_gate_enabled, new_risk_buy_prohibited, @@ -127,10 +126,6 @@ def submit_order_intent( "live_authority_granted": admission.live_authority_granted, }, ) - intent = replace( - intent, - quantity=apply_combined_scale(intent.quantity, admission.combined_scale), - ) return _submit_order_intent( ib, intent, diff --git a/tests/test_account_new_risk_gate.py b/tests/test_account_new_risk_gate.py index 08e654c..e98a73b 100644 --- a/tests/test_account_new_risk_gate.py +++ b/tests/test_account_new_risk_gate.py @@ -15,7 +15,7 @@ from application.account_new_risk_gate_support import ( ACCOUNT_NEW_RISK_GATE_ENV, - apply_combined_scale, + apply_combined_scale_to_target_weights, build_account_new_risk_snapshot, build_portfolio_from_account_values, build_snapshot_from_portfolio, @@ -254,8 +254,15 @@ def test_build_portfolio_from_account_values_maps_equity(): } -def test_missing_combined_scale_is_no_op(): - assert apply_combined_scale(4.0, None) == 4.0 +def test_combined_scale_halves_target_weights(): + assert apply_combined_scale_to_target_weights({"TQQQ": 0.8, "QQQ": 0.2}, 0.5) == { + "TQQQ": 0.4, + "QQQ": 0.1, + } + + +def test_missing_combined_scale_leaves_target_weights(): + assert apply_combined_scale_to_target_weights({"TQQQ": 0.8}, None) == {"TQQQ": 0.8} def test_submit_order_intent_rejects_buy_when_gate_prohibits(): @@ -281,7 +288,8 @@ def test_submit_order_intent_rejects_buy_when_gate_prohibits(): assert "EQUITY_UNKNOWN_FAIL_CLOSED" in report.raw_payload.get("reason_codes", []) -def test_submit_order_intent_halves_buy_quantity_for_half_scale(): +def test_submit_order_intent_does_not_scale_buy_quantity(): + """Envelope scale applies to target weights, not submit-time quantity.""" set_cycle_snapshot( InjectedReconciliationSnapshot( observation_status="COMPLETE", @@ -300,7 +308,7 @@ def test_submit_order_intent_halves_buy_quantity_for_half_scale(): SimpleNamespace(), OrderIntent(symbol="SPY", side="buy", quantity=4.0), ) - assert submit_mock.call_args.args[1].quantity == 2.0 + assert submit_mock.call_args.args[1].quantity == 4.0 def test_submit_order_intent_allows_sell_when_gate_prohibits():