feat(execution): 체결가 0/None 시 지정가 백필 가드 + WARN 로깅 (dcy-032/BAT-048)

REQ-095(슬롯 평단·원장 price 지정가 백필, 0 오염 방지) + REQ-096(백필/미해결 WARN 로깅) 구현.
지정가 부재 시 skip+강WARN. application 단일화(_resolve_fill_price), wire/LedgerTrade 스키마 불변.
575 passed/0 fail, opus QC PASS. REQ-097 알람=스파이크 산출물만(impl 후속).

- _resolve_fill_price 헬퍼 추가: avg_price 검증, limit_price 백필, 미해결 WARN
- _on_filled/_on_partial_cancel 통합: 백필 적용, 슬롯/원장 일관성
- _record_ledger 시그니처: resolved_price 파라미터 (backward compat)
- test_execution_fill_price_backfill.py: 21 시나리오 (avg=None/0, limit=있음/없음 등)
- docs: dcy-032, req-095/096/097, bat-048 설계/검증

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5
This commit is contained in:
2026-06-21 13:34:31 +09:00
co-authored by Claude Opus 4.8
parent d182464b7a
commit f1b649cf2a
15 changed files with 2134 additions and 11 deletions
@@ -0,0 +1,969 @@
"""Fill price backfill guard tests (REQ-095, REQ-096, BAT-048).
Tests the _resolve_fill_price helper and its integration into _on_filled /
_on_partial_cancel / _record_ledger for the cases where KIS returns
avg_price=None or avg_price=0.0.
All cases use a StubBroker that returns a programmed FillStatus so we can
inject arbitrary avg_price values (including None/0) without touching the
real KIS or paper adapter.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Optional
from uuid import uuid4
from trex_core.adapters.ledger.in_memory import InMemoryLedger
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
from trex_core.application.broker_port import (
BalanceItem,
FillStatus,
OrderAck,
OrderRequest,
)
from trex_core.application.execution import ExecutionService
from trex_core.application.intents import BuyIntent, IntentService
from trex_core.application.ledger_outbox import InMemoryLedgerOutbox, LedgerWriter
from trex_core.domain.slot import SlotService, SlotState
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
from trex_core.domain.slot.models import Slot
# ---------------------------------------------------------------------------
# Stub broker — lets tests inject arbitrary FillStatus values
# ---------------------------------------------------------------------------
@dataclass
class _ProgrammedOrder:
oid: str
symbol: str
limit_price: Optional[int]
fill_status: FillStatus # returned on first poll
class StubBroker:
"""Minimal broker that returns a pre-programmed FillStatus on poll_fill.
submit_order always accepts and stores limit_price so tests can verify
the slot's limit_price context. The programmed fill_status is returned
immediately (fill_after_polls=0 semantics).
"""
def __init__(self, fill_status: FillStatus, *, balance_qty: int = 10) -> None:
self._fill_status = fill_status
self._balance_qty: dict[str, int] = {}
self._default_balance_qty = balance_qty
self._orders: dict[str, _ProgrammedOrder] = {}
self._seq = 0
def name(self) -> str:
return "stub"
def submit_order(self, req: OrderRequest) -> OrderAck:
self._seq += 1
oid = f"STUB-{self._seq:06d}"
self._orders[oid] = _ProgrammedOrder(
oid=oid,
symbol=req.symbol,
limit_price=req.limit_price,
fill_status=self._fill_status,
)
# After submit, simulate the broker holding the qty.
if req.side == "buy":
self._balance_qty[req.symbol] = (
self._balance_qty.get(req.symbol, 0) + self._default_balance_qty
)
return OrderAck(accepted=True, broker_order_id=oid)
def poll_fill(self, broker_order_id: str, *, side: str, symbol: str) -> FillStatus:
o = self._orders.get(broker_order_id)
if o is None:
return FillStatus(state="unknown")
return o.fill_status
def cancel_order(self, broker_order_id: str, *, side: str, symbol: str, qty: int) -> bool:
return False
def get_balance(self) -> list[BalanceItem]:
return [
BalanceItem(symbol=sym, hldg_qty=qty)
for sym, qty in self._balance_qty.items()
if qty > 0
]
# ---------------------------------------------------------------------------
# Wiring helpers
# ---------------------------------------------------------------------------
INST = "default"
def _wire(broker):
"""Wire up execution stack with in-memory adapters."""
repo = InMemorySlotRepository()
svc = SlotService(repo, instance_key=INST)
intents = IntentService(
svc, repo, InMemoryIntentStore(),
resolve_symbol=lambda pid: "005930",
instance_key=INST,
)
outbox = InMemoryLedgerOutbox()
execution = ExecutionService(svc, repo, broker, ledger_outbox=outbox, instance_key=INST)
repo.reconcile_universe(INST, {"PF-1": "005930"})
return repo, svc, intents, execution, outbox
def _buy(intents, *, iid="b1", portfolio_id="PF-1", qty=10, entry_price=1000):
return intents.submit_buy(
BuyIntent(intent_id=iid, portfolio_id=portfolio_id, qty=qty, entry_price=entry_price)
)
def _make_filled_status(
*,
avg_price: Optional[float],
filled_qty: int = 10,
execution_id: str = "EX-001",
fee: float = 0.015,
tax: float = 0.0,
) -> FillStatus:
"""Build a FillStatus(state='filled') with the given avg_price."""
return FillStatus(
state="filled",
filled_qty=filled_qty,
avg_price=avg_price,
execution_id=execution_id,
fee=fee,
tax=tax,
)
def _make_partial_cancel_status(
*,
avg_price: Optional[float],
filled_qty: int = 5,
execution_id: str = "EX-002",
fee: float = 0.007,
tax: float = 0.0,
) -> FillStatus:
"""Build a FillStatus representing partial fill then external cancel."""
return FillStatus(
state="partially_filled",
filled_qty=filled_qty,
avg_price=avg_price,
execution_id=execution_id,
fee=fee,
tax=tax,
cancelled=True,
)
# ===========================================================================
# REQ-095 / REQ-096 — _on_filled: backfill cases
# ===========================================================================
class TestOnFilledBackfill:
"""REQ-095 AC-1, AC-3, AC-4 / REQ-096 AC-1, AC-3 via _on_filled."""
def test_avg_price_none_with_limit_price_uses_limit_price(self, caplog):
"""REQ-095 AC-1: avg_price=None + limit_price>0 → slot avg = limit_price,
REQ-096 AC-1: exactly 1 WARN log with reason=avg_price_backfill."""
fill_status = _make_filled_status(avg_price=None)
broker = StubBroker(fill_status)
repo, svc, intents, execution, outbox = _wire(broker)
sid = _buy(intents, entry_price=1000).slot_id
# Confirm limit_price is stored (set during dispatch)
with caplog.at_level(logging.WARNING, logger="trex_core.application.execution"):
r = execution.run_once()
assert r.filled == 1, "fill must be registered"
slot = repo.fetch_slot(sid)
assert slot.status is SlotState.HOLDING, "slot must transition to HOLDING"
# REQ-095 AC-1: avg_price must equal limit_price (1000), NOT 0
assert slot.avg_price == 1000.0, f"expected 1000.0, got {slot.avg_price}"
assert slot.avg_price != 0.0
# REQ-096 AC-1: exactly one backfill WARN
warn_msgs = [
rec.getMessage() for rec in caplog.records
if rec.levelno == logging.WARNING and "avg_price_backfill" in rec.getMessage()
]
assert len(warn_msgs) == 1, f"expected 1 backfill WARN, got {len(warn_msgs)}: {warn_msgs}"
assert "reason=avg_price_backfill" in warn_msgs[0]
assert str(sid) in warn_msgs[0] # slot_id in log
def test_avg_price_zero_with_limit_price_uses_limit_price(self, caplog):
"""REQ-095 AC-2 (AC-1 variant with 0.0): avg_price=0.0 → limit_price backfill."""
fill_status = _make_filled_status(avg_price=0.0)
broker = StubBroker(fill_status)
repo, svc, intents, execution, outbox = _wire(broker)
sid = _buy(intents, entry_price=1500).slot_id
with caplog.at_level(logging.WARNING, logger="trex_core.application.execution"):
r = execution.run_once()
assert r.filled == 1
slot = repo.fetch_slot(sid)
assert slot.status is SlotState.HOLDING
# avg_price must be limit_price (1500), not 0
assert slot.avg_price == 1500.0, f"expected 1500.0, got {slot.avg_price}"
assert slot.avg_price != 0.0
warn_msgs = [
rec.getMessage() for rec in caplog.records
if rec.levelno == logging.WARNING and "avg_price_backfill" in rec.getMessage()
]
assert len(warn_msgs) == 1
def test_happy_path_uses_avg_price_no_backfill_warn(self, caplog):
"""REQ-095 AC-4 / REQ-096 AC-3: normal positive avg_price — no backfill WARN."""
fill_status = _make_filled_status(avg_price=1234.0)
broker = StubBroker(fill_status)
repo, svc, intents, execution, outbox = _wire(broker)
sid = _buy(intents, entry_price=1200).slot_id
with caplog.at_level(logging.WARNING, logger="trex_core.application.execution"):
r = execution.run_once()
assert r.filled == 1
slot = repo.fetch_slot(sid)
assert slot.status is SlotState.HOLDING
# slot avg_price should equal fs.avg_price (1234.0), not limit_price
assert slot.avg_price == 1234.0
backfill_warns = [
rec.getMessage() for rec in caplog.records
if "avg_price_backfill" in rec.getMessage() or "fill_price_unresolvable" in rec.getMessage()
]
assert backfill_warns == [], f"no backfill WARN expected on happy path: {backfill_warns}"
def test_avg_price_none_limit_price_none_skips_fill(self, caplog):
"""REQ-095 AC-6 / REQ-096 AC-2: avg_price=None + limit_price=None/≤0 →
fill skipped, slot stays PENDING, strong WARN with reason=fill_price_unresolvable."""
fill_status = _make_filled_status(avg_price=None)
broker = StubBroker(fill_status)
repo, svc, intents, execution, outbox = _wire(broker)
# Submit buy WITHOUT limit_price by using entry_price=None — however
# BuyIntent requires entry_price. We inject a slot directly with no limit_price.
anon = Slot(slot_id=uuid4(), status=SlotState.INACTIVE)
repo.add_slot(anon)
svc.transit_slot(
anon.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="test",
symbol="005930", portfolio_id="PF-1",
limit_price=None, # explicitly no limit_price
)
sid = anon.slot_id
# Manually dispatch the slot so broker_order_id is set
repo.mark_dispatched(sid, "STUB-MANUAL-001")
broker._orders["STUB-MANUAL-001"] = _ProgrammedOrder(
oid="STUB-MANUAL-001",
symbol="005930",
limit_price=None,
fill_status=fill_status,
)
broker._balance_qty["005930"] = 10
with caplog.at_level(logging.WARNING, logger="trex_core.application.execution"):
execution.run_once()
# Slot must NOT have transitioned — still PENDING_BUY
slot = repo.fetch_slot(sid)
assert slot.status is SlotState.PENDING_BUY, (
f"slot must stay PENDING_BUY on unresolvable fill, got {slot.status}"
)
# Ledger must be empty (no price=0 record)
assert outbox.all_items() == [], "no ledger record should be created on unresolvable fill"
# REQ-096 AC-2: at least 1 strong WARN with reason=fill_price_unresolvable
unresolvable_warns = [
rec.getMessage() for rec in caplog.records
if "fill_price_unresolvable" in rec.getMessage()
]
assert len(unresolvable_warns) >= 1, (
"expected at least 1 fill_price_unresolvable WARN"
)
def test_avg_price_none_limit_price_zero_skips_fill(self, caplog):
"""REQ-095 AC-6 variant: limit_price=0 (≤0) also triggers skip + strong WARN."""
fill_status = _make_filled_status(avg_price=None)
broker = StubBroker(fill_status)
repo, svc, intents, execution, outbox = _wire(broker)
anon = Slot(slot_id=uuid4(), status=SlotState.INACTIVE)
repo.add_slot(anon)
svc.transit_slot(
anon.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="test",
symbol="005930", portfolio_id="PF-1",
limit_price=0, # ≤0 limit_price
)
sid = anon.slot_id
repo.mark_dispatched(sid, "STUB-ZERO-001")
broker._orders["STUB-ZERO-001"] = _ProgrammedOrder(
oid="STUB-ZERO-001",
symbol="005930",
limit_price=0,
fill_status=fill_status,
)
broker._balance_qty["005930"] = 10
with caplog.at_level(logging.WARNING, logger="trex_core.application.execution"):
execution.run_once()
slot = repo.fetch_slot(sid)
assert slot.status is SlotState.PENDING_BUY
assert outbox.all_items() == []
unresolvable_warns = [
rec.getMessage() for rec in caplog.records
if "fill_price_unresolvable" in rec.getMessage()
]
assert len(unresolvable_warns) >= 1
# ===========================================================================
# REQ-095 / REQ-096 — _on_partial_cancel: backfill cases
# ===========================================================================
class TestOnPartialCancelBackfill:
"""REQ-095 AC-2, AC-3 / REQ-096 AC-1, AC-3 via _on_partial_cancel."""
def test_avg_price_none_partial_cancel_uses_limit_price(self, caplog):
"""REQ-095 AC-2 (_on_partial_cancel path): avg_price=None → limit_price backfill,
REQ-096 AC-1: exactly 1 WARN with reason=avg_price_backfill."""
fill_status = _make_partial_cancel_status(avg_price=None, filled_qty=5)
broker = StubBroker(fill_status, balance_qty=5)
repo, svc, intents, execution, outbox = _wire(broker)
sid = _buy(intents, entry_price=2000, qty=10).slot_id
with caplog.at_level(logging.WARNING, logger="trex_core.application.execution"):
r = execution.run_once()
# partial_cancel counts as r.filled += 1
assert r.filled == 1
slot = repo.fetch_slot(sid)
assert slot.status is SlotState.HOLDING
# avg_price should be limit_price (2000), not 0
assert slot.avg_price == 2000.0, f"expected 2000.0, got {slot.avg_price}"
assert slot.avg_price != 0.0
warn_msgs = [
rec.getMessage() for rec in caplog.records
if rec.levelno == logging.WARNING and "avg_price_backfill" in rec.getMessage()
]
assert len(warn_msgs) == 1, f"expected 1 backfill WARN, got {len(warn_msgs)}"
def test_avg_price_zero_partial_cancel_uses_limit_price(self, caplog):
"""avg_price=0.0 in partial_cancel path → limit_price backfill (REQ-095 AC-2)."""
fill_status = _make_partial_cancel_status(avg_price=0.0, filled_qty=5)
broker = StubBroker(fill_status, balance_qty=5)
repo, svc, intents, execution, outbox = _wire(broker)
sid = _buy(intents, entry_price=1800, qty=10).slot_id
with caplog.at_level(logging.WARNING, logger="trex_core.application.execution"):
r = execution.run_once()
assert r.filled == 1
slot = repo.fetch_slot(sid)
assert slot.status is SlotState.HOLDING
assert slot.avg_price == 1800.0
assert slot.avg_price != 0.0
warn_msgs = [
rec.getMessage() for rec in caplog.records
if "avg_price_backfill" in rec.getMessage()
]
assert len(warn_msgs) == 1
def test_happy_path_partial_cancel_no_backfill_warn(self, caplog):
"""REQ-096 AC-3: normal avg_price in partial_cancel — no backfill WARN."""
fill_status = _make_partial_cancel_status(avg_price=1900.0, filled_qty=5)
broker = StubBroker(fill_status, balance_qty=5)
repo, svc, intents, execution, outbox = _wire(broker)
sid = _buy(intents, entry_price=1800, qty=10).slot_id
with caplog.at_level(logging.WARNING, logger="trex_core.application.execution"):
r = execution.run_once()
assert r.filled == 1
slot = repo.fetch_slot(sid)
assert slot.status is SlotState.HOLDING
assert slot.avg_price == 1900.0
backfill_warns = [
rec.getMessage() for rec in caplog.records
if "avg_price_backfill" in rec.getMessage() or "fill_price_unresolvable" in rec.getMessage()
]
assert backfill_warns == []
def test_partial_cancel_unresolvable_skips_fill(self, caplog):
"""REQ-095 AC-6 via _on_partial_cancel: unresolvable price → skip."""
fill_status = _make_partial_cancel_status(avg_price=None, filled_qty=5)
broker = StubBroker(fill_status, balance_qty=5)
repo, svc, intents, execution, outbox = _wire(broker)
anon = Slot(slot_id=uuid4(), status=SlotState.INACTIVE)
repo.add_slot(anon)
svc.transit_slot(
anon.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="test",
symbol="005930", portfolio_id="PF-1",
limit_price=None,
)
sid = anon.slot_id
repo.mark_dispatched(sid, "STUB-PC-001")
broker._orders["STUB-PC-001"] = _ProgrammedOrder(
oid="STUB-PC-001",
symbol="005930",
limit_price=None,
fill_status=fill_status,
)
broker._balance_qty["005930"] = 5
with caplog.at_level(logging.WARNING, logger="trex_core.application.execution"):
execution.run_once()
slot = repo.fetch_slot(sid)
assert slot.status is SlotState.PENDING_BUY
assert outbox.all_items() == []
unresolvable_warns = [
rec.getMessage() for rec in caplog.records
if "fill_price_unresolvable" in rec.getMessage()
]
assert len(unresolvable_warns) >= 1
# ===========================================================================
# REQ-095 AC-3 — _record_ledger: ledger price matches backfill value
# ===========================================================================
class TestRecordLedgerBackfillPrice:
"""REQ-095 AC-3: ledger record price == backfill price (not 0)."""
def test_ledger_price_equals_backfill_limit_price_on_filled(self):
"""REQ-095 AC-3: avg_price=None + limit_price=1000 →
LedgerTrade.price = 1000.0 (not 0.0)."""
fill_status = _make_filled_status(avg_price=None, execution_id="EX-LEDGER-001")
broker = StubBroker(fill_status)
repo, svc, intents, execution, outbox = _wire(broker)
ledger = InMemoryLedger()
writer = LedgerWriter(outbox, ledger)
_buy(intents, entry_price=1000).slot_id
r = execution.run_once()
assert r.ledger_enqueued == 1
writer.run_once()
assert ledger.count() == 1
trade = ledger.trades()[0]
assert trade.price == 1000.0, f"expected ledger price=1000.0, got {trade.price}"
assert trade.price != 0.0
def test_ledger_price_equals_backfill_limit_price_on_partial_cancel(self):
"""REQ-095 AC-3 via _on_partial_cancel: backfill price flows to ledger."""
fill_status = _make_partial_cancel_status(
avg_price=None, filled_qty=5, execution_id="EX-PC-LEDGER"
)
broker = StubBroker(fill_status, balance_qty=5)
repo, svc, intents, execution, outbox = _wire(broker)
ledger = InMemoryLedger()
writer = LedgerWriter(outbox, ledger)
_buy(intents, entry_price=2500, qty=10).slot_id
r = execution.run_once()
assert r.ledger_enqueued == 1
writer.run_once()
assert ledger.count() == 1
trade = ledger.trades()[0]
assert trade.price == 2500.0, f"expected ledger price=2500.0, got {trade.price}"
assert trade.price != 0.0
def test_ledger_price_on_happy_path_uses_avg_price(self):
"""REQ-095 AC-4 regression: normal avg_price → ledger uses avg_price, not limit_price."""
fill_status = _make_filled_status(avg_price=1111.0, execution_id="EX-HAPPY")
broker = StubBroker(fill_status)
repo, svc, intents, execution, outbox = _wire(broker)
ledger = InMemoryLedger()
writer = LedgerWriter(outbox, ledger)
_buy(intents, entry_price=1200).slot_id # limit 1200, but avg = 1111
execution.run_once()
writer.run_once()
trade = ledger.trades()[0]
assert trade.price == 1111.0, f"expected ledger price=1111.0 (avg_price), got {trade.price}"
def test_ledger_not_written_when_fill_price_unresolvable(self):
"""REQ-095 AC-6: unresolvable fill → no ledger record, price=0 record never created."""
fill_status = _make_filled_status(avg_price=None, execution_id="EX-SKIP")
broker = StubBroker(fill_status)
repo, svc, intents, execution, outbox = _wire(broker)
ledger = InMemoryLedger()
writer = LedgerWriter(outbox, ledger)
anon = Slot(slot_id=uuid4(), status=SlotState.INACTIVE)
repo.add_slot(anon)
svc.transit_slot(
anon.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="test",
symbol="005930", portfolio_id="PF-1",
limit_price=None,
)
sid = anon.slot_id
repo.mark_dispatched(sid, "STUB-SKIP-001")
broker._orders["STUB-SKIP-001"] = _ProgrammedOrder(
oid="STUB-SKIP-001",
symbol="005930",
limit_price=None,
fill_status=fill_status,
)
broker._balance_qty["005930"] = 10
execution.run_once()
writer.run_once()
assert outbox.all_items() == []
assert ledger.count() == 0, "no ledger record must be created when fill is skipped"
# ===========================================================================
# REQ-096 AC-4 — WARN log field traceability
# ===========================================================================
class TestWarnLogFields:
"""REQ-096 AC-4: backfill WARN log contains required fields for traceability."""
def test_backfill_warn_contains_required_fields(self, caplog):
"""avg_price_backfill WARN must include slot_id, symbol, broker_order_id,
original avg_price (None), backfill_price, reason=avg_price_backfill."""
fill_status = _make_filled_status(avg_price=None)
broker = StubBroker(fill_status)
repo, svc, intents, execution, outbox = _wire(broker)
sid = _buy(intents, entry_price=3000).slot_id
with caplog.at_level(logging.WARNING, logger="trex_core.application.execution"):
execution.run_once()
warn_msgs = [
rec.getMessage() for rec in caplog.records
if "avg_price_backfill" in rec.getMessage()
]
assert len(warn_msgs) == 1
msg = warn_msgs[0]
assert str(sid) in msg, "slot_id must appear in WARN log"
assert "005930" in msg, "symbol must appear in WARN log"
assert "avg_price=None" in msg, "original avg_price must appear in WARN log"
assert "backfill_price=3000" in msg, "backfill_price must appear in WARN log"
assert "reason=avg_price_backfill" in msg
def test_unresolvable_warn_contains_required_fields(self, caplog):
"""fill_price_unresolvable WARN must include slot_id, symbol, broker_order_id,
avg_price, limit_price, reason=fill_price_unresolvable."""
fill_status = _make_filled_status(avg_price=None)
broker = StubBroker(fill_status)
repo, svc, intents, execution, outbox = _wire(broker)
anon = Slot(slot_id=uuid4(), status=SlotState.INACTIVE)
repo.add_slot(anon)
svc.transit_slot(
anon.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="test",
symbol="005930", portfolio_id="PF-1",
limit_price=None,
)
sid = anon.slot_id
repo.mark_dispatched(sid, "STUB-FIELD-001")
broker._orders["STUB-FIELD-001"] = _ProgrammedOrder(
oid="STUB-FIELD-001",
symbol="005930",
limit_price=None,
fill_status=fill_status,
)
broker._balance_qty["005930"] = 10
with caplog.at_level(logging.WARNING, logger="trex_core.application.execution"):
execution.run_once()
warn_msgs = [
rec.getMessage() for rec in caplog.records
if "fill_price_unresolvable" in rec.getMessage()
]
assert len(warn_msgs) >= 1
msg = warn_msgs[0]
assert str(sid) in msg, "slot_id must appear in WARN log"
assert "005930" in msg, "symbol must appear in WARN log"
assert "reason=fill_price_unresolvable" in msg
# ===========================================================================
# NOTE-2: PENDING_SELL path — backfill / skip regression (EC-3, M2 safety net)
# ===========================================================================
#
# Design intent (design.md EC-3):
# - _on_filled / _on_partial_cancel PENDING_SELL branch does NOT update slot
# avg_price (cost basis is preserved). fill_price is used only for
# _record_ledger (the sell execution price).
# - slot.limit_price is cleared to None on HOLDING entry (service.py), so
# a sell slot normally carries limit_price=None (market order). A limit
# sell sets it explicitly via svc.transit_slot(HOLDING→PENDING_SELL,
# limit_price=xxx).
# - avg_price=None + limit_price>0 → backfill: _record_ledger price=limit_price + WARN
# - avg_price=None + limit_price=None → skip + strong WARN + slot stays PENDING_SELL
# (no ledger record, no partial state mutation)
def _make_holding_slot(
repo: InMemorySlotRepository,
svc: SlotService,
*,
portfolio_id: str = "PF-1",
symbol: str = "005930",
avg_price: float = 5000.0,
qty: float = 10.0,
):
"""Seed a HOLDING slot directly (INACTIVE→HOLDING is not an allowed transition,
so we use repo.add_slot for test setup).
Returns the slot_id (UUID).
"""
from datetime import datetime, timezone
sid = uuid4()
slot = Slot(
slot_id=sid,
status=SlotState.HOLDING,
instance_key=INST,
portfolio_id=portfolio_id,
symbol=symbol,
avg_price=avg_price,
qty=qty,
created_at=datetime.now(tz=timezone.utc),
updated_at=datetime.now(tz=timezone.utc),
)
repo.add_slot(slot)
return sid
class TestSellPathBackfill:
"""EC-3: PENDING_SELL fill price backfill / skip path (M2 safety net — TSL).
Design intent: avg_price (cost basis) is PRESERVED on sell; fill_price flows
only to _record_ledger as the sell execution price. Backfill and skip behaviour
must be tested independently of the buy path.
"""
# -----------------------------------------------------------------------
# EC-3a: _on_filled PENDING_SELL + avg_price=None + limit_price>0
# → backfill: ledger price = limit_price + WARN + slot goes INACTIVE
# -----------------------------------------------------------------------
def test_sell_filled_avg_price_none_with_limit_price_uses_backfill(self, caplog):
"""EC-3a (_on_filled PENDING_SELL): avg_price=None + limit_price>0 →
ledger price = limit_price (not 0), exactly 1 WARN reason=avg_price_backfill.
Slot cost basis (avg_price) is preserved and NOT mutated."""
fill_status = _make_filled_status(avg_price=None, filled_qty=10, execution_id="EX-SELL-001")
broker = StubBroker(fill_status, balance_qty=0) # balance 0 = full sell
repo, svc, intents, execution, outbox = _wire(broker)
ledger = InMemoryLedger()
writer = LedgerWriter(outbox, ledger)
# Seed a HOLDING slot with known cost basis (avg_price=5000)
sid = _make_holding_slot(repo, svc, avg_price=5000.0, qty=10.0)
# Transition to PENDING_SELL with an explicit limit_price (limit sell)
svc.transit_slot(
sid, SlotState.HOLDING, SlotState.PENDING_SELL,
reason="sell_decision", actor="test",
limit_price=4800, # explicit sell limit
)
repo.mark_dispatched(sid, "STUB-SELL-FILL-001")
broker._orders["STUB-SELL-FILL-001"] = _ProgrammedOrder(
oid="STUB-SELL-FILL-001",
symbol="005930",
limit_price=4800,
fill_status=fill_status,
)
# balance 0 → full sell → slot → INACTIVE
with caplog.at_level(logging.WARNING, logger="trex_core.application.execution"):
r = execution.run_once()
assert r.filled == 1, "sell fill must be registered"
slot = repo.fetch_slot(sid)
# Full sell (broker balance 0) → INACTIVE
assert slot.status is SlotState.INACTIVE, (
f"full sell must go to INACTIVE, got {slot.status}"
)
# cost basis (avg_price) must NOT have been mutated to fill_price
# (INACTIVE slot avg_price is cleared by transit; we verify no 0 contamination
# occurred before the transition — we check via ledger price instead)
# Backfill WARN must be emitted
backfill_warns = [
rec.getMessage() for rec in caplog.records
if "avg_price_backfill" in rec.getMessage()
]
assert len(backfill_warns) == 1, (
f"expected 1 avg_price_backfill WARN, got {len(backfill_warns)}"
)
assert "reason=avg_price_backfill" in backfill_warns[0]
# Ledger price must be backfill value (4800), not 0
writer.run_once()
assert ledger.count() == 1
trade = ledger.trades()[0]
assert trade.price == 4800.0, (
f"ledger sell price must be backfill limit_price=4800, got {trade.price}"
)
assert trade.price != 0.0
assert trade.side == "sell"
# -----------------------------------------------------------------------
# EC-3b: _on_partial_cancel PENDING_SELL + avg_price=None + limit_price>0
# → backfill: ledger price = limit_price + WARN + slot stays HOLDING
# -----------------------------------------------------------------------
def test_sell_partial_cancel_avg_price_none_with_limit_price_uses_backfill(self, caplog):
"""EC-3b (_on_partial_cancel PENDING_SELL): avg_price=None + limit_price>0 →
ledger price = limit_price + WARN. Remaining balance stays HOLDING."""
fill_status = _make_partial_cancel_status(
avg_price=None, filled_qty=5, execution_id="EX-SELL-PC-001"
)
# balance 5 → partial sell → remainder stays HOLDING
broker = StubBroker(fill_status, balance_qty=5)
repo, svc, intents, execution, outbox = _wire(broker)
ledger = InMemoryLedger()
writer = LedgerWriter(outbox, ledger)
sid = _make_holding_slot(repo, svc, avg_price=5000.0, qty=10.0)
svc.transit_slot(
sid, SlotState.HOLDING, SlotState.PENDING_SELL,
reason="sell_decision", actor="test",
limit_price=4900,
)
repo.mark_dispatched(sid, "STUB-SELL-PC-001")
broker._orders["STUB-SELL-PC-001"] = _ProgrammedOrder(
oid="STUB-SELL-PC-001",
symbol="005930",
limit_price=4900,
fill_status=fill_status,
)
broker._balance_qty["005930"] = 5 # remainder after partial fill
with caplog.at_level(logging.WARNING, logger="trex_core.application.execution"):
r = execution.run_once()
assert r.filled == 1
slot = repo.fetch_slot(sid)
# Partial sell (balance=5 > 0) → back to HOLDING
assert slot.status is SlotState.HOLDING, (
f"partial sell remainder must go to HOLDING, got {slot.status}"
)
backfill_warns = [
rec.getMessage() for rec in caplog.records
if "avg_price_backfill" in rec.getMessage()
]
assert len(backfill_warns) == 1, (
f"expected 1 avg_price_backfill WARN, got {len(backfill_warns)}"
)
# Ledger price = backfill limit_price (4900), not 0
writer.run_once()
assert ledger.count() == 1
trade = ledger.trades()[0]
assert trade.price == 4900.0, (
f"ledger sell price must be backfill limit_price=4900, got {trade.price}"
)
assert trade.side == "sell"
# -----------------------------------------------------------------------
# EC-3c: _on_filled PENDING_SELL + avg_price=None + limit_price=None
# → skip + strong WARN + slot stays PENDING_SELL + no ledger record
# -----------------------------------------------------------------------
def test_sell_filled_avg_price_none_limit_price_none_skips(self, caplog):
"""EC-3c (_on_filled PENDING_SELL): avg_price=None + limit_price=None →
skip + strong WARN reason=fill_price_unresolvable + slot stays PENDING_SELL
+ NO ledger record (design.md EC-3: market sell with limit_price=None is
normal for TSL; skip+strong WARN is the intended safe fallback)."""
fill_status = _make_filled_status(avg_price=None, filled_qty=10, execution_id="EX-SELL-SKIP-001")
broker = StubBroker(fill_status, balance_qty=0)
repo, svc, intents, execution, outbox = _wire(broker)
sid = _make_holding_slot(repo, svc, avg_price=5000.0, qty=10.0)
# Market sell: limit_price stays None (normal TSL path)
svc.transit_slot(
sid, SlotState.HOLDING, SlotState.PENDING_SELL,
reason="tsl_trigger", actor="test",
# no limit_price → None
)
repo.mark_dispatched(sid, "STUB-SELL-SKIP-001")
broker._orders["STUB-SELL-SKIP-001"] = _ProgrammedOrder(
oid="STUB-SELL-SKIP-001",
symbol="005930",
limit_price=None,
fill_status=fill_status,
)
broker._balance_qty["005930"] = 0
with caplog.at_level(logging.WARNING, logger="trex_core.application.execution"):
execution.run_once()
# Slot must stay PENDING_SELL (no transition)
slot = repo.fetch_slot(sid)
assert slot.status is SlotState.PENDING_SELL, (
f"slot must stay PENDING_SELL on unresolvable sell fill, got {slot.status}"
)
# No ledger record
assert outbox.all_items() == [], (
"no ledger record must be created when sell fill price is unresolvable"
)
# Strong WARN with reason=fill_price_unresolvable
unresolvable_warns = [
rec.getMessage() for rec in caplog.records
if "fill_price_unresolvable" in rec.getMessage()
]
assert len(unresolvable_warns) >= 1, (
"expected at least 1 fill_price_unresolvable WARN on sell skip"
)
assert "reason=fill_price_unresolvable" in unresolvable_warns[0]
# -----------------------------------------------------------------------
# EC-3d: _on_partial_cancel PENDING_SELL + avg_price=None + limit_price=None
# → skip + strong WARN + slot stays PENDING_SELL + no ledger record
# -----------------------------------------------------------------------
def test_sell_partial_cancel_avg_price_none_limit_price_none_skips(self, caplog):
"""EC-3d (_on_partial_cancel PENDING_SELL): avg_price=None + limit_price=None →
skip + strong WARN + slot stays PENDING_SELL + no ledger record."""
fill_status = _make_partial_cancel_status(
avg_price=None, filled_qty=5, execution_id="EX-SELL-PC-SKIP-001"
)
broker = StubBroker(fill_status, balance_qty=5)
repo, svc, intents, execution, outbox = _wire(broker)
sid = _make_holding_slot(repo, svc, avg_price=5000.0, qty=10.0)
svc.transit_slot(
sid, SlotState.HOLDING, SlotState.PENDING_SELL,
reason="tsl_trigger", actor="test",
# no limit_price → None (market sell)
)
repo.mark_dispatched(sid, "STUB-SELL-PC-SKIP-001")
broker._orders["STUB-SELL-PC-SKIP-001"] = _ProgrammedOrder(
oid="STUB-SELL-PC-SKIP-001",
symbol="005930",
limit_price=None,
fill_status=fill_status,
)
broker._balance_qty["005930"] = 5
with caplog.at_level(logging.WARNING, logger="trex_core.application.execution"):
execution.run_once()
slot = repo.fetch_slot(sid)
assert slot.status is SlotState.PENDING_SELL, (
f"slot must stay PENDING_SELL on unresolvable partial-cancel sell, got {slot.status}"
)
assert outbox.all_items() == [], (
"no ledger record must be created when sell partial-cancel price is unresolvable"
)
unresolvable_warns = [
rec.getMessage() for rec in caplog.records
if "fill_price_unresolvable" in rec.getMessage()
]
assert len(unresolvable_warns) >= 1, (
"expected at least 1 fill_price_unresolvable WARN on sell partial-cancel skip"
)
# -----------------------------------------------------------------------
# EC-3e: happy path — sell fill with positive avg_price → no backfill WARN
# -----------------------------------------------------------------------
def test_sell_filled_happy_path_no_backfill_warn(self, caplog):
"""EC-3e: normal sell fill with positive avg_price → ledger price = avg_price,
no backfill WARN, slot goes INACTIVE (full sell)."""
fill_status = _make_filled_status(
avg_price=4950.0, filled_qty=10, execution_id="EX-SELL-HAPPY-001"
)
broker = StubBroker(fill_status, balance_qty=0) # balance 0 = full sell
repo, svc, intents, execution, outbox = _wire(broker)
ledger = InMemoryLedger()
writer = LedgerWriter(outbox, ledger)
sid = _make_holding_slot(repo, svc, avg_price=5000.0, qty=10.0)
svc.transit_slot(
sid, SlotState.HOLDING, SlotState.PENDING_SELL,
reason="tsl_trigger", actor="test",
)
repo.mark_dispatched(sid, "STUB-SELL-HAPPY-001")
broker._orders["STUB-SELL-HAPPY-001"] = _ProgrammedOrder(
oid="STUB-SELL-HAPPY-001",
symbol="005930",
limit_price=None,
fill_status=fill_status,
)
broker._balance_qty["005930"] = 0
with caplog.at_level(logging.WARNING, logger="trex_core.application.execution"):
r = execution.run_once()
assert r.filled == 1
slot = repo.fetch_slot(sid)
assert slot.status is SlotState.INACTIVE
backfill_warns = [
rec.getMessage() for rec in caplog.records
if "avg_price_backfill" in rec.getMessage() or "fill_price_unresolvable" in rec.getMessage()
]
assert backfill_warns == [], f"no backfill WARN expected on happy sell path: {backfill_warns}"
writer.run_once()
assert ledger.count() == 1
trade = ledger.trades()[0]
assert trade.price == 4950.0, (
f"ledger sell price must equal avg_price=4950.0, got {trade.price}"
)
assert trade.side == "sell"
# ===========================================================================
# Regression: paper broker happy-path must produce zero backfill WARNs
# ===========================================================================
class TestPaperBrokerRegression:
"""EC-5: paper broker always returns positive avg_price → no backfill WARN."""
def test_paper_broker_buy_no_backfill_warn(self, caplog):
"""EC-5 regression: PaperBroker fill → avg_price always positive → no WARN."""
from trex_core.adapters.broker.paper import PaperBroker
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
paper = PaperBroker(fill_after_polls=0)
repo2 = InMemorySlotRepository()
svc2 = SlotService(repo2, instance_key=INST)
intents2 = IntentService(
svc2, repo2, InMemoryIntentStore(),
resolve_symbol=lambda pid: "005930",
instance_key=INST,
)
outbox2 = InMemoryLedgerOutbox()
execution2 = ExecutionService(svc2, repo2, paper, ledger_outbox=outbox2, instance_key=INST)
repo2.reconcile_universe(INST, {"PF-1": "005930"})
intents2.submit_buy(BuyIntent(intent_id="paper-b1", portfolio_id="PF-1", qty=5, entry_price=1000))
with caplog.at_level(logging.WARNING, logger="trex_core.application.execution"):
execution2.run_once()
backfill_warns = [
rec.getMessage() for rec in caplog.records
if "avg_price_backfill" in rec.getMessage() or "fill_price_unresolvable" in rec.getMessage()
]
assert backfill_warns == [], f"paper broker must not trigger backfill WARN: {backfill_warns}"