feat(execution,obs): 호가단위 스냅(dcy-030/#28) + 운영 관측성(dcy-029/#29) + REL-007 환류

dcy-030 (issue #28, P0 체결 복구):
- REQ-083 KRX 호가단위(tick) 순수 유틸 (domain/pricing.py)
- REQ-070 CHG: 슬리피지 실효가 호가단위 스냅 — 상한 준수(매수 floor/매도 ceil)
  + band<1tick 하한/상한 보장 + 부동소수 epsilon 가드. 147700→148400/54500→54700.
- BAT-044 release-candidate

dcy-029 (issue #29, 운영 관측성):
- REQ-084 구조화 로깅 기반 (runtime/logging_setup.py): ISO8601 KST·레벨·Loki JSON
  ·correlation id(slot_id)
- REQ-085 주문 결과 로깅 (event=order_submit|fill|reject)
- REQ-087 거부 ERROR 가시성 (조용한 롤백 제거)
- REQ-086 청산 reason+actor 동반 노출 (안전망/전략 구분, 신규 필드 없음)
- REQ-088 운영 조회 CLI report (application/operator_report.py, 보유·오늘 체결
  ·실현 PnL net·대기, direct-DB)
- REQ-089 운영자 런북 docs/operations.md
- REQ-090 baseline 정합 (runtime-flows·project-structure·interface-contract)
- BAT-045(P0)·BAT-046(P1) release-candidate

REL-007: confirmed → feedback-captured (운영 인스턴스 라이브 검증 + #28/#29 환류
정규화). BAT-037~041 released.

검증: 537 passed, 47 skipped, 회귀 0, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-16 13:19:31 +09:00
co-authored by Claude Opus 4.8
parent e0d4e71304
commit d8d9021260
48 changed files with 2461 additions and 58 deletions
+113 -14
View File
@@ -80,30 +80,32 @@ def _buy_to_pending(intents, qty=10, entry_price=1000, timeout_sec=None, slippag
class TestSlippageDispatch:
def test_buy_slippage_ceil_applied_to_limit(self):
"""buy: effective_limit = ceil(entry_price * (1 + pct/100))."""
def test_buy_slippage_floor_to_tick(self):
"""buy: effective_limit = floor_to_tick(entry * (1 + pct/100)).
dcy-030/REQ-070 CHG: cap-respecting direction (was ceil). entry=1000
is in the <2,000 band (tick 1) so floor_to_tick(1005.0) = 1005.
"""
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
_buy_to_pending(intents, entry_price=1000, slippage_pct=0.5)
broker.responder = lambda side: FillStatus(state="accepted")
execution.run_once()
order = broker.orders[0]
# ceil(1000 * 1.005) = ceil(1005.0) = 1005
assert order.limit_price == 1005
def test_buy_slippage_fractional_ceil(self):
"""ceil(1001 * 1.003) = ceil(1004.003) = 1005."""
def test_buy_slippage_fractional_floors_down(self):
"""floor_to_tick(1001 * 1.003) = floor(1004.003) = 1004 (tick 1)."""
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
_buy_to_pending(intents, entry_price=1001, slippage_pct=0.3)
broker.responder = lambda side: FillStatus(state="accepted")
execution.run_once()
import math
expected = int(math.ceil(1001 * 1.003))
assert broker.orders[0].limit_price == expected
# cap-respecting: floor (not ceil). 1004.003 -> 1004.
assert broker.orders[0].limit_price == 1004
def test_sell_slippage_floor_applied_to_limit(self):
"""sell: effective_limit = floor(limit_price * (1 - pct/100))."""
def test_sell_slippage_ceil_to_tick(self):
"""sell: effective_limit = ceil_to_tick(limit_price * (1 - pct/100))."""
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
# drive to holding first
@@ -122,10 +124,8 @@ class TestSlippageDispatch:
broker.responder = lambda side: FillStatus(state="accepted")
execution.run_once() # dispatch sell (pending state already has limit_price)
sell_orders = [o for o in broker.orders if o.side == "sell"]
# floor(2000 * 0.99) = floor(1980.0) = 1980
import math
expected = int(math.floor(2000 * 0.99))
assert sell_orders[0].limit_price == expected
# ceil_to_tick(2000 * 0.99) = ceil_to_tick(1980.0) = 1980 (tick 5, exact).
assert sell_orders[0].limit_price == 1980
def test_no_slippage_when_slippage_pct_is_none(self):
"""No slippage_pct → effective_limit = entry_price unchanged."""
@@ -158,6 +158,105 @@ class TestSlippageDispatch:
# 0.0 is falsy → slippage block skipped → no change
assert broker.orders[0].limit_price == 1000
def test_issue28_buy_147700_snaps_to_valid_tick(self):
"""issue #28: 147,700 @ 0.5% → floor_to_tick(148438.5) = 148400 (tick 100)."""
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
_buy_to_pending(intents, entry_price=147700, slippage_pct=0.5)
broker.responder = lambda side: FillStatus(state="accepted")
execution.run_once()
assert broker.orders[0].limit_price == 148400
def test_issue28_buy_54500_snaps_to_valid_tick(self):
"""issue #28: 54,500 @ 0.5% → floor_to_tick(54772.5) = 54700 (tick 100)."""
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
_buy_to_pending(intents, entry_price=54500, slippage_pct=0.5)
broker.responder = lambda side: FillStatus(state="accepted")
execution.run_once()
assert broker.orders[0].limit_price == 54700
def test_buy_sub_tick_band_floors_to_entry_tick(self):
"""band < 1 tick: buy effective ≥ entry's valid tick (no under-fill).
entry=147700 (on tick 100), pct=0.01 → raw=147714.77, floor_to_tick=147700;
guard max(147700, ceil_to_tick(147700)=147700) = 147700 ≥ entry, valid tick.
"""
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
_buy_to_pending(intents, entry_price=147700, slippage_pct=0.01)
broker.responder = lambda side: FillStatus(state="accepted")
execution.run_once()
eff = broker.orders[0].limit_price
assert eff == 147700
assert eff % 100 == 0 # valid KRX tick
assert eff <= 147700 * 1.0001 # within slippage ceiling
def test_buy_snap_never_exceeds_slippage_ceiling(self):
"""cap-respecting: buy effective ≤ entry*(1+pct/100), always a valid tick."""
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
_buy_to_pending(intents, entry_price=35000, slippage_pct=0.7)
broker.responder = lambda side: FillStatus(state="accepted")
execution.run_once()
eff = broker.orders[0].limit_price
# 35000*1.007 = 35245 -> tick 50 -> floor_to_tick = 35200
assert eff == 35200
assert eff % 50 == 0
assert eff <= 35000 * 1.007
# ============================================================================
# REQ-085 / REQ-087: order-result logging + reject ERROR visibility (dcy-029)
# ============================================================================
class TestOrderResultLogging:
def test_submit_and_fill_are_logged_with_event_keys(self, caplog):
from trex_core.runtime import logging_setup as ls
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
_buy_to_pending(intents, entry_price=1000, slippage_pct=None)
broker.responder = lambda side: FillStatus(
state="filled", filled_qty=10, avg_price=1000.0,
execution_id="e1", fee=1.5, tax=0.0,
)
with caplog.at_level(logging.INFO):
execution.run_once() # dispatch (submit) + poll (fill)
text = "\n".join(r.getMessage() for r in caplog.records)
assert "event=order_submit" in text
assert "event=order_fill" in text
assert "fee=1.5" in text
# correlation id set to the slot_id (intent->order->fill join key)
sid = broker.orders[0].slot_id
assert ls.get_correlation_id() == sid
def test_reject_is_logged_at_error_level(self, caplog):
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
broker.submit_order = lambda req: OrderAck(
accepted=False, broker_order_id=None, reason="주식주문호가단위 오류",
)
_buy_to_pending(intents, entry_price=1000)
with caplog.at_level(logging.INFO):
execution.run_once()
rejects = [r for r in caplog.records if "event=order_reject" in r.getMessage()]
assert rejects, "reject must be logged"
assert all(r.levelno == logging.ERROR for r in rejects)
assert "호가단위" in rejects[0].getMessage()
def test_normal_fill_emits_no_error(self, caplog):
broker = _RecordingBroker()
repo, svc, intents, execution = _wire(broker)
_buy_to_pending(intents, entry_price=1000)
broker.responder = lambda side: FillStatus(
state="filled", filled_qty=10, avg_price=1000.0,
execution_id="e1", fee=0.0, tax=0.0,
)
with caplog.at_level(logging.INFO):
execution.run_once()
assert not [r for r in caplog.records if r.levelno >= logging.ERROR]
# ============================================================================
# REQ-069: timeout auto-cancel