Files
trex-core/tests/application/test_execution.py
T
genichinandClaude Opus 4.8 aee3512383 feat(intents): dcy-011 buy intent portfolio_id 필수 계약 + ledger skip 가시화 (BAT-015)
dcy-011 전 주기 delivery. issue #6(buy 식별자 정정) + issue #11(ledger silent skip) 흡수.
거버넌스: 기존 Implemented REQ는 change-req, 신규 기능만 신규 REQ(OQ-3).

- REQ-007 (change-req): buy admission portfolio_id 필수화 + SnapshotStore symbol
  resolve + unknown_portfolio 거부 (hard cutover, OQ-1/OQ-2)
- REQ-021 (change-req): buy 메시지 payload symbol 제거
- REQ-018 (change-req): _record_ledger skip 가시화 (WARN + ledger_skipped 카운터)
- REQ-055 (신규): closed↔ledger_outbox anti-join 운영 점검 find_ledger_gap_slots

코드: intents.py(BuyIntent portfolio_id 필수 + resolve_symbol 주입 + _admit_buy
reject 분기), messaging_handlers.py, runtime/daemon.py(_resolve_symbol 배선),
execution.py(skip WARN 로그), adapters/persistence/ops_checks.py(신규 anti-join).

검증: 단위 282 passed/32 skipped, 인프라 게이트(PG+RabbitMQ) 314 passed, ruff clean.
BAT-015(standard) release-candidate. srs/index Next ID → REQ-056.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 12:57:51 +09:00

91 lines
3.8 KiB
Python

"""Order execution loop tests via paper broker (REQ-013, REQ-014).
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
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)
def _wire(broker):
repo = InMemorySlotRepository()
svc = SlotService(repo)
intents = IntentService(svc, repo, InMemoryIntentStore())
execution = ExecutionService(svc, repo, broker)
svc.preallocate_daily_slots(DAY, 5)
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 test_buy_lifecycle_to_holding():
repo, svc, intents, execution = _wire(PaperBroker(fill_after_polls=0))
d = _buy(intents)
sid = d.slot_id
assert repo.fetch_slot(sid).status is SlotState.PENDING_BUY
execution.run_once() # dispatch + poll(fill immediately)
slot = repo.fetch_slot(sid)
assert slot.status is SlotState.HOLDING and slot.qty == 10 and slot.avg_price == 1000.0
def test_buy_dispatch_is_idempotent_no_double_order():
broker = PaperBroker(fill_after_polls=5) # stays accepted (not filled) across ticks
repo, svc, intents, execution = _wire(broker)
sid = _buy(intents).slot_id
execution.run_once() # dispatch (1 order) + poll(accepted)
bid1 = repo.fetch_slot(sid).broker_order_id
execution.run_once() # must NOT re-dispatch
bid2 = repo.fetch_slot(sid).broker_order_id
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"}))
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
def test_sell_lifecycle_to_closed_bumps_entry_count():
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
def test_reconcile_after_crash_completes_fill_without_redispatch():
broker = PaperBroker(fill_after_polls=1)
repo, svc, intents, execution = _wire(broker)
sid = _buy(intents).slot_id
execution.run_once() # dispatch + poll1 (accepted, not filled) — simulates crash here
assert repo.fetch_slot(sid).status is SlotState.PENDING_BUY
orders_before = len(broker._orders)
execution.reconcile() # restart: re-poll -> fills, no new order
slot = repo.fetch_slot(sid)
assert slot.status is SlotState.HOLDING
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"