From 1608cbfd10d10ca940fa3cc0d429f185a794700c Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 18 Sep 2026 08:22:14 +0800 Subject: [PATCH 1/3] 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 --- 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 | 43 +++++++- uv.lock | 6 +- 6 files changed, 167 insertions(+), 7 deletions(-) diff --git a/application/account_new_risk_gate_support.py b/application/account_new_risk_gate_support.py index 33d209a..ce7a62a 100644 --- a/application/account_new_risk_gate_support.py +++ b/application/account_new_risk_gate_support.py @@ -323,6 +323,109 @@ def apply_combined_scale(value: float, scale: float | None) -> float: return value * scale + +_ATTENTION_PLATFORM = "longbridge" +_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 ("LONGBRIDGE_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 2090963..8cf1309 100644 --- a/application/execution_service.py +++ b/application/execution_service.py @@ -11,6 +11,7 @@ build_snapshot_from_portfolio, evaluate_portfolio_new_risk_admission, is_account_new_risk_gate_enabled, + maybe_publish_attention_for_admission, new_risk_buy_prohibited, set_cycle_snapshot, ) @@ -1109,6 +1110,21 @@ def record_quote_snapshot(snapshot) -> None: # Dry-run verification reports need this axis proof; live heartbeat/ # trade notifications stay unchanged (console diagnostic above still fires). note_logs.append(gate_diagnostic_message) + attention_counts = maybe_publish_attention_for_admission( + admission, + portfolio=portfolio, + execution=execution, + snapshot=account_new_risk_cycle_snapshot, + ) + attention_message = ( + "[Attention notify] " + f"sent={attention_counts.get('sent', 0)} " + f"skipped={attention_counts.get('skipped', 0)} " + f"failed={attention_counts.get('failed', 0)}" + ) + print(with_prefix(attention_message), flush=True) + if dry_run_only: + note_logs.append(attention_message) else: set_cycle_snapshot(None) if _execution_is_blocked(plan=plan, execution=execution, allocation=allocation): diff --git a/pyproject.toml b/pyproject.toml index 38d4b18..f74ac00 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ dependencies = [ "google-cloud-storage", "google-auth", "longport==3.0.23", - "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", ] @@ -61,5 +61,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 b9ec3b5..3f95bf7 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 4deea4b..702d460 100644 --- a/tests/test_account_new_risk_gate.py +++ b/tests/test_account_new_risk_gate.py @@ -16,7 +16,8 @@ REPO_ROOT.parent / "QuantPlatformKit" / ".worktrees" / "drift-to-new-risk-a" / "src" ) QPK_SRC = REPO_ROOT.parent / "QuantPlatformKit" / "src" -for qpk_src in (QPK_DRIFT_WORKTREE_SRC, QPK_SRC, QPK_PIN_WORKTREE_SRC): +QPK_ATTENTION_WIRE_SRC = Path("/Users/lisiyi/Projects/.worktrees/qpk-attention-wire-20260918/src") +for qpk_src in (QPK_ATTENTION_WIRE_SRC, QPK_DRIFT_WORKTREE_SRC, QPK_SRC, QPK_PIN_WORKTREE_SRC): if (qpk_src / "quant_platform_kit").exists() and str(qpk_src) not in sys.path: sys.path.insert(0, str(qpk_src)) @@ -26,7 +27,9 @@ build_account_new_risk_snapshot, build_snapshot_from_portfolio, evaluate_portfolio_new_risk_admission, + maybe_publish_attention_for_admission, new_risk_buy_prohibited, + reset_attention_sent_keys_for_tests, set_cycle_snapshot, ) from application.execution_service import execute_rebalance_cycle @@ -40,6 +43,7 @@ class AccountNewRiskGateSupportTests(unittest.TestCase): def tearDown(self) -> None: set_cycle_snapshot(None) + reset_attention_sent_keys_for_tests() os.environ.pop(ACCOUNT_NEW_RISK_GATE_ENV, None) os.environ.pop("LONGBRIDGE_MAX_DAILY_LOSS_USD", None) os.environ.pop("MAX_DAILY_LOSS_USD", None) @@ -284,6 +288,43 @@ def test_explicit_healthy_snapshot_allows_new_risk(self) -> None: def test_missing_combined_scale_is_no_op(self) -> None: self.assertEqual(apply_combined_scale(4.0, None), 4.0) + def test_attention_notify_on_new_risk_prohibit_dedupes(self) -> None: + reset_attention_sent_keys_for_tests() + portfolio = { + "total_equity": 50_000.0, + "strategy_profile": "soxl_soxx_trend_income", + "account_id": "00827", + "account_new_risk_snapshot": {"production_drift_status": "critical"}, + } + admission = evaluate_portfolio_new_risk_admission(portfolio) + self.assertTrue(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, + ) + self.assertEqual(counts.get("sent"), 1) + counts2 = maybe_publish_attention_for_admission( + admission, + portfolio=portfolio, + snapshot=snapshot, + telegram_sender=_sender, + log_message=lambda *_a, **_k: None, + ) + self.assertEqual(counts2.get("sent"), 0) + self.assertEqual(counts2.get("skipped"), 1) + self.assertEqual(len(payloads), 1) + + def test_submit_order_blocks_buy_when_equity_missing(self) -> None: set_cycle_snapshot(build_snapshot_from_portfolio({})) attempts = {"count": 0} diff --git a/uv.lock b/uv.lock index 4fdd49a..7ea46e3 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 = "blinker" @@ -727,7 +727,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" }, @@ -1210,7 +1210,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 f145160f9f09f7a675e97061c67e8ba9ae44f1f1 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 18 Sep 2026 08:33:08 +0800 Subject: [PATCH 2/3] 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 | 18 ++++++++++---- application/execution_service.py | 13 ++++++++++ application/longbridge_execution.py | 2 -- tests/test_account_new_risk_gate.py | 25 +++++++++++++++----- 4 files changed, 45 insertions(+), 13 deletions(-) diff --git a/application/account_new_risk_gate_support.py b/application/account_new_risk_gate_support.py index ce7a62a..702a264 100644 --- a/application/account_new_risk_gate_support.py +++ b/application/account_new_risk_gate_support.py @@ -316,11 +316,19 @@ 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_allocation_targets( + allocation: Mapping[str, Any] | None, + combined_scale: float | None, +) -> dict[str, Any]: + """Shrink allocation targets by admission combined_scale; omit when scale missing.""" + from quant_platform_kit.risk.capital_risk_envelope import apply_combined_scale_to_targets + + allocation_out = dict(allocation or {}) + allocation_out["targets"] = apply_combined_scale_to_targets( + allocation_out.get("targets"), + combined_scale, + ) + return allocation_out diff --git a/application/execution_service.py b/application/execution_service.py index 8cf1309..a61b956 100644 --- a/application/execution_service.py +++ b/application/execution_service.py @@ -7,6 +7,7 @@ from dataclasses import dataclass from application.account_new_risk_gate_support import ( + apply_combined_scale_to_allocation_targets, build_account_new_risk_snapshot, build_snapshot_from_portfolio, evaluate_portfolio_new_risk_admission, @@ -1125,6 +1126,18 @@ def record_quote_snapshot(snapshot) -> None: print(with_prefix(attention_message), flush=True) if dry_run_only: note_logs.append(attention_message) + allocation = apply_combined_scale_to_allocation_targets( + allocation, + admission.combined_scale, + ) + if admission.combined_scale is not None: + scale_message = ( + f"[Envelope scale] combined_scale={admission.combined_scale} " + "applied_to_allocation_targets" + ) + print(with_prefix(scale_message), flush=True) + if dry_run_only: + note_logs.append(scale_message) else: set_cycle_snapshot(None) if _execution_is_blocked(plan=plan, execution=execution, allocation=allocation): diff --git a/application/longbridge_execution.py b/application/longbridge_execution.py index 9ea897b..87fc54e 100644 --- a/application/longbridge_execution.py +++ b/application/longbridge_execution.py @@ -3,7 +3,6 @@ from typing import Any 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, @@ -68,7 +67,6 @@ def submit_order( "live_authority_granted": admission.live_authority_granted, }, ) - quantity = apply_combined_scale(quantity, admission.combined_scale) return _get_qpk_submit_order()( t_ctx, symbol, diff --git a/tests/test_account_new_risk_gate.py b/tests/test_account_new_risk_gate.py index 702d460..f05cf83 100644 --- a/tests/test_account_new_risk_gate.py +++ b/tests/test_account_new_risk_gate.py @@ -17,13 +17,14 @@ ) QPK_SRC = REPO_ROOT.parent / "QuantPlatformKit" / "src" QPK_ATTENTION_WIRE_SRC = Path("/Users/lisiyi/Projects/.worktrees/qpk-attention-wire-20260918/src") -for qpk_src in (QPK_ATTENTION_WIRE_SRC, QPK_DRIFT_WORKTREE_SRC, QPK_SRC, QPK_PIN_WORKTREE_SRC): +QPK_ENVELOPE_SRC = Path("/Users/lisiyi/Projects/.worktrees/qpk-envelope-scale-20260918/src") +for qpk_src in (QPK_ENVELOPE_SRC, QPK_ATTENTION_WIRE_SRC, QPK_DRIFT_WORKTREE_SRC, QPK_SRC, QPK_PIN_WORKTREE_SRC): if (qpk_src / "quant_platform_kit").exists() and str(qpk_src) not in sys.path: sys.path.insert(0, str(qpk_src)) from application.account_new_risk_gate_support import ( ACCOUNT_NEW_RISK_GATE_ENV, - apply_combined_scale, + apply_combined_scale_to_allocation_targets, build_account_new_risk_snapshot, build_snapshot_from_portfolio, evaluate_portfolio_new_risk_admission, @@ -285,8 +286,19 @@ def test_explicit_healthy_snapshot_allows_new_risk(self) -> None: self.assertEqual(result.disposition, NewRiskDisposition.ALLOW_NEW_RISK) self.assertFalse(result.live_authority_granted) - def test_missing_combined_scale_is_no_op(self) -> None: - self.assertEqual(apply_combined_scale(4.0, None), 4.0) + def test_combined_scale_halves_allocation_targets(self) -> None: + scaled = apply_combined_scale_to_allocation_targets( + {"targets": {"2800.HK": 0.5, "2828.HK": 0.5}}, + 0.5, + ) + self.assertEqual(scaled["targets"], {"2800.HK": 0.25, "2828.HK": 0.25}) + + def test_missing_combined_scale_leaves_targets(self) -> None: + allocation = {"targets": {"2800.HK": 0.5}} + self.assertEqual( + apply_combined_scale_to_allocation_targets(allocation, None)["targets"], + {"2800.HK": 0.5}, + ) def test_attention_notify_on_new_risk_prohibit_dedupes(self) -> None: reset_attention_sent_keys_for_tests() @@ -346,7 +358,8 @@ def fake_submit(*_args, **_kwargs): self.assertEqual(report.status, "rejected") self.assertEqual(report.raw_payload.get("detail"), "account_new_risk_gate") - def test_submit_order_halves_buy_quantity_for_half_scale(self) -> None: + def test_submit_order_does_not_scale_buy_quantity(self) -> None: + """Envelope scale applies to allocation targets, not submit-time quantity.""" set_cycle_snapshot( build_snapshot_from_portfolio( { @@ -375,7 +388,7 @@ def fake_submit(*_args, **kwargs): quantity=4.0, ) - self.assertEqual(submitted["quantity"], 2.0) + self.assertEqual(submitted["quantity"], 4.0) def test_submit_order_allows_sell_when_buy_prohibited(self) -> None: set_cycle_snapshot(build_snapshot_from_portfolio({})) From 15c0d63d64d79d1450740fbfc0f32b6458aa659e Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 18 Sep 2026 09:21:24 +0800 Subject: [PATCH 3/3] fix(test): keep zero-investable scenario above envelope-scaled target Avoid capital-band combined_scale=0.85 flipping the fixture into a sell. Co-authored-by: Cursor --- tests/test_rebalance_service.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_rebalance_service.py b/tests/test_rebalance_service.py index 09522f0..91810cc 100644 --- a/tests/test_rebalance_service.py +++ b/tests/test_rebalance_service.py @@ -3076,10 +3076,13 @@ def test_zero_target_sell_uses_sellable_quantity_not_price_derived_floor(self): self.assertIn("限价卖出] SOXL: 2股", sent_messages[0]) def test_zero_investable_cash_reports_buying_power_without_trade_note(self): + # Equity ~103k sits in the 50k–250k capital band (combined_scale=0.85). + # Keep pre-scale targets high enough that post-scale target still exceeds + # market value so the scenario stays "want buy / zero investable cash". plan = _build_plan( strategy_symbols=("BOXX",), safe_haven_symbols=("BOXX",), - targets={"BOXX": 27316.33}, + targets={"BOXX": 32000.0}, market_values={"BOXX": 24880.00}, sellable_quantities={"BOXX": 214}, quantities={"BOXX": 214},