Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
116 changes: 111 additions & 5 deletions application/account_new_risk_gate_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -347,11 +347,117 @@ 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)



_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:
Expand Down
27 changes: 26 additions & 1 deletion application/execution_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@

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,
is_account_new_risk_gate_enabled,
maybe_publish_attention_for_admission,
new_risk_buy_prohibited,
set_cycle_snapshot,
)
Expand Down Expand Up @@ -1416,7 +1418,30 @@ 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,
)
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)

Expand Down
5 changes: 0 additions & 5 deletions application/ibkr_order_execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
Expand Down Expand Up @@ -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",
]
2 changes: 1 addition & 1 deletion qsl.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
65 changes: 60 additions & 5 deletions tests/test_account_new_risk_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,17 @@

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,
evaluate_account_values_new_risk_admission,
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
Expand All @@ -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",
Expand Down Expand Up @@ -251,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():
Expand All @@ -278,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",
Expand All @@ -297,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():
Expand Down Expand Up @@ -350,3 +361,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

6 changes: 3 additions & 3 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading