feat(slot): BAT-030 — 자산 중심 cutover 완성 (dcy-020 BAT-δ, REQ-056·057·062·064·065)

M1 literal inactive 전면화: SlotState 5-state(idle/closed 별칭 제거),
마이그레이션 010(CHECK 교체+행 rewrite), ops_checks 를 transition_log 기반 재작성.
M2 집행 매도 수량 브로커 정본(D3): dispatch 매도 qty=broker hldg_qty,
미러 fallback 없음, 브로커 0 → skip+WARN, 리스트 기반 dispatch(head-block 방지).
M3 컬럼 drop + legacy pool 제거: 마이그레이션 011(trading_date·entry_count DROP),
repo 계약 instance 스코프 개편(집행 큐 포함), preallocation 폐기(데몬 7스레드),
admission 자산 중심 단일 경로(slot_id 정본), capacity 재정의(available=M−active),
daily_symbol_entries 코드 경로 제거.
M4 cutover 도구(D1·D4): trex-core-cutover(전체 rebuild, preflight/브로커 carry-over/
--allow-unmapped/--promote-unique/dry-run) + PG 리허설 테스트 8건.
M5 baseline 마감: data-model/runtime-flows/project-structure/interface-contract
자산 중심 최종 갱신 + run-trex-core-daemon 스킬 정합.

검증: 단위 367 passed + ruff clean; PG-gated 29 passed(010/011 round-trip·드리프트
가드·cutover 리허설); RabbitMQ 21 passed; run-the-app e2e(test DB+실 MQ, M=1):
reconcile 슬롯 생성→slot_id 매수→dedup→capacity_full→fill→already_active→
매도→inactive(자산 바인딩 보존)→영구 슬롯 재활성 전 구간 green.
실 cutover 는 운영자 실행(REL 단계, D4).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 15:16:15 +09:00
co-authored by Claude Opus 4.8
parent cec34be5f4
commit 7d727824dc
67 changed files with 1584 additions and 1862 deletions
+82 -23
View File
@@ -1,11 +1,9 @@
"""Order execution loop tests via paper broker (REQ-013, REQ-014).
"""Order execution loop tests via paper broker (REQ-013, REQ-014; BAT-030 asset-centric).
In-memory adapters; deterministic paper double drives the full async path.
"""
from __future__ import annotations
from datetime import date
from trex_core.adapters.broker.paper import PaperBroker
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
from trex_core.application.execution import ExecutionService
@@ -13,20 +11,27 @@ from trex_core.application.intents import BuyIntent, IntentService, SellIntent
from trex_core.domain.slot import SlotService, SlotState
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
DAY = date(2026, 6, 4)
INST = "default"
SYMBOLS = {f"PF-{i}": f"00593{i}" for i in range(5)}
def _wire(broker):
def _wire(broker, max_active_slots=None):
repo = InMemorySlotRepository()
svc = SlotService(repo)
intents = IntentService(svc, repo, InMemoryIntentStore())
execution = ExecutionService(svc, repo, broker)
svc.preallocate_daily_slots(DAY, 5)
svc = SlotService(repo, instance_key=INST)
intents = IntentService(
svc, repo, InMemoryIntentStore(),
resolve_symbol=SYMBOLS.get,
instance_key=INST, max_active_slots=max_active_slots,
)
execution = ExecutionService(svc, repo, broker, instance_key=INST)
repo.reconcile_universe(INST, set(SYMBOLS)) # permanent asset slots
return repo, svc, intents, execution
def _buy(intents, iid="b1", symbol="005930", qty=10):
return intents.submit_buy(BuyIntent(intent_id=iid, trading_date=DAY, symbol=symbol, qty=qty, entry_price=1000, portfolio_id="PF-1"))
def _buy(intents, iid="b1", pid="PF-1", qty=10):
return intents.submit_buy(
BuyIntent(intent_id=iid, portfolio_id=pid, qty=qty, entry_price=1000)
)
def test_buy_lifecycle_to_holding():
@@ -50,24 +55,74 @@ def test_buy_dispatch_is_idempotent_no_double_order():
assert bid1 == bid2 and len(broker._orders) == 1 # exactly one broker order
def test_buy_reject_rolls_back_to_idle():
repo, svc, intents, execution = _wire(PaperBroker(reject_symbols={"005930"}))
def test_buy_reject_rolls_back_to_inactive():
repo, svc, intents, execution = _wire(PaperBroker(reject_symbols={"005931"}))
sid = _buy(intents).slot_id
execution.run_once() # dispatch + poll(reject)
slot = repo.fetch_slot(sid)
assert slot.status is SlotState.IDLE and slot.symbol is None and slot.broker_order_id is None
assert slot.status is SlotState.INACTIVE and slot.broker_order_id is None
assert slot.portfolio_id == "PF-1" # permanent slot keeps its asset binding
def test_sell_lifecycle_to_closed_bumps_entry_count():
def test_sell_lifecycle_returns_to_inactive():
repo, svc, intents, execution = _wire(PaperBroker(fill_after_polls=0))
sid = _buy(intents).slot_id
execution.run_once() # -> holding
assert repo.fetch_slot(sid).status is SlotState.HOLDING
intents.submit_sell(SellIntent(intent_id="s1", slot_id=sid))
assert repo.fetch_slot(sid).status is SlotState.PENDING_SELL
execution.run_once() # dispatch sell + fill -> closed
assert repo.fetch_slot(sid).status is SlotState.CLOSED
assert svc.get_today_entry_count(DAY, "005930") == 0 # +1 happens on closed->idle recovery
execution.run_once() # dispatch sell + fill -> inactive (asset slot at rest)
slot = repo.fetch_slot(sid)
assert slot.status is SlotState.INACTIVE
assert slot.qty is None and slot.portfolio_id == "PF-1"
def test_sell_qty_is_broker_balance_not_slot_mirror():
"""dcy-020/REQ-062 (D3): sell dispatch quantity = broker hldg_qty even when
the slot mirror qty is stale."""
broker = PaperBroker(fill_after_polls=0)
repo, svc, intents, execution = _wire(broker)
sid = _buy(intents, qty=10).slot_id
execution.run_once() # holding, broker balance = 10
# corrupt the mirror: slot says 7, broker says 10
svc.transit_slot(sid, SlotState.HOLDING, SlotState.PENDING_SELL,
reason="r", actor="t", qty=7.0)
execution.run_once() # dispatch sell with broker qty
sell_orders = [o for o in broker._orders.values() if o.side == "sell"]
assert len(sell_orders) == 1 and sell_orders[0].qty == 10
def test_sell_skipped_when_broker_flat():
"""dcy-020/REQ-062 (D3): broker flat -> sell skipped (no mirror fallback),
surfaced via the sell_skipped_no_balance counter; no broker order is sent."""
broker = PaperBroker(fill_after_polls=0)
repo, svc, intents, execution = _wire(broker)
sid = _buy(intents).slot_id
execution.run_once() # holding (broker balance 10)
broker._orders.clear() # simulate divergence: broker is actually flat
svc.transit_slot(sid, SlotState.HOLDING, SlotState.PENDING_SELL,
reason="r", actor="t")
r = execution.run_once()
assert r.sell_skipped_no_balance == 1
assert r.dispatched == 0
assert repo.fetch_slot(sid).status is SlotState.PENDING_SELL # untouched
assert not [o for o in broker._orders.values() if o.side == "sell"]
def test_skipped_sell_does_not_block_other_dispatches():
"""A broker-flat sell must not head-block the dispatch queue."""
broker = PaperBroker(fill_after_polls=0)
repo, svc, intents, execution = _wire(broker)
sid = _buy(intents, iid="b1", pid="PF-1").slot_id
execution.run_once() # PF-1 holding
broker._orders.clear() # broker flat for PF-1's symbol
svc.transit_slot(sid, SlotState.HOLDING, SlotState.PENDING_SELL,
reason="r", actor="t")
d2 = _buy(intents, iid="b2", pid="PF-2") # a buy behind the stuck sell
r = execution.run_once()
assert r.sell_skipped_no_balance == 1
assert r.dispatched >= 1 # the buy went out
assert repo.fetch_slot(d2.slot_id).status is SlotState.HOLDING
def test_reconcile_after_crash_completes_fill_without_redispatch():
@@ -83,8 +138,12 @@ def test_reconcile_after_crash_completes_fill_without_redispatch():
assert len(broker._orders) == orders_before # reconcile did not submit a new order
def test_no_idle_slots_blocks_further_buys():
repo, svc, intents, execution = _wire(PaperBroker(fill_after_polls=0))
for i in range(5):
assert _buy(intents, iid=f"b{i}").status == "admitted"
assert _buy(intents, iid="overflow").status == "rejected"
def test_capacity_full_blocks_further_buys():
"""dcy-020/REQ-059: at M active slots further buys reject capacity_full."""
repo, svc, intents, execution = _wire(
PaperBroker(fill_after_polls=0), max_active_slots=2,
)
assert _buy(intents, iid="b0", pid="PF-0").status == "admitted"
assert _buy(intents, iid="b1", pid="PF-1").status == "admitted"
over = _buy(intents, iid="b2", pid="PF-2")
assert over.status == "rejected" and over.reason == "capacity_full"