"""dcy-020 / REQ-059 (BAT-028; BAT-030 단일 경로): asset-centric buy admission (FCFS·M캡·over-cap).""" from __future__ import annotations from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore from trex_core.application.intents import BuyIntent, IntentService from trex_core.domain.slot import SlotService, SlotState from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository INST = "inst-1" # portfolio_id -> symbol (universe resolver) _SYMS = {"pf-a": "005930", "pf-b": "000660", "pf-c": "035720"} def _wire(max_active): repo = InMemorySlotRepository() svc = SlotService(repo, instance_key=INST) intents = IntentService( svc, repo, InMemoryIntentStore(), resolve_symbol=lambda pid: _SYMS.get(pid), instance_key=INST, max_active_slots=max_active, ) return repo, svc, intents def test_activates_asset_slot(): repo, svc, intents = _wire(max_active=2) repo.reconcile_universe(INST, {"pf-a": "SYM", "pf-b": "SYM"}) d = intents.submit_buy(BuyIntent(intent_id="b1", portfolio_id="pf-a")) assert d.status == "admitted" slot = repo.find_slot_by_portfolio(INST, "pf-a") assert slot.status is SlotState.PENDING_BUY and slot.symbol == "005930" def test_activates_by_slot_id(): # dcy-020/REQ-061: slot_id is the canonical buy contract key. repo, svc, intents = _wire(max_active=2) repo.reconcile_universe(INST, {"pf-a": "SYM"}) slot = repo.find_slot_by_portfolio(INST, "pf-a") d = intents.submit_buy(BuyIntent(intent_id="b1", slot_id=slot.slot_id)) assert d.status == "admitted" and d.slot_id == slot.slot_id got = repo.fetch_slot(slot.slot_id) assert got.status is SlotState.PENDING_BUY and got.symbol == "005930" # resolved from slot.portfolio_id def test_unknown_slot_for_bad_slot_id(): from uuid import uuid4 repo, svc, intents = _wire(max_active=2) repo.reconcile_universe(INST, {"pf-a": "SYM"}) d = intents.submit_buy(BuyIntent(intent_id="b1", slot_id=uuid4())) assert d.status == "rejected" and d.reason == "unknown_slot" def test_already_active_is_rejected(): repo, svc, intents = _wire(max_active=2) repo.reconcile_universe(INST, {"pf-a": "SYM", "pf-b": "SYM"}) intents.submit_buy(BuyIntent(intent_id="b1", portfolio_id="pf-a")) d2 = intents.submit_buy(BuyIntent(intent_id="b2", portfolio_id="pf-a")) assert d2.status == "rejected" and d2.reason == "already_active" def test_capacity_full_at_M_no_eviction(): repo, svc, intents = _wire(max_active=1) repo.reconcile_universe(INST, {"pf-a": "SYM", "pf-b": "SYM"}) d1 = intents.submit_buy(BuyIntent(intent_id="b1", portfolio_id="pf-a")) assert d1.status == "admitted" d2 = intents.submit_buy(BuyIntent(intent_id="b2", portfolio_id="pf-b")) assert d2.status == "rejected" and d2.reason == "capacity_full" # existing holding untouched (no eviction) assert repo.find_slot_by_portfolio(INST, "pf-a").status is SlotState.PENDING_BUY def test_unknown_portfolio_when_no_asset_slot(): repo, svc, intents = _wire(max_active=2) repo.reconcile_universe(INST, {"pf-a": "SYM"}) # pf-c is resolvable to a symbol but has no reconciled asset slot d = intents.submit_buy(BuyIntent(intent_id="b1", portfolio_id="pf-c")) assert d.status == "rejected" and d.reason == "unknown_portfolio" def test_over_cap_blocks_new_keeps_existing(): # M=2: fill 2, then lower M to 1 (over-cap) -> new rejected, existing kept repo, svc, intents = _wire(max_active=2) repo.reconcile_universe(INST, {"pf-a": "SYM", "pf-b": "SYM", "pf-c": "SYM"}) intents.submit_buy(BuyIntent(intent_id="b1", portfolio_id="pf-a")) intents.submit_buy(BuyIntent(intent_id="b2", portfolio_id="pf-b")) assert repo.count_active(INST) == 2 intents._max_active_slots = 1 # operator lowered M (over-cap) d = intents.submit_buy(BuyIntent(intent_id="b3", portfolio_id="pf-c")) assert d.status == "rejected" and d.reason == "capacity_full" # both existing positions kept assert repo.count_active(INST) == 2 def test_no_cap_admits_without_capacity_check(): # BAT-030: max_active_slots=None -> no capacity cap (asset-centric only path) repo, svc, intents = _wire(max_active=None) repo.reconcile_universe(INST, {"pf-a": "SYM", "pf-b": "SYM", "pf-c": "SYM"}) for i, pid in enumerate(sorted(_SYMS)): d = intents.submit_buy(BuyIntent(intent_id=f"b{i}", portfolio_id=pid)) assert d.status == "admitted" assert repo.count_active(INST) == 3 def test_buy_guard_rejection_is_fail_closed_and_sell_path_is_unaffected(): repo = InMemorySlotRepository() svc = SlotService(repo, instance_key=INST) repo.reconcile_universe(INST, {"pf-a": "SYM"}) intents = IntentService( svc, repo, InMemoryIntentStore(), resolve_symbol=lambda pid: _SYMS.get(pid), instance_key=INST, buy_guard_fn=lambda _intent: "insufficient_snapshot_cash", ) decision = intents.submit_buy( BuyIntent( intent_id="guarded", portfolio_id="pf-a", qty=1, entry_price=1000, ) ) assert decision.status == "rejected" assert decision.reason == "insufficient_snapshot_cash" assert repo.find_slot_by_portfolio(INST, "pf-a").status is SlotState.INACTIVE def test_buy_guard_exception_rejects_without_slot_transition(): repo = InMemorySlotRepository() svc = SlotService(repo, instance_key=INST) repo.reconcile_universe(INST, {"pf-a": "SYM"}) def broken_guard(_intent): raise RuntimeError("unavailable") intents = IntentService( svc, repo, InMemoryIntentStore(), resolve_symbol=lambda pid: _SYMS.get(pid), instance_key=INST, buy_guard_fn=broken_guard, ) decision = intents.submit_buy( BuyIntent(intent_id="broken", portfolio_id="pf-a", qty=1, entry_price=1000) ) assert decision.reason == "account_snapshot_unavailable" assert repo.find_slot_by_portfolio(INST, "pf-a").status is SlotState.INACTIVE