Files
trex-core/tests/application/test_asset_centric_admission.py

151 lines
6.0 KiB
Python

"""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