Skip to content

Commit ca5c79a

Browse files
authored
Merge pull request #373 from QuantStrategyLab/codex/longbridge-account-identity
feat: gate LongBridge orders on account identity
2 parents cfe1e42 + 0a12218 commit ca5c79a

8 files changed

Lines changed: 208 additions & 2 deletions

application/account_identity.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""Read-only LongBridge account-identity evidence adapter."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any
6+
7+
from quant_platform_kit.common.account_identity import (
8+
AccountIdentityEvidenceSource,
9+
BrokerAccountIdentity,
10+
)
11+
12+
13+
def observe_longbridge_account_identity(trade_context: Any) -> BrokerAccountIdentity | None:
14+
"""Return only the account type exposed by LongBridge's positions API.
15+
16+
LongBridge's public trade API exposes ``account_channel`` on position
17+
channels, but it does not expose a stable account number or paper/live
18+
marker for this comparison. Returning ``None`` on an API failure lets the
19+
shared policy produce a redacted evidence-unavailable finding.
20+
"""
21+
22+
try:
23+
response = trade_context.stock_positions()
24+
except Exception:
25+
return None
26+
channels = getattr(response, "channels", None)
27+
if channels is None:
28+
return None
29+
account_types = tuple(
30+
str(getattr(channel, "account_channel", "") or "").strip()
31+
for channel in channels
32+
if str(getattr(channel, "account_channel", "") or "").strip()
33+
)
34+
return BrokerAccountIdentity(
35+
platform_id="longbridge",
36+
evidence_source=AccountIdentityEvidenceSource.BROKER_API_PARTIAL,
37+
account_types=account_types,
38+
)

application/rebalance_service.py

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@
99
from application.execution_state import build_execution_marker_key
1010
from application.durable_execution_commands import enqueue_paper_execution_command
1111
from application.runtime_dependencies import LongBridgeRebalanceConfig, LongBridgeRebalanceRuntime
12+
from quant_platform_kit.common.account_identity import (
13+
AccountIdentityGuardedExecutionPort,
14+
AccountIdentityPolicy,
15+
evaluate_account_identity,
16+
)
1217
from quant_platform_kit.longbridge.market_data import fetch_lot_sizes
1318
from application.signal_snapshot import build_signal_snapshot
1419
from notifications.events import NotificationPublisher
@@ -240,6 +245,37 @@ def _durable_command_required_message(*, execution: dict) -> str:
240245
)
241246

242247

