fix: guard unmanaged resolution on authoritative balances
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
This commit is contained in:
co-authored by
genichin
parent
f2b825e720
commit
5a13c0494b
@@ -35,6 +35,11 @@ class RaisingBroker(FakeBroker):
|
|||||||
raise BrokerBalanceError("transport_failed")
|
raise BrokerBalanceError("transport_failed")
|
||||||
|
|
||||||
|
|
||||||
|
class FilteredBroker(FakeBroker):
|
||||||
|
def exposes_full_balance_snapshot(self) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _wire():
|
def _wire():
|
||||||
repo = InMemorySlotRepository()
|
repo = InMemorySlotRepository()
|
||||||
return repo, SlotService(repo, instance_key=INSTANCE)
|
return repo, SlotService(repo, instance_key=INSTANCE)
|
||||||
@@ -188,3 +193,22 @@ def test_broker_failure_does_not_resolve_unmanaged_state():
|
|||||||
state = store.get_state(instance_key=INSTANCE, symbol="008110")
|
state = store.get_state(instance_key=INSTANCE, symbol="008110")
|
||||||
assert state is not None
|
assert state is not None
|
||||||
assert state["lifecycle_state"] == "active"
|
assert state["lifecycle_state"] == "active"
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_authoritative_broker_view_does_not_resolve_unmanaged_state():
|
||||||
|
repo, svc = _wire()
|
||||||
|
store = InMemoryUnmanagedPositionStore()
|
||||||
|
store.observe(instance_key=INSTANCE, symbol="008110", quantity=3)
|
||||||
|
|
||||||
|
decisions = reconcile_core_holdings(
|
||||||
|
slot_repo=repo,
|
||||||
|
slot_service=svc,
|
||||||
|
broker=FilteredBroker({}),
|
||||||
|
instance_key=INSTANCE,
|
||||||
|
unmanaged_position_store=store,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert decisions == {}
|
||||||
|
state = store.get_state(instance_key=INSTANCE, symbol="008110")
|
||||||
|
assert state is not None
|
||||||
|
assert state["lifecycle_state"] == "active"
|
||||||
|
|||||||
@@ -593,6 +593,7 @@ def test_unmanaged_list_outputs_durable_state(monkeypatch, capsys):
|
|||||||
assert rc == 0
|
assert rc == 0
|
||||||
out = json.loads(capsys.readouterr().out)
|
out = json.loads(capsys.readouterr().out)
|
||||||
assert out["count"] == 1
|
assert out["count"] == 1
|
||||||
|
assert out["instance_key"] == "inst-1"
|
||||||
assert out["states"][0]["symbol"] == "008110"
|
assert out["states"][0]["symbol"] == "008110"
|
||||||
assert out["states"][0]["evidence_ref"] == "kis-balance:2026-09-11"
|
assert out["states"][0]["evidence_ref"] == "kis-balance:2026-09-11"
|
||||||
|
|
||||||
@@ -688,6 +689,58 @@ def test_unmanaged_record_persists_operator_action(monkeypatch, capsys):
|
|||||||
assert out["state"]["evidence_ref"] == "kis-balance:2026-09-11"
|
assert out["state"]["evidence_ref"] == "kis-balance:2026-09-11"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unmanaged_record_text_output(monkeypatch, capsys):
|
||||||
|
class _Store:
|
||||||
|
def __init__(self, *_args, **_kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def record_operator_action(self, **kwargs):
|
||||||
|
return {
|
||||||
|
"instance_key": "inst-1",
|
||||||
|
"symbol": kwargs["symbol"],
|
||||||
|
"quantity": 3,
|
||||||
|
"lifecycle_state": "active",
|
||||||
|
"first_seen_at": "2026-09-07T00:00:00+00:00",
|
||||||
|
"last_seen_at": "2026-09-11T00:00:00+00:00",
|
||||||
|
"quantity_changed_at": "2026-09-07T00:00:00+00:00",
|
||||||
|
"resolved_at": None,
|
||||||
|
"operator_action": kwargs["action"],
|
||||||
|
"evidence_ref": kwargs["evidence_ref"],
|
||||||
|
"operator_note": kwargs["note"],
|
||||||
|
"operator_actor": kwargs["actor"],
|
||||||
|
"operator_updated_at": "2026-09-11T00:00:00+00:00",
|
||||||
|
"created_at": "2026-09-07T00:00:00+00:00",
|
||||||
|
"updated_at": "2026-09-11T00:00:00+00:00",
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"trex_core.adapters.portfolio.unmanaged_position_store.PostgreSQLUnmanagedPositionStore",
|
||||||
|
_Store,
|
||||||
|
)
|
||||||
|
args = SimpleNamespace(
|
||||||
|
cmd="unmanaged",
|
||||||
|
unmanaged_cmd="record",
|
||||||
|
symbol="008110",
|
||||||
|
action="approved_exception",
|
||||||
|
evidence_ref="kis-balance:2026-09-11",
|
||||||
|
note="confirmed",
|
||||||
|
actor="operator",
|
||||||
|
instance="inst-1",
|
||||||
|
config=None,
|
||||||
|
config_path=None,
|
||||||
|
env_file=None,
|
||||||
|
json=False,
|
||||||
|
timeout=10.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
rc = cli._cmd_unmanaged_record(args, SimpleNamespace(dsn="postgresql://x/y"))
|
||||||
|
|
||||||
|
assert rc == 0
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "recorded unmanaged position instance=inst-1 symbol=008110" in out
|
||||||
|
assert "evidence_ref=kis-balance:2026-09-11" in out
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# REL-010 regression: _cmd_verify broker import must not raise ImportError
|
# REL-010 regression: _cmd_verify broker import must not raise ImportError
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import os
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from threading import Lock
|
from threading import Lock
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from trex_core.application.broker_port import (
|
from trex_core.application.broker_port import (
|
||||||
BalanceItem,
|
BalanceItem,
|
||||||
@@ -134,8 +133,8 @@ class OwnershipAwareBroker:
|
|||||||
*,
|
*,
|
||||||
side: str,
|
side: str,
|
||||||
symbol: str,
|
symbol: str,
|
||||||
order_date: Optional[str] = None,
|
order_date: str | None = None,
|
||||||
submitted_at: Optional[datetime] = None,
|
submitted_at: datetime | None = None,
|
||||||
) -> FillStatus:
|
) -> FillStatus:
|
||||||
result = self._delegate.poll_fill(
|
result = self._delegate.poll_fill(
|
||||||
broker_order_id,
|
broker_order_id,
|
||||||
@@ -175,6 +174,10 @@ class OwnershipAwareBroker:
|
|||||||
def get_balance(self) -> list[BalanceItem]:
|
def get_balance(self) -> list[BalanceItem]:
|
||||||
return self._store.project(self._delegate.get_balance())
|
return self._store.project(self._delegate.get_balance())
|
||||||
|
|
||||||
|
def exposes_full_balance_snapshot(self) -> bool:
|
||||||
|
"""False: projected balances omit operator-owned/unmanaged broker positions."""
|
||||||
|
return False
|
||||||
|
|
||||||
def account_scope_hash(self) -> str:
|
def account_scope_hash(self) -> str:
|
||||||
return self._delegate.account_scope_hash()
|
return self._delegate.account_scope_hash()
|
||||||
|
|
||||||
|
|||||||
@@ -364,6 +364,18 @@ def _notify_unmanaged_position_transition(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _broker_balances_are_authoritative_for_unmanaged_resolution(broker: BrokerPort) -> bool:
|
||||||
|
marker = getattr(broker, "exposes_full_balance_snapshot", None)
|
||||||
|
if callable(marker):
|
||||||
|
try:
|
||||||
|
return bool(marker())
|
||||||
|
except Exception: # noqa: BLE001 - fail closed on broker self-report failure
|
||||||
|
return False
|
||||||
|
if isinstance(marker, bool):
|
||||||
|
return marker
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def reconcile_core_holdings(
|
def reconcile_core_holdings(
|
||||||
*,
|
*,
|
||||||
slot_repo,
|
slot_repo,
|
||||||
@@ -535,7 +547,10 @@ def reconcile_core_holdings(
|
|||||||
symbol=sym,
|
symbol=sym,
|
||||||
quantity=bq,
|
quantity=bq,
|
||||||
)
|
)
|
||||||
if unmanaged_position_store is not None:
|
if (
|
||||||
|
unmanaged_position_store is not None
|
||||||
|
and _broker_balances_are_authoritative_for_unmanaged_resolution(broker)
|
||||||
|
):
|
||||||
try:
|
try:
|
||||||
for state in unmanaged_position_store.resolve_absent(
|
for state in unmanaged_position_store.resolve_absent(
|
||||||
instance_key=instance_key,
|
instance_key=instance_key,
|
||||||
@@ -555,6 +570,11 @@ def reconcile_core_holdings(
|
|||||||
)
|
)
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
_log.exception("reconcile_core_holdings: unmanaged_position resolve failed")
|
_log.exception("reconcile_core_holdings: unmanaged_position resolve failed")
|
||||||
|
elif unmanaged_position_store is not None:
|
||||||
|
_log.debug(
|
||||||
|
"reconcile_core_holdings: unmanaged_position resolve skipped "
|
||||||
|
"(non-authoritative broker balance view)"
|
||||||
|
)
|
||||||
# req-0007 / ac-0024: retry pending_resolved for B-case identities no longer mismatched.
|
# req-0007 / ac-0024: retry pending_resolved for B-case identities no longer mismatched.
|
||||||
if alarm_emitter is not None and notification_store is not None:
|
if alarm_emitter is not None and notification_store is not None:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1016,7 +1016,7 @@ def _cmd_unmanaged_list(args, cfg) -> int:
|
|||||||
symbol=args.symbol,
|
symbol=args.symbol,
|
||||||
)
|
)
|
||||||
payload = {
|
payload = {
|
||||||
"instance": instance,
|
"instance_key": instance,
|
||||||
"status": args.status,
|
"status": args.status,
|
||||||
"count": len(states),
|
"count": len(states),
|
||||||
"states": states,
|
"states": states,
|
||||||
|
|||||||
Reference in New Issue
Block a user