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
121 changes: 116 additions & 5 deletions application/account_new_risk_gate_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -316,11 +316,122 @@ 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



_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:
Expand Down
29 changes: 29 additions & 0 deletions application/execution_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@
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,
is_account_new_risk_gate_enabled,
maybe_publish_attention_for_admission,
new_risk_buy_prohibited,
set_cycle_snapshot,
)
Expand Down Expand Up @@ -1109,6 +1111,33 @@ 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)
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):
Expand Down
2 changes: 0 additions & 2 deletions application/longbridge_execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
Expand Down Expand Up @@ -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",
]
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
66 changes: 60 additions & 6 deletions tests/test_account_new_risk_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,21 @@
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")
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,
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
Expand All @@ -40,6 +44,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)
Expand Down Expand Up @@ -281,8 +286,56 @@ 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()
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({}))
Expand All @@ -305,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(
{
Expand Down Expand Up @@ -334,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({}))
Expand Down
5 changes: 4 additions & 1 deletion tests/test_rebalance_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
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