매수 계약 키를 portfolio_id → slot_id 로 이동(매도는 이미 slot_id). 외부 전략 무중단 위해 portfolio_id 레거시 dual-accept. - BuyIntent: slot_id 필드 추가, portfolio_id Optional 완화 - _admit_buy: 자산 중심 모드 우선 분기; _admit_buy_asset_centric 가 slot_id(canonical) 또는 portfolio_id(legacy dual-accept)로 슬롯 해소, symbol 은 슬롯 portfolio_id 로 resolve. unknown_slot/unknown_portfolio 사유 구분. - messaging_handlers: buy 파싱에 slot_id 수용 - 신규 테스트: slot_id 매수 / unknown_slot / 기존 dual-accept 회귀 검증: 전체 389 passed / 36 skipped, ruff clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
106 lines
4.6 KiB
Python
106 lines
4.6 KiB
Python
"""dcy-020 / REQ-059 (BAT-028): asset-centric buy admission (FCFS·M캡·over-cap)."""
|
|
from __future__ import annotations
|
|
|
|
from datetime import date
|
|
|
|
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
|
|
|
|
DAY = date(2026, 6, 10)
|
|
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, DAY, {"pf-a", "pf-b"})
|
|
d = intents.submit_buy(BuyIntent(intent_id="b1", trading_date=DAY, 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, DAY, {"pf-a"})
|
|
slot = repo.find_slot_by_portfolio(INST, "pf-a")
|
|
d = intents.submit_buy(BuyIntent(intent_id="b1", trading_date=DAY, 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, DAY, {"pf-a"})
|
|
d = intents.submit_buy(BuyIntent(intent_id="b1", trading_date=DAY, 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, DAY, {"pf-a", "pf-b"})
|
|
intents.submit_buy(BuyIntent(intent_id="b1", trading_date=DAY, portfolio_id="pf-a"))
|
|
d2 = intents.submit_buy(BuyIntent(intent_id="b2", trading_date=DAY, 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, DAY, {"pf-a", "pf-b"})
|
|
d1 = intents.submit_buy(BuyIntent(intent_id="b1", trading_date=DAY, portfolio_id="pf-a"))
|
|
assert d1.status == "admitted"
|
|
d2 = intents.submit_buy(BuyIntent(intent_id="b2", trading_date=DAY, 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, DAY, {"pf-a"})
|
|
# pf-c is resolvable to a symbol but has no reconciled asset slot
|
|
d = intents.submit_buy(BuyIntent(intent_id="b1", trading_date=DAY, 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, DAY, {"pf-a", "pf-b", "pf-c"})
|
|
intents.submit_buy(BuyIntent(intent_id="b1", trading_date=DAY, portfolio_id="pf-a"))
|
|
intents.submit_buy(BuyIntent(intent_id="b2", trading_date=DAY, 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", trading_date=DAY, 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_legacy_pool_mode_unaffected():
|
|
# max_active=None -> legacy pool path (preallocate + find_allocatable)
|
|
repo = InMemorySlotRepository()
|
|
svc = SlotService(repo)
|
|
intents = IntentService(svc, repo, InMemoryIntentStore(),
|
|
resolve_symbol=lambda pid: _SYMS.get(pid))
|
|
svc.preallocate_daily_slots(DAY, 1)
|
|
d = intents.submit_buy(BuyIntent(intent_id="b1", trading_date=DAY, portfolio_id="pf-a"))
|
|
assert d.status == "admitted"
|