Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
977 lines
37 KiB
Python
977 lines
37 KiB
Python
"""Blocking reconcile B/C cases + gate tests (REQ-103, REQ-104, REQ-107, REQ-108, BAT-050)."""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
import pytest
|
|
|
|
from trex_core.application.broker_port import BalanceItem
|
|
from trex_core.application.intents import BuyIntent, IntentService, SellIntent
|
|
from trex_core.application.messaging import RpcRequest
|
|
from trex_core.application.messaging_handlers import make_slots_rpc_handler
|
|
from trex_core.application.portfolio import (
|
|
InMemoryPauseStore,
|
|
InMemoryReconcileWarningStore,
|
|
PortfolioItem,
|
|
SnapshotStore,
|
|
slot_to_dict,
|
|
)
|
|
from trex_core.application.reconciliation import (
|
|
reconcile_core_holdings,
|
|
reconcile_positions,
|
|
)
|
|
from trex_core.domain.slot import SlotService, SlotState
|
|
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class FakeBroker:
|
|
def __init__(self, balances):
|
|
self._b = balances
|
|
def name(self): return "fake"
|
|
def submit_order(self, req): ...
|
|
def poll_fill(self, *args, **kw): ...
|
|
def cancel_order(self, *args, **kw): ...
|
|
def get_balance(self): return list(self._b)
|
|
|
|
|
|
class InMemoryIntentStore:
|
|
"""Minimal in-memory intent store for tests."""
|
|
def __init__(self):
|
|
self._db: dict = {}
|
|
def try_insert(self, intent_id, intent_type, payload):
|
|
if intent_id in self._db:
|
|
return False
|
|
self._db[intent_id] = {"status": None, "slot_id": None, "reason": None}
|
|
return True
|
|
def get(self, intent_id):
|
|
rec = self._db.get(intent_id)
|
|
if rec is None:
|
|
return None
|
|
class R:
|
|
def __init__(self, d): self.__dict__.update(d)
|
|
r = R(rec)
|
|
r.intent_id = intent_id
|
|
return r
|
|
def finalize(self, intent_id, status, slot_id, reason):
|
|
self._db[intent_id] = {"status": status, "slot_id": slot_id, "reason": reason}
|
|
|
|
|
|
class RecordingIntentStore(InMemoryIntentStore):
|
|
def __init__(self, trace=None, fail_finalize=False):
|
|
super().__init__()
|
|
self._trace = trace
|
|
self._fail_finalize = fail_finalize
|
|
|
|
def finalize(self, intent_id, status, slot_id, reason):
|
|
if self._trace is not None:
|
|
self._trace.append("finalize")
|
|
if self._fail_finalize:
|
|
raise RuntimeError("finalize failed")
|
|
super().finalize(intent_id, status, slot_id, reason)
|
|
|
|
|
|
class RecordingAlarmEmitter:
|
|
def __init__(self, *, trace=None, return_value=True, raise_error=False):
|
|
self.calls = []
|
|
self._trace = trace
|
|
self._return_value = return_value
|
|
self._raise_error = raise_error
|
|
|
|
def publish_alarm(self, code, title, *, fields=None, severity="critical"):
|
|
if self._trace is not None:
|
|
self._trace.append("publish")
|
|
self.calls.append((code, title, severity, fields or {}))
|
|
if self._raise_error:
|
|
raise RuntimeError("transport down")
|
|
return self._return_value
|
|
|
|
|
|
class DuplicateInFlightIntentStore:
|
|
def try_insert(self, intent_id, intent_type, payload):
|
|
return False
|
|
|
|
def get(self, intent_id):
|
|
return None
|
|
|
|
def finalize(self, intent_id, status, slot_id, reason):
|
|
raise AssertionError("finalize must not be called for duplicate_in_flight")
|
|
|
|
|
|
class PostFinalizeContextFailingSlotRepository(InMemorySlotRepository):
|
|
"""Raise fetch_slot only after explicitly armed by finalize."""
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self._raise_on_fetch = False
|
|
|
|
def fetch_slot(self, slot_id):
|
|
if self._raise_on_fetch:
|
|
raise RuntimeError("context lookup failed")
|
|
return super().fetch_slot(slot_id)
|
|
|
|
|
|
class FinalizeArmingIntentStore(InMemoryIntentStore):
|
|
"""Arm repo fetch failure immediately after finalize persistence."""
|
|
|
|
def __init__(self, repo):
|
|
super().__init__()
|
|
self._repo = repo
|
|
|
|
def finalize(self, intent_id, status, slot_id, reason):
|
|
super().finalize(intent_id, status, slot_id, reason)
|
|
self._repo._raise_on_fetch = True
|
|
|
|
|
|
def _make_holding_slot(repo, svc, instance_key="default", portfolio_id="pf-A", symbol="005930", qty=10):
|
|
"""Helper: creates universe, puts slot in HOLDING state with given qty."""
|
|
repo.reconcile_universe(instance_key, {portfolio_id: symbol})
|
|
slot = repo.find_slot_by_portfolio(instance_key, portfolio_id)
|
|
pending = svc.transit_slot(
|
|
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
|
reason="buy", actor="test", symbol=symbol,
|
|
)
|
|
holding = svc.transit_slot(
|
|
pending.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
|
reason="filled", actor="test", avg_price=100.0, qty=float(qty),
|
|
)
|
|
return holding
|
|
|
|
|
|
def _store_with(items):
|
|
store = SnapshotStore()
|
|
store.swap(items)
|
|
return store
|
|
|
|
|
|
# ===========================================================================
|
|
# REQ-103 AC-1: B case (slot qty != broker qty) => warn_store.set_warned(case_code='B')
|
|
# ===========================================================================
|
|
|
|
def test_reconcile_core_holdings_b_case_sets_warned():
|
|
repo = InMemorySlotRepository()
|
|
svc = SlotService(repo)
|
|
warn_store = InMemoryReconcileWarningStore()
|
|
|
|
slot = _make_holding_slot(repo, svc, qty=10)
|
|
# Broker has different qty (5 != 10) => B case
|
|
broker = FakeBroker([BalanceItem(symbol="005930", hldg_qty=5)])
|
|
decisions = reconcile_core_holdings(
|
|
slot_repo=repo, slot_service=svc, broker=broker,
|
|
instance_key="default", warn_store=warn_store,
|
|
)
|
|
slot_id_str = str(slot.slot_id)
|
|
assert decisions[slot_id_str] == "qty_warning"
|
|
assert warn_store.is_warned(slot_id_str) is True
|
|
|
|
|
|
# REQ-103 AC-1: B case stores case_code='B'
|
|
def test_reconcile_core_holdings_b_case_code():
|
|
repo = InMemorySlotRepository()
|
|
svc = SlotService(repo)
|
|
warn_store = InMemoryReconcileWarningStore()
|
|
|
|
slot = _make_holding_slot(repo, svc, qty=10)
|
|
broker = FakeBroker([BalanceItem(symbol="005930", hldg_qty=3)])
|
|
reconcile_core_holdings(
|
|
slot_repo=repo, slot_service=svc, broker=broker,
|
|
instance_key="default", warn_store=warn_store,
|
|
)
|
|
assert warn_store.list_warned() == {str(slot.slot_id)}
|
|
|
|
|
|
# REQ-103 AC-3: matching qty => warn_store NOT set
|
|
def test_reconcile_core_holdings_matching_qty_no_warn(caplog):
|
|
repo = InMemorySlotRepository()
|
|
svc = SlotService(repo)
|
|
warn_store = InMemoryReconcileWarningStore()
|
|
|
|
_make_holding_slot(repo, svc, qty=10)
|
|
broker = FakeBroker([BalanceItem(symbol="005930", hldg_qty=10)]) # match
|
|
decisions = reconcile_core_holdings(
|
|
slot_repo=repo, slot_service=svc, broker=broker,
|
|
instance_key="default", warn_store=warn_store,
|
|
)
|
|
assert warn_store.list_warned() == set() # S-4: no false positive
|
|
assert all(v == "ok" for v in decisions.values() if v != "unmanaged_position")
|
|
|
|
|
|
# REQ-103 AC-4: A case (broker 0) => ERROR transition, NOT warn_store
|
|
def test_reconcile_core_holdings_a_case_error_not_warned():
|
|
repo = InMemorySlotRepository()
|
|
svc = SlotService(repo)
|
|
warn_store = InMemoryReconcileWarningStore()
|
|
|
|
slot = _make_holding_slot(repo, svc, qty=10)
|
|
broker = FakeBroker([]) # broker flat (qty=0)
|
|
decisions = reconcile_core_holdings(
|
|
slot_repo=repo, slot_service=svc, broker=broker,
|
|
instance_key="default", warn_store=warn_store,
|
|
)
|
|
slot_id_str = str(slot.slot_id)
|
|
assert decisions[slot_id_str] == "error"
|
|
assert warn_store.is_warned(slot_id_str) is False # A uses error, not warn_store
|
|
|
|
|
|
# REQ-103 AC-5: warn_store=None => backward compat, no exception
|
|
def test_reconcile_core_holdings_no_warn_store_backward_compat():
|
|
repo = InMemorySlotRepository()
|
|
svc = SlotService(repo)
|
|
|
|
_make_holding_slot(repo, svc, qty=10)
|
|
broker = FakeBroker([BalanceItem(symbol="005930", hldg_qty=5)]) # mismatch
|
|
decisions = reconcile_core_holdings(
|
|
slot_repo=repo, slot_service=svc, broker=broker,
|
|
instance_key="default",
|
|
warn_store=None, # no store
|
|
)
|
|
# Still returns qty_warning, no exception
|
|
assert any(v == "qty_warning" for v in decisions.values())
|
|
|
|
|
|
# ===========================================================================
|
|
# REQ-103 AC-2: C case (hub qty != broker qty) => warn_store.set_warned(case_code='C')
|
|
# ===========================================================================
|
|
|
|
def test_reconcile_positions_c_case_sets_warned():
|
|
repo = InMemorySlotRepository()
|
|
svc = SlotService(repo)
|
|
warn_store = InMemoryReconcileWarningStore()
|
|
|
|
slot = _make_holding_slot(repo, svc, qty=10, portfolio_id="pf-A")
|
|
store = _store_with([
|
|
PortfolioItem(portfolio_id="pf-A", symbol="005930", cash_balance=0, asset_quantity=10),
|
|
])
|
|
broker = FakeBroker([BalanceItem(symbol="005930", hldg_qty=7)]) # hub=10, broker=7 => C
|
|
decisions = reconcile_positions(
|
|
store=store, broker=broker,
|
|
slot_repo=repo, instance_key="default",
|
|
warn_store=warn_store,
|
|
)
|
|
assert decisions["pf-A"] == "warning"
|
|
assert warn_store.is_warned(str(slot.slot_id)) is True
|
|
|
|
|
|
# REQ-103 AC-3: C case matching => no warn
|
|
def test_reconcile_positions_c_case_matching_no_warn():
|
|
repo = InMemorySlotRepository()
|
|
svc = SlotService(repo)
|
|
warn_store = InMemoryReconcileWarningStore()
|
|
|
|
_make_holding_slot(repo, svc, qty=10, portfolio_id="pf-A")
|
|
store = _store_with([
|
|
PortfolioItem(portfolio_id="pf-A", symbol="005930", cash_balance=0, asset_quantity=10),
|
|
])
|
|
broker = FakeBroker([BalanceItem(symbol="005930", hldg_qty=10)]) # match
|
|
decisions = reconcile_positions(
|
|
store=store, broker=broker,
|
|
slot_repo=repo, instance_key="default",
|
|
warn_store=warn_store,
|
|
)
|
|
assert decisions["pf-A"] == "ok"
|
|
assert warn_store.list_warned() == set() # S-4: no false positive
|
|
|
|
|
|
# REQ-103 AC-5: C case with warn_store=None => backward compat
|
|
def test_reconcile_positions_no_warn_store_backward_compat():
|
|
store = _store_with([
|
|
PortfolioItem(portfolio_id="pf-A", symbol="005930", cash_balance=0, asset_quantity=10),
|
|
])
|
|
broker = FakeBroker([BalanceItem(symbol="005930", hldg_qty=7)])
|
|
decisions = reconcile_positions(store=store, broker=broker, warn_store=None)
|
|
# in-memory warning still set on store
|
|
assert decisions["pf-A"] == "warning"
|
|
assert store.warnings() == {"pf-A"}
|
|
|
|
|
|
# ===========================================================================
|
|
# REQ-104 AC-1: reconcile-warned slot => buy intent rejected
|
|
# ===========================================================================
|
|
|
|
def test_intent_service_buy_rejected_when_reconcile_warned():
|
|
repo = InMemorySlotRepository()
|
|
svc = SlotService(repo)
|
|
warn_store = InMemoryReconcileWarningStore()
|
|
intent_store = InMemoryIntentStore()
|
|
|
|
repo.reconcile_universe("default", {"pf-A": "005930"})
|
|
slot = repo.find_slot_by_portfolio("default", "pf-A")
|
|
warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="B")
|
|
|
|
intent_svc = IntentService(
|
|
svc, repo, intent_store,
|
|
instance_key="default",
|
|
warn_store=warn_store,
|
|
)
|
|
d = intent_svc.submit_buy(BuyIntent(
|
|
intent_id="b-001",
|
|
slot_id=slot.slot_id,
|
|
entry_price=100.0,
|
|
qty=10.0,
|
|
))
|
|
assert d.status == "rejected"
|
|
assert d.reason == "reconcile_warned"
|
|
|
|
|
|
# REQ-104 AC-2: reconcile-warned HOLDING slot => sell intent rejected (external path)
|
|
def test_intent_service_sell_rejected_when_reconcile_warned():
|
|
repo = InMemorySlotRepository()
|
|
svc = SlotService(repo)
|
|
warn_store = InMemoryReconcileWarningStore()
|
|
intent_store = InMemoryIntentStore()
|
|
|
|
slot = _make_holding_slot(repo, svc, qty=10)
|
|
warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="B")
|
|
|
|
intent_svc = IntentService(
|
|
svc, repo, intent_store,
|
|
instance_key="default",
|
|
warn_store=warn_store,
|
|
)
|
|
d = intent_svc.submit_sell(SellIntent(
|
|
intent_id="s-001",
|
|
slot_id=slot.slot_id,
|
|
reason="strategy_exit",
|
|
))
|
|
assert d.status == "rejected"
|
|
assert d.reason == "reconcile_warned"
|
|
|
|
|
|
def test_reconcile_warned_buy_emits_alarm_with_allowlisted_fields_after_finalize():
|
|
repo = InMemorySlotRepository()
|
|
svc = SlotService(repo)
|
|
warn_store = InMemoryReconcileWarningStore()
|
|
trace = []
|
|
intent_store = RecordingIntentStore(trace=trace)
|
|
alarms = RecordingAlarmEmitter(trace=trace)
|
|
|
|
repo.reconcile_universe("default", {"pf-A": "005930"})
|
|
slot = repo.find_slot_by_portfolio("default", "pf-A")
|
|
warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="B")
|
|
|
|
intent_svc = IntentService(
|
|
svc, repo, intent_store,
|
|
instance_key="default",
|
|
warn_store=warn_store,
|
|
alarm_emitter=alarms,
|
|
)
|
|
d = intent_svc.submit_buy(BuyIntent(
|
|
intent_id="b-alert-001",
|
|
slot_id=slot.slot_id,
|
|
entry_price=100.0,
|
|
qty=10.0,
|
|
strategy_id="stg-buy",
|
|
))
|
|
assert d.status == "rejected"
|
|
assert d.reason == "reconcile_warned"
|
|
assert trace == ["finalize", "publish"]
|
|
assert len(alarms.calls) == 1
|
|
code, title, severity, fields = alarms.calls[0]
|
|
assert code == "strategy_intent_rejected"
|
|
assert severity == "warn"
|
|
assert title
|
|
assert fields["instance_key"] == "default"
|
|
assert fields["intent_id"] == "b-alert-001"
|
|
assert fields["intent_type"] == "buy"
|
|
assert fields["reason"] == "reconcile_warned"
|
|
assert fields["broker_submitted"] is False
|
|
assert fields["slot_id"] == str(slot.slot_id)
|
|
assert fields["portfolio_id"] == "pf-A"
|
|
assert fields["symbol"] == "005930"
|
|
assert fields["strategy_id"] == "stg-buy"
|
|
for blocked in ("qty", "entry_price", "limit_price", "venue", "extra", "sell_strategy_id"):
|
|
assert blocked not in fields
|
|
text = repr((title, fields))
|
|
for forbidden in ("account", "token", "password", "secret", "dsn", "connection", "raw_payload"):
|
|
assert forbidden not in text.lower()
|
|
|
|
|
|
def test_reconcile_warned_sell_emits_alarm_with_normalized_strategy_id():
|
|
repo = InMemorySlotRepository()
|
|
svc = SlotService(repo)
|
|
warn_store = InMemoryReconcileWarningStore()
|
|
intent_store = InMemoryIntentStore()
|
|
alarms = RecordingAlarmEmitter()
|
|
|
|
slot = _make_holding_slot(repo, svc, qty=10, portfolio_id="pf-A", symbol="005930")
|
|
warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="C")
|
|
|
|
intent_svc = IntentService(
|
|
svc, repo, intent_store,
|
|
instance_key="default",
|
|
warn_store=warn_store,
|
|
alarm_emitter=alarms,
|
|
)
|
|
d = intent_svc.submit_sell(SellIntent(
|
|
intent_id="s-alert-001",
|
|
slot_id=slot.slot_id,
|
|
reason="strategy_exit",
|
|
sell_strategy_id="stg-sell",
|
|
))
|
|
assert d.status == "rejected"
|
|
assert d.reason == "reconcile_warned"
|
|
assert len(alarms.calls) == 1
|
|
_, _, severity, fields = alarms.calls[0]
|
|
assert severity == "warn"
|
|
assert fields["intent_type"] == "sell"
|
|
assert fields["strategy_id"] == "stg-sell"
|
|
assert fields["slot_id"] == str(slot.slot_id)
|
|
|
|
|
|
def test_reconcile_warned_alarm_dedup_by_intent_id_across_service_recreation():
|
|
repo = InMemorySlotRepository()
|
|
svc = SlotService(repo)
|
|
warn_store = InMemoryReconcileWarningStore()
|
|
intent_store = InMemoryIntentStore()
|
|
alarms = RecordingAlarmEmitter()
|
|
|
|
repo.reconcile_universe("default", {"pf-A": "005930"})
|
|
slot = repo.find_slot_by_portfolio("default", "pf-A")
|
|
warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="B")
|
|
|
|
s1 = IntentService(
|
|
svc, repo, intent_store, instance_key="default", warn_store=warn_store, alarm_emitter=alarms
|
|
)
|
|
s2 = IntentService(
|
|
svc, repo, intent_store, instance_key="default", warn_store=warn_store, alarm_emitter=alarms
|
|
)
|
|
first = s1.submit_buy(BuyIntent(intent_id="b-dedup", slot_id=slot.slot_id))
|
|
second = s1.submit_buy(BuyIntent(intent_id="b-dedup", slot_id=slot.slot_id))
|
|
third = s2.submit_buy(BuyIntent(intent_id="b-dedup", slot_id=slot.slot_id))
|
|
assert first.reason == "reconcile_warned"
|
|
assert second.reason == "reconcile_warned"
|
|
assert third.reason == "reconcile_warned"
|
|
assert len(alarms.calls) == 1
|
|
|
|
|
|
def test_reconcile_warned_alarm_emits_per_distinct_intent_id_on_same_slot():
|
|
repo = InMemorySlotRepository()
|
|
svc = SlotService(repo)
|
|
warn_store = InMemoryReconcileWarningStore()
|
|
intent_store = InMemoryIntentStore()
|
|
alarms = RecordingAlarmEmitter()
|
|
|
|
repo.reconcile_universe("default", {"pf-A": "005930"})
|
|
slot = repo.find_slot_by_portfolio("default", "pf-A")
|
|
warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="B")
|
|
|
|
intent_svc = IntentService(
|
|
svc, repo, intent_store, instance_key="default", warn_store=warn_store, alarm_emitter=alarms
|
|
)
|
|
d1 = intent_svc.submit_buy(BuyIntent(intent_id="b-uniq-1", slot_id=slot.slot_id))
|
|
d2 = intent_svc.submit_buy(BuyIntent(intent_id="b-uniq-2", slot_id=slot.slot_id))
|
|
assert d1.reason == "reconcile_warned"
|
|
assert d2.reason == "reconcile_warned"
|
|
assert len(alarms.calls) == 2
|
|
|
|
|
|
def test_reconcile_warned_alarm_not_published_for_duplicate_in_flight_or_other_decisions():
|
|
repo = InMemorySlotRepository()
|
|
svc = SlotService(repo)
|
|
alarms = RecordingAlarmEmitter()
|
|
|
|
duplicate = IntentService(
|
|
svc, repo, DuplicateInFlightIntentStore(), instance_key="default", alarm_emitter=alarms
|
|
)
|
|
d_dup = duplicate.submit_buy(BuyIntent(intent_id="dup", portfolio_id="pf-A"))
|
|
assert d_dup.reason == "duplicate_in_flight"
|
|
assert alarms.calls == []
|
|
|
|
repo.reconcile_universe("default", {"pf-A": "005930"})
|
|
slot = repo.find_slot_by_portfolio("default", "pf-A")
|
|
normal = IntentService(
|
|
svc, repo, InMemoryIntentStore(), instance_key="default", alarm_emitter=alarms
|
|
)
|
|
admitted = normal.submit_buy(BuyIntent(intent_id="admitted", slot_id=slot.slot_id, qty=1, entry_price=100))
|
|
assert admitted.status == "admitted"
|
|
invalid = normal.submit_buy(BuyIntent(intent_id="bad-venue", slot_id=slot.slot_id, venue="BAD"))
|
|
assert invalid.reason == "invalid_venue"
|
|
repo.reconcile_universe("default", {"pf-A": "005930", "pf-B": "000660"})
|
|
slot_b = repo.find_slot_by_portfolio("default", "pf-B")
|
|
capped = IntentService(
|
|
svc, repo, InMemoryIntentStore(), instance_key="default", max_active_slots=0, alarm_emitter=alarms
|
|
)
|
|
full = capped.submit_buy(BuyIntent(intent_id="full", slot_id=slot_b.slot_id))
|
|
assert full.reason == "capacity_full"
|
|
assert alarms.calls == []
|
|
|
|
|
|
def test_reconcile_warned_alarm_finalize_failure_and_publish_failure_are_isolated():
|
|
repo = InMemorySlotRepository()
|
|
svc = SlotService(repo)
|
|
warn_store = InMemoryReconcileWarningStore()
|
|
repo.reconcile_universe("default", {"pf-A": "005930"})
|
|
slot = repo.find_slot_by_portfolio("default", "pf-A")
|
|
warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="B")
|
|
|
|
alarms = RecordingAlarmEmitter()
|
|
failing_store = RecordingIntentStore(fail_finalize=True)
|
|
failing_finalize = IntentService(
|
|
svc, repo, failing_store,
|
|
instance_key="default",
|
|
warn_store=warn_store,
|
|
alarm_emitter=alarms,
|
|
)
|
|
with pytest.raises(RuntimeError, match="finalize failed"):
|
|
failing_finalize.submit_buy(BuyIntent(intent_id="b-finalize-fail", slot_id=slot.slot_id))
|
|
assert alarms.calls == []
|
|
|
|
soft_fail_alarm = RecordingAlarmEmitter(return_value=False)
|
|
raising_alarm = RecordingAlarmEmitter(raise_error=True)
|
|
non_blocking_1 = IntentService(
|
|
svc, repo, InMemoryIntentStore(), instance_key="default", warn_store=warn_store, alarm_emitter=soft_fail_alarm
|
|
)
|
|
non_blocking_2 = IntentService(
|
|
svc, repo, InMemoryIntentStore(), instance_key="default", warn_store=warn_store, alarm_emitter=raising_alarm
|
|
)
|
|
d1 = non_blocking_1.submit_buy(BuyIntent(intent_id="b-alarm-false", slot_id=slot.slot_id))
|
|
d2 = non_blocking_2.submit_buy(BuyIntent(intent_id="b-alarm-exc", slot_id=slot.slot_id))
|
|
d3 = IntentService(
|
|
svc, repo, InMemoryIntentStore(), instance_key="default", warn_store=warn_store, alarm_emitter=None
|
|
).submit_buy(BuyIntent(intent_id="b-no-emitter", slot_id=slot.slot_id))
|
|
assert d1.reason == "reconcile_warned"
|
|
assert d2.reason == "reconcile_warned"
|
|
assert d3.reason == "reconcile_warned"
|
|
|
|
|
|
def test_reconcile_warned_buy_context_lookup_failure_does_not_change_response():
|
|
repo = PostFinalizeContextFailingSlotRepository()
|
|
svc = SlotService(repo)
|
|
warn_store = InMemoryReconcileWarningStore()
|
|
intent_store = FinalizeArmingIntentStore(repo)
|
|
alarms = RecordingAlarmEmitter()
|
|
repo.reconcile_universe("default", {"pf-A": "005930"})
|
|
slot = repo.find_slot_by_portfolio("default", "pf-A")
|
|
warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="B")
|
|
intent_svc = IntentService(
|
|
svc, repo, intent_store, instance_key="default", warn_store=warn_store, alarm_emitter=alarms
|
|
)
|
|
|
|
decision = intent_svc.submit_buy(
|
|
BuyIntent(intent_id="buy-context-fail", slot_id=slot.slot_id, strategy_id="stg-buy")
|
|
)
|
|
assert decision.status == "rejected"
|
|
assert decision.reason == "reconcile_warned"
|
|
assert len(alarms.calls) == 1
|
|
_, _, _, fields = alarms.calls[0]
|
|
assert fields["intent_id"] == "buy-context-fail"
|
|
assert fields["strategy_id"] == "stg-buy"
|
|
assert "slot_id" not in fields
|
|
assert "portfolio_id" not in fields
|
|
assert "symbol" not in fields
|
|
|
|
|
|
def test_reconcile_warned_sell_context_lookup_failure_does_not_change_response():
|
|
repo = PostFinalizeContextFailingSlotRepository()
|
|
svc = SlotService(repo)
|
|
warn_store = InMemoryReconcileWarningStore()
|
|
intent_store = FinalizeArmingIntentStore(repo)
|
|
alarms = RecordingAlarmEmitter()
|
|
slot = _make_holding_slot(repo, svc, qty=10, portfolio_id="pf-A", symbol="005930")
|
|
warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="C")
|
|
intent_svc = IntentService(
|
|
svc, repo, intent_store, instance_key="default", warn_store=warn_store, alarm_emitter=alarms
|
|
)
|
|
|
|
decision = intent_svc.submit_sell(
|
|
SellIntent(intent_id="sell-context-fail", slot_id=slot.slot_id, sell_strategy_id="stg-sell")
|
|
)
|
|
assert decision.status == "rejected"
|
|
assert decision.reason == "reconcile_warned"
|
|
assert len(alarms.calls) == 1
|
|
_, _, _, fields = alarms.calls[0]
|
|
assert fields["intent_id"] == "sell-context-fail"
|
|
assert fields["strategy_id"] == "stg-sell"
|
|
assert "slot_id" not in fields
|
|
assert "portfolio_id" not in fields
|
|
assert "symbol" not in fields
|
|
|
|
|
|
# REQ-104 AC-3: no warn, no pause => buy/sell admitted normally
|
|
def test_intent_service_no_warn_admitted_normally():
|
|
repo = InMemorySlotRepository()
|
|
svc = SlotService(repo)
|
|
warn_store = InMemoryReconcileWarningStore()
|
|
intent_store = InMemoryIntentStore()
|
|
|
|
repo.reconcile_universe("default", {"pf-A": "005930"})
|
|
slot = repo.find_slot_by_portfolio("default", "pf-A")
|
|
|
|
intent_svc = IntentService(
|
|
svc, repo, intent_store,
|
|
instance_key="default",
|
|
warn_store=warn_store,
|
|
)
|
|
d = intent_svc.submit_buy(BuyIntent(
|
|
intent_id="b-002",
|
|
slot_id=slot.slot_id,
|
|
entry_price=100.0,
|
|
qty=10.0,
|
|
))
|
|
assert d.status == "admitted"
|
|
|
|
|
|
# REQ-104: warn_store=None => gate skipped, backward compat
|
|
def test_intent_service_no_warn_store_backward_compat():
|
|
repo = InMemorySlotRepository()
|
|
svc = SlotService(repo)
|
|
intent_store = InMemoryIntentStore()
|
|
|
|
repo.reconcile_universe("default", {"pf-A": "005930"})
|
|
slot = repo.find_slot_by_portfolio("default", "pf-A")
|
|
|
|
intent_svc = IntentService(
|
|
svc, repo, intent_store,
|
|
instance_key="default",
|
|
warn_store=None, # no gate
|
|
)
|
|
d = intent_svc.submit_buy(BuyIntent(
|
|
intent_id="b-003",
|
|
slot_id=slot.slot_id,
|
|
entry_price=100.0,
|
|
qty=10.0,
|
|
))
|
|
assert d.status == "admitted"
|
|
|
|
|
|
# ===========================================================================
|
|
# REQ-104: safety-sell / kill-switch / watchdog bypass (OQ-B 확정)
|
|
# Internal path: SlotService.sell() / transit_slot() directly — NOT IntentService
|
|
# ===========================================================================
|
|
|
|
def test_safety_sell_bypasses_reconcile_warning_gate():
|
|
"""Safety-sell calls SlotService directly — reconcile_warned slot can still be sold."""
|
|
repo = InMemorySlotRepository()
|
|
svc = SlotService(repo)
|
|
warn_store = InMemoryReconcileWarningStore()
|
|
|
|
slot = _make_holding_slot(repo, svc, qty=10)
|
|
warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="B")
|
|
|
|
# Safety-sell path: SlotService.sell() directly (no IntentService gate)
|
|
# Verify that transit_slot HOLDING -> PENDING_SELL succeeds regardless of warn_store.
|
|
updated = svc.transit_slot(
|
|
slot.slot_id,
|
|
SlotState.HOLDING,
|
|
SlotState.PENDING_SELL,
|
|
reason="safety_sell",
|
|
actor="trex-safety-sell",
|
|
)
|
|
assert updated.status == SlotState.PENDING_SELL
|
|
# reconcile_warned flag unchanged — sell went through
|
|
assert warn_store.is_warned(str(slot.slot_id)) is True
|
|
|
|
|
|
# ===========================================================================
|
|
# REQ-104 AC-4/5/6/7: slots RPC reconcile_warned field
|
|
# ===========================================================================
|
|
|
|
def _wire_with_warn(warned_slot_ids=None):
|
|
repo = InMemorySlotRepository()
|
|
svc = SlotService(repo)
|
|
repo.reconcile_universe("default", {"pf-A": "SYM-A", "pf-B": "SYM-B"})
|
|
store = SnapshotStore()
|
|
store.swap([
|
|
PortfolioItem(portfolio_id="pf-A", symbol="SYM-A", cash_balance=0, asset_quantity=0),
|
|
PortfolioItem(portfolio_id="pf-B", symbol="SYM-B", cash_balance=0, asset_quantity=0),
|
|
])
|
|
pause = InMemoryPauseStore()
|
|
warn_store = InMemoryReconcileWarningStore()
|
|
if warned_slot_ids:
|
|
for sid in warned_slot_ids:
|
|
warn_store.set_warned(sid, portfolio_id="pf-A", case_code="B")
|
|
handler = make_slots_rpc_handler(
|
|
repo,
|
|
snapshot_store=store,
|
|
pause_store=pause,
|
|
warn_store=warn_store,
|
|
)
|
|
return repo, svc, handler, pause, warn_store
|
|
|
|
|
|
def _req(filter=None):
|
|
return RpcRequest(payload={"filter": filter or "all"}, correlation_id="t")
|
|
|
|
|
|
# REQ-104 AC-5: reconcile_warned=True for warned slot
|
|
def test_slots_rpc_reconcile_warned_true():
|
|
repo, svc, handler, _, warn_store = _wire_with_warn()
|
|
slot_a = repo.find_slot_by_portfolio("default", "pf-A")
|
|
warn_store.set_warned(str(slot_a.slot_id), portfolio_id="pf-A", case_code="B")
|
|
resp = handler(_req())
|
|
rows = {r["portfolio_id"]: r for r in resp.payload["slots"]}
|
|
assert rows["pf-A"]["reconcile_warned"] is True
|
|
assert rows["pf-B"]["reconcile_warned"] is False
|
|
|
|
|
|
# REQ-104 AC-4: pause only => paused=True, reconcile_warned=False
|
|
def test_slots_rpc_paused_only():
|
|
repo, svc, handler, pause, _ = _wire_with_warn()
|
|
pause.pause("pf-A")
|
|
resp = handler(_req())
|
|
rows = {r["portfolio_id"]: r for r in resp.payload["slots"]}
|
|
assert rows["pf-A"]["paused"] is True
|
|
assert rows["pf-A"]["reconcile_warned"] is False
|
|
|
|
|
|
# REQ-104 AC-6: both pause + reconcile_warned
|
|
def test_slots_rpc_both_pause_and_reconcile_warned():
|
|
repo, svc, handler, pause, warn_store = _wire_with_warn()
|
|
slot_a = repo.find_slot_by_portfolio("default", "pf-A")
|
|
pause.pause("pf-A")
|
|
warn_store.set_warned(str(slot_a.slot_id), portfolio_id="pf-A", case_code="C")
|
|
resp = handler(_req())
|
|
rows = {r["portfolio_id"]: r for r in resp.payload["slots"]}
|
|
assert rows["pf-A"]["paused"] is True
|
|
assert rows["pf-A"]["reconcile_warned"] is True
|
|
|
|
|
|
# REQ-104 AC-7: warn_store=None => reconcile_warned=False (backward compat, NFR-4)
|
|
def test_slots_rpc_no_warn_store_reconcile_warned_false():
|
|
repo = InMemorySlotRepository()
|
|
repo.reconcile_universe("default", {"pf-A": "SYM"})
|
|
handler = make_slots_rpc_handler(repo, warn_store=None)
|
|
resp = handler(_req())
|
|
for row in resp.payload["slots"]:
|
|
assert row["reconcile_warned"] is False
|
|
|
|
|
|
# REQ-104 AC-8: existing 'warned' field unchanged
|
|
def test_slots_rpc_existing_warned_field_unchanged():
|
|
"""warned field (slot qty vs hub asset_quantity divergence) must not be affected."""
|
|
repo = InMemorySlotRepository()
|
|
svc = SlotService(repo)
|
|
repo.reconcile_universe("default", {"pf-A": "SYM"})
|
|
store = SnapshotStore()
|
|
store.swap([
|
|
PortfolioItem(portfolio_id="pf-A", symbol="SYM", cash_balance=0, asset_quantity=5),
|
|
])
|
|
slot_a = repo.find_slot_by_portfolio("default", "pf-A")
|
|
pending = svc.transit_slot(slot_a.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
|
reason="buy", actor="test", symbol="SYM")
|
|
svc.transit_slot(pending.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
|
reason="filled", actor="test", avg_price=100.0, qty=10.0)
|
|
warn_store = InMemoryReconcileWarningStore()
|
|
handler = make_slots_rpc_handler(repo, snapshot_store=store, warn_store=warn_store)
|
|
resp = handler(RpcRequest(payload={"filter": "holding"}, correlation_id="t"))
|
|
row = resp.payload["slots"][0]
|
|
# slot.qty=10 != hub asset_quantity=5 => warned=True (existing field)
|
|
assert row["warned"] is True
|
|
# reconcile_warned is separate
|
|
assert row["reconcile_warned"] is False
|
|
|
|
|
|
# ===========================================================================
|
|
# REQ-107: logging fields
|
|
# ===========================================================================
|
|
|
|
def test_reconcile_core_holdings_b_case_logging(caplog):
|
|
repo = InMemorySlotRepository()
|
|
svc = SlotService(repo)
|
|
warn_store = InMemoryReconcileWarningStore()
|
|
|
|
_make_holding_slot(repo, svc, qty=10)
|
|
broker = FakeBroker([BalanceItem(symbol="005930", hldg_qty=5)])
|
|
with caplog.at_level(logging.WARNING, logger="trex_core.application.reconciliation"):
|
|
reconcile_core_holdings(
|
|
slot_repo=repo, slot_service=svc, broker=broker,
|
|
instance_key="default", warn_store=warn_store,
|
|
)
|
|
log_text = caplog.text
|
|
assert "reconcile_warning set" in log_text
|
|
assert "case_code=B" in log_text
|
|
assert "broker_qty=5" in log_text
|
|
|
|
|
|
def test_reconcile_positions_c_case_logging(caplog):
|
|
repo = InMemorySlotRepository()
|
|
svc = SlotService(repo)
|
|
warn_store = InMemoryReconcileWarningStore()
|
|
|
|
_make_holding_slot(repo, svc, qty=10, portfolio_id="pf-A")
|
|
store = _store_with([
|
|
PortfolioItem(portfolio_id="pf-A", symbol="005930", cash_balance=0, asset_quantity=10),
|
|
])
|
|
broker = FakeBroker([BalanceItem(symbol="005930", hldg_qty=7)])
|
|
with caplog.at_level(logging.WARNING, logger="trex_core.application.reconciliation"):
|
|
reconcile_positions(
|
|
store=store, broker=broker,
|
|
slot_repo=repo, instance_key="default",
|
|
warn_store=warn_store,
|
|
)
|
|
log_text = caplog.text
|
|
assert "reconcile_warning set" in log_text
|
|
assert "case_code=C" in log_text
|
|
|
|
|
|
# ===========================================================================
|
|
# REQ-108: SlotState enum + ALLOWED_TRANSITIONS invariant (NFR-4)
|
|
# ===========================================================================
|
|
|
|
def test_slot_state_enum_unchanged():
|
|
"""SlotState enum has exactly the same 5 values as before BAT-050."""
|
|
from trex_core.domain.slot.state import SlotState
|
|
expected = {"inactive", "pending_buy", "holding", "pending_sell", "error"}
|
|
assert {s.value for s in SlotState} == expected
|
|
|
|
|
|
def test_allowed_transitions_count():
|
|
"""ALLOWED_TRANSITIONS count: 7 base + 4 to_error + 2 from_error
|
|
+ 1 sync-onboarding (BAT-051 REQ-105 OQ-A: INACTIVE→HOLDING) = 14.
|
|
|
|
BAT-050 did not add transitions; BAT-051 added exactly 1 (INACTIVE→HOLDING)
|
|
for cold-start onboarding (actor=sync, wire contract unchanged).
|
|
SlotState enum remains 5 values.
|
|
"""
|
|
from trex_core.domain.slot.state import ALLOWED_TRANSITIONS, SlotState
|
|
# 14 = 7 base + 4 to_error + 2 from_error + 1 sync-onboarding (BAT-051)
|
|
assert len(ALLOWED_TRANSITIONS) == 14
|
|
# BAT-051 REQ-105 OQ-A: INACTIVE→HOLDING is now allowed (sync-only actor)
|
|
assert (SlotState.INACTIVE, SlotState.HOLDING) in ALLOWED_TRANSITIONS
|
|
# SlotState enum still 5 values — wire contract unchanged
|
|
assert len(list(SlotState)) == 5
|
|
|
|
|
|
# NFR-2: short-term instance normal scenario (slot qty == broker qty) => no warn
|
|
def test_short_term_instance_normal_no_warn():
|
|
"""M1/M2 daily slot matching scenario — no false positive blocking (S-4, NFR-2)."""
|
|
repo = InMemorySlotRepository()
|
|
svc = SlotService(repo)
|
|
warn_store = InMemoryReconcileWarningStore()
|
|
|
|
_make_holding_slot(repo, svc, qty=10, portfolio_id="pf-A")
|
|
store = _store_with([
|
|
PortfolioItem(portfolio_id="pf-A", symbol="005930", cash_balance=0, asset_quantity=10),
|
|
])
|
|
broker = FakeBroker([BalanceItem(symbol="005930", hldg_qty=10)]) # perfect match
|
|
|
|
reconcile_positions(
|
|
store=store, broker=broker,
|
|
slot_repo=repo, instance_key="default",
|
|
warn_store=warn_store,
|
|
)
|
|
reconcile_core_holdings(
|
|
slot_repo=repo, slot_service=svc, broker=broker,
|
|
instance_key="default", warn_store=warn_store,
|
|
)
|
|
# No new warn_store entries
|
|
assert warn_store.list_warned() == set()
|
|
|
|
|
|
# NFR-3: reconcile does NOT auto-modify slot qty
|
|
def test_reconcile_no_auto_fix_slot_qty():
|
|
"""Reconcile must only detect and block — no auto-modification of slot qty."""
|
|
repo = InMemorySlotRepository()
|
|
svc = SlotService(repo)
|
|
warn_store = InMemoryReconcileWarningStore()
|
|
|
|
slot = _make_holding_slot(repo, svc, qty=10)
|
|
broker = FakeBroker([BalanceItem(symbol="005930", hldg_qty=5)])
|
|
|
|
reconcile_core_holdings(
|
|
slot_repo=repo, slot_service=svc, broker=broker,
|
|
instance_key="default", warn_store=warn_store,
|
|
)
|
|
# Slot qty must remain 10 — not auto-corrected to 5
|
|
updated_slot = repo.fetch_slot(slot.slot_id)
|
|
assert float(updated_slot.qty) == 10.0
|
|
|
|
|
|
# ===========================================================================
|
|
# slot_to_dict unit tests for reconcile_warned field
|
|
# ===========================================================================
|
|
|
|
def test_slot_to_dict_reconcile_warned_false_when_no_warn_store():
|
|
"""When warn_store is None, reconcile_warned=False (NFR-4 additive)."""
|
|
repo = InMemorySlotRepository()
|
|
svc = SlotService(repo)
|
|
slot = _make_holding_slot(repo, svc, qty=5)
|
|
row = slot_to_dict(slot, warn_store=None)
|
|
assert "reconcile_warned" in row
|
|
assert row["reconcile_warned"] is False
|
|
|
|
|
|
def test_slot_to_dict_reconcile_warned_true_when_warned():
|
|
repo = InMemorySlotRepository()
|
|
svc = SlotService(repo)
|
|
slot = _make_holding_slot(repo, svc, qty=5)
|
|
warn_store = InMemoryReconcileWarningStore()
|
|
warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="B")
|
|
row = slot_to_dict(slot, warn_store=warn_store)
|
|
assert row["reconcile_warned"] is True
|
|
|
|
|
|
def test_slot_to_dict_paused_and_warned_independent():
|
|
"""paused and reconcile_warned are independent fields."""
|
|
repo = InMemorySlotRepository()
|
|
svc = SlotService(repo)
|
|
slot = _make_holding_slot(repo, svc, qty=5)
|
|
pause_store = InMemoryPauseStore()
|
|
pause_store.pause("pf-A")
|
|
warn_store = InMemoryReconcileWarningStore()
|
|
warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="C")
|
|
row = slot_to_dict(slot, pause_store=pause_store, warn_store=warn_store)
|
|
assert row["paused"] is True
|
|
assert row["reconcile_warned"] is True
|
|
|
|
|
|
# ===========================================================================
|
|
# AC-0023 review: warn_store.is_warned() raises → intent fail-closed
|
|
# ===========================================================================
|
|
|
|
class _RaisingWarnStore:
|
|
"""Stub warn_store whose is_warned always raises (simulates DB unavailable)."""
|
|
def is_warned(self, slot_id: str) -> bool:
|
|
raise RuntimeError("DB unavailable")
|
|
def list_c_case_warned(self) -> set:
|
|
raise RuntimeError("DB unavailable")
|
|
|
|
|
|
def test_buy_intent_fail_closed_when_warn_store_raises():
|
|
"""If warn_store.is_warned() raises, buy intent must be rejected (fail-closed)."""
|
|
repo = InMemorySlotRepository()
|
|
svc = SlotService(repo)
|
|
intent_store = InMemoryIntentStore()
|
|
|
|
repo.reconcile_universe("default", {"pf-A": "005930"})
|
|
slot = repo.find_slot_by_portfolio("default", "pf-A")
|
|
|
|
intent_svc = IntentService(
|
|
svc, repo, intent_store,
|
|
instance_key="default",
|
|
warn_store=_RaisingWarnStore(),
|
|
)
|
|
d = intent_svc.submit_buy(BuyIntent(
|
|
intent_id="b-fc-001",
|
|
slot_id=slot.slot_id,
|
|
entry_price=100.0,
|
|
qty=10.0,
|
|
))
|
|
assert d.status == "rejected"
|
|
assert d.reason == "reconcile_warning_unavailable"
|
|
|
|
|
|
def test_sell_intent_fail_closed_when_warn_store_raises():
|
|
"""If warn_store.is_warned() raises, sell intent must be rejected (fail-closed)."""
|
|
repo = InMemorySlotRepository()
|
|
svc = SlotService(repo)
|
|
intent_store = InMemoryIntentStore()
|
|
|
|
slot = _make_holding_slot(repo, svc, qty=10)
|
|
|
|
intent_svc = IntentService(
|
|
svc, repo, intent_store,
|
|
instance_key="default",
|
|
warn_store=_RaisingWarnStore(),
|
|
)
|
|
d = intent_svc.submit_sell(SellIntent(
|
|
intent_id="s-fc-001",
|
|
slot_id=slot.slot_id,
|
|
reason="strategy_exit",
|
|
))
|
|
assert d.status == "rejected"
|
|
assert d.reason == "reconcile_warning_unavailable"
|