248+
def _account_identity_blocked_message(*, findings: tuple[str, ...]) -> str:
249+
detail = ", ".join(findings) or "account_identity_blocked"
250+
return f"Broker orders blocked by account identity gate: {detail}"
251+
252+
253+
def _evaluate_execution_account_identity(
254+
*,
255+
runtime: LongBridgeRebalanceRuntime,
256+
config: LongBridgeRebalanceConfig,
257+
trade_context,
258+
):
259+
policy = getattr(config, "account_identity_policy", None)
260+
if policy is None:
261+
policy = AccountIdentityPolicy()
262+
if not isinstance(policy, AccountIdentityPolicy):
263+
policy = AccountIdentityPolicy.from_mapping(policy)
264+
if not policy.is_configured:
265+
return None
266+
observer = getattr(runtime, "account_identity_observer", None)
267+
observation = observer(trade_context) if callable(observer) else None
268+
return evaluate_account_identity(
269+
expected_platform_id=getattr(
270+
config,
271+
"account_identity_expected_platform_id",
272+
"longbridge",
273+
),
274+
policy=policy,
275+
observation=observation,
276+
)
277+
278+
243279
def _should_record_execution_marker(*, result: ExecutionCycleResult, config: LongBridgeRebalanceConfig) -> bool:
244280
if not getattr(config, "execution_dedup_enabled", False):
245281
return False
@@ -293,6 +329,16 @@ def run_strategy(
293329
quote_context, trade_context, indicators = runtime.bootstrap()
294330
market_data_port = runtime.market_data_port_factory(quote_context)
295331
execution_port = runtime.execution_port_factory(trade_context)
332+
account_identity_decision = _evaluate_execution_account_identity(
333+
runtime=runtime,
334+
config=config,
335+
trade_context=trade_context,
336+
)
337+
if account_identity_decision is not None:
338+
execution_port = AccountIdentityGuardedExecutionPort(
339+
delegate=execution_port,
340+
decision=account_identity_decision,
341+
)
296342

297343
def load_plan(*, current_snapshot):
298344
current_snapshot = attach_strategy_plugin_metadata(
@@ -318,6 +364,24 @@ def fetch_replanned_state():
318364
return load_plan(current_snapshot=current_snapshot)
319365

320366
plan, portfolio, execution, allocation = fetch_replanned_state()
367+
account_identity_blocked = bool(
368+
account_identity_decision is not None
369+
and not account_identity_decision.broker_write_allowed
370+
)
371+
if account_identity_decision is not None:
372+
execution["account_identity"] = account_identity_decision.to_receipt()
373+
if account_identity_decision.would_block:
374+
print(
375+
config.with_prefix(
376+
"account_identity_gate "
377+
f"enforcement={account_identity_decision.policy.enforcement.value} "
378+
f"findings={','.join(account_identity_decision.findings)}"
379+
),
380+
flush=True,
381+
)
382+
if account_identity_blocked:
383+
execution["account_identity_blocked"] = True
384+
execution["account_identity_block_reason"] = "account_identity_verification_failed"
321385
paper_command_observation = enqueue_paper_execution_command(
322386
enabled=bool(getattr(config, "durable_execution_command_paper_enabled", False)),
323387
dry_run_only=bool(getattr(config, "dry_run_only", False)),
@@ -342,7 +406,7 @@ def fetch_replanned_state():
342406
if direct_live_routing_blocked:
343407
execution["direct_live_routing_blocked"] = True
344408
execution["direct_live_routing_block_reason"] = "durable_execution_command_required"
345-
execution_already_recorded = direct_live_routing_blocked
409+
execution_already_recorded = direct_live_routing_blocked or account_identity_blocked
346410
execution_claim_acquired = False
347411
if not direct_live_routing_blocked and execution_marker_key and execution_state_store:
348412
try:
@@ -390,7 +454,12 @@ def fetch_replanned_state():
390454
) from exc
391455

392456
if execution_already_recorded:
393-
if direct_live_routing_blocked:
457+
if account_identity_blocked:
458+
message = _account_identity_blocked_message(
459+
findings=tuple(account_identity_decision.findings),
460+
)
461+
runtime.notify_issue("Account identity gate blocked broker orders", message)
462+
elif direct_live_routing_blocked:
394463
message = _durable_command_required_message(execution=execution)
395464
runtime.notify_issue("Next-session execution blocked", message)
396465
else:

application/runtime_composer.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from typing import Any
88

99
from application.runtime_bootstrap_adapters import build_runtime_bootstrap
10+
from application.account_identity import observe_longbridge_account_identity
1011
from application.runtime_dependencies import LongBridgeRebalanceConfig, LongBridgeRebalanceRuntime
1112
from application.execution_state import (
1213
build_execution_marker_store_from_env,
@@ -22,6 +23,7 @@
2223
from quant_platform_kit.common.runtime_assembly import build_runtime_assembly
2324
from quant_platform_kit.common.runtime_target import build_runtime_context_fields
2425
from quant_platform_kit.common.runtime_target import RuntimeTarget
26+
from quant_platform_kit.common.account_identity import AccountIdentityPolicy
2527
from quant_platform_kit.common.strategy_release import build_runtime_loaded_receipt
2628
from notifications.telegram import build_prefixer
2729
from quant_platform_kit.notifications.cycle_channel import build_cycle_sender
@@ -205,6 +207,7 @@ def build_rebalance_runtime(
205207
execution_port_factory=self.broker_adapters.build_execution_port,
206208
post_submit_order=notification_adapters.post_submit_order,
207209
fetch_order_status=self.fetch_order_status_fn,
210+
account_identity_observer=observe_longbridge_account_identity,
208211
)
209212

210213
def build_read_only_broker_contexts(self) -> tuple[Any, Any]:
@@ -306,6 +309,12 @@ def build_rebalance_config(
306309
if self.runtime_target is not None
307310
else None
308311
),
312+
account_identity_policy=AccountIdentityPolicy.from_mapping(
313+
self.runtime_target.account_identity
314+
if self.runtime_target is not None
315+
else None
316+
),
317+
account_identity_expected_platform_id="longbridge",
309318
)
310319

311320
def load_strategy_plugin_signals(self, raw_mounts):

application/runtime_dependencies.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ class LongBridgeRebalanceConfig:
4141
execution_command_store: Any = None
4242
runtime_release_receipt: Mapping[str, Any] | None = None
4343
expected_strategy_release: Any = None
44+
account_identity_policy: Any = None
45+
account_identity_expected_platform_id: str = "longbridge"
4446

4547

4648
@dataclass(frozen=True)
@@ -55,3 +57,4 @@ class LongBridgeRebalanceRuntime:
5557
execution_port_factory: Callable[[Any], ExecutionPort]
5658
post_submit_order: Callable[[Any, Any, Any], None] | None = None
5759
fetch_order_status: Callable[..., Any] | None = None
60+
account_identity_observer: Callable[[Any], Any] | None = None

main.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,9 @@ def _summarize_cycle_result_for_report(cycle_result, *, dry_run: bool) -> dict:
324324
durable_command = execution.get("durable_execution_command")
325325
if isinstance(durable_command, dict):
326326
summary["durable_execution_command"] = dict(durable_command)
327+
account_identity = execution.get("account_identity")
328+
if isinstance(account_identity, dict):
329+
summary["account_identity"] = dict(account_identity)
327330
return summary
328331

329332

tests/test_account_identity.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from __future__ import annotations
2+
3+
import sys
4+
from pathlib import Path
5+
from types import SimpleNamespace
6+
7+
8+
ROOT = Path(__file__).resolve().parents[1]
9+
if str(ROOT) not in sys.path:
10+
sys.path.insert(0, str(ROOT))
11+
QPK_SRC = ROOT.parent / "QuantPlatformKit" / "src"
12+
if QPK_SRC.exists() and str(QPK_SRC) not in sys.path:
13+
sys.path.insert(0, str(QPK_SRC))
14+
15+
from application.account_identity import observe_longbridge_account_identity
16+
17+
18+
def test_longbridge_identity_observer_uses_broker_account_channels_only():
19+
observation = observe_longbridge_account_identity(
20+
SimpleNamespace(
21+
stock_positions=lambda: SimpleNamespace(
22+
channels=(
23+
SimpleNamespace(account_channel="Cash"),
24+
SimpleNamespace(account_channel="Margin"),
25+
SimpleNamespace(account_channel="Cash"),
26+
)
27+
)
28+
)
29+
)
30+
31+
assert observation is not None
32+
assert observation.platform_id == "longbridge"
33+
assert observation.evidence_source.value == "broker_api_partial"
34+
assert observation.account_types == ("cash", "margin")
35+
assert observation.account_id_fingerprint is None
36+
assert observation.account_modes == ()
37+
38+
39+
def test_longbridge_identity_observer_returns_no_evidence_when_broker_read_fails():
40+
class FailingContext:
41+
def stock_positions(self):
42+
raise RuntimeError("broker unavailable")
43+
44+
assert observe_longbridge_account_identity(FailingContext()) is None

tests/test_rebalance_service.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from application import rebalance_service
2626
from application.execution_service import execute_rebalance_cycle
2727
from application.runtime_dependencies import LongBridgeRebalanceConfig, LongBridgeRebalanceRuntime
28+
from quant_platform_kit.common.account_identity import BrokerAccountIdentity
2829
from notifications.telegram import build_translator
2930
from quant_platform_kit.common.models import ExecutionReport, PortfolioSnapshot, Position, QuoteSnapshot
3031
from quant_platform_kit.common.port_adapters import CallableExecutionPort, CallableMarketDataPort, CallableNotificationPort, CallablePortfolioPort
@@ -156,6 +157,43 @@ def _build_snapshot(plan, *, phase=""):
156157

157158

158159
class RebalanceServiceNotificationTests(unittest.TestCase):
160+
def test_account_identity_gate_marks_mismatch_for_pre_execution_block(self):
161+
runtime = LongBridgeRebalanceRuntime(
162+
bootstrap=lambda: None,
163+
resolve_rebalance_plan=lambda **_kwargs: {},
164+
market_data_port_factory=lambda _value: None,
165+
estimate_max_purchase_quantity=lambda *_args, **_kwargs: 0,
166+
notifications=CallableNotificationPort(lambda _message: None),
167+
notify_issue=lambda _title, _detail: None,
168+
portfolio_port_factory=lambda *_args: None,
169+
execution_port_factory=lambda _value: None,
170+
account_identity_observer=lambda _trade_context: BrokerAccountIdentity(
171+
platform_id="longbridge",
172+
account_types=("margin",),
173+
),
174+
)
175+
config = LongBridgeRebalanceConfig(
176+
limit_sell_discount=1.0,
177+
limit_buy_premium=1.0,
178+
separator="-",
179+
translator=build_translator("en"),
180+
with_prefix=lambda message: message,
181+
account_identity_policy={
182+
"enforcement": "enforce",
183+
"expected_account_types": ["cash"],
184+
},
185+
)
186+
187+
decision = rebalance_service._evaluate_execution_account_identity(
188+
runtime=runtime,
189+
config=config,
190+
trade_context=object(),
191+
)
192+
193+
self.assertIsNotNone(decision)
194+
self.assertFalse(decision.broker_write_allowed)
195+
self.assertIn("account_identity_type_mismatch", decision.findings)
196+
159197
def test_submitted_broker_order_is_recorded_as_pending_reconciliation(self):
160198
submitted_orders = []
161199
plan = _build_plan(

tests/test_runtime_composer.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ def fake_cycle_sender(**kwargs):
142142
assert runtime.market_data_port_factory == "market-data-port-factory"
143143
assert runtime.notifications == "notification-port"
144144
assert runtime.fetch_order_status == "fetch-order-status"
145+
assert runtime.account_identity_observer is not None
145146
silent_runtime.notifications.send_text("precheck heartbeat")
146147
assert observed["sent_message"] == ("tg-token", "chat-id", "[HK] hello")
147148
assert runtime.post_submit_order == "post-submit-order"
@@ -155,3 +156,4 @@ def fake_cycle_sender(**kwargs):
155156
assert config.execution_dedup_enabled is True
156157
assert config.execution_state_account_scope == "HK"
157158
assert config.execution_state_store.cloud_prefix_uri == "gs://bucket/runtime-reports"
159+
assert config.account_identity_policy.is_configured is False

0 commit comments

Comments
 (0)