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:
@@ -23,7 +23,6 @@ single producer for ledger work items (NFR-3, single record subject).
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Callable, Optional
|
||||
@@ -31,7 +30,9 @@ from typing import Callable, Optional
|
||||
from trex_core.application.broker_port import BrokerPort, OrderRequest
|
||||
from trex_core.application.ledger_outbox import LedgerOutbox
|
||||
from trex_core.application.ledger_port import LedgerTrade
|
||||
from trex_core.domain.pricing import ceil_to_tick, floor_to_tick
|
||||
from trex_core.domain.slot import SlotService, SlotState, SlotStateMismatchError
|
||||
from trex_core.runtime.logging_setup import set_correlation_id
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
@@ -137,24 +138,30 @@ class ExecutionService:
|
||||
# limit_price is cleared on entering HOLDING, so a market sell
|
||||
# carries None -> order_type "01".
|
||||
# dcy-022 / REQ-070: slippage_pct adjusts the effective limit price
|
||||
# when entry_price is set. buy: ceil(limit*(1+pct/100)); sell: floor.
|
||||
# No slippage when limit is None (market order — no reference price).
|
||||
# when entry_price is set. No slippage when limit is None (market
|
||||
# order — no reference price).
|
||||
# dcy-030 / REQ-070 CHG-20260616-01: snap the slippage limit to a
|
||||
# valid KRX tick (REQ-083). Direction = cap-respecting: buy floors
|
||||
# to tick (never exceeds the slippage ceiling), sell ceils to tick
|
||||
# (never breaches the floor). A band<1-tick guard keeps the order
|
||||
# fillable at the entry's own valid tick (no under-fill).
|
||||
limit = int(slot.limit_price) if slot.limit_price else None
|
||||
if limit is not None and slot.slippage_pct:
|
||||
factor = (
|
||||
1.0 + slot.slippage_pct / 100.0
|
||||
if side == "buy"
|
||||
else 1.0 - slot.slippage_pct / 100.0
|
||||
)
|
||||
limit = (
|
||||
int(math.ceil(limit * factor))
|
||||
if side == "buy"
|
||||
else int(math.floor(limit * factor))
|
||||
)
|
||||
base = float(slot.limit_price)
|
||||
if side == "buy":
|
||||
raw = base * (1.0 + slot.slippage_pct / 100.0)
|
||||
limit = max(floor_to_tick(raw), ceil_to_tick(base))
|
||||
else:
|
||||
raw = base * (1.0 - slot.slippage_pct / 100.0)
|
||||
limit = min(ceil_to_tick(raw), floor_to_tick(base))
|
||||
_log.debug(
|
||||
"slippage applied: slot=%s side=%s base_limit=%s pct=%.4f effective=%s",
|
||||
slot.slot_id, side, slot.limit_price, slot.slippage_pct, limit,
|
||||
"slippage applied: slot=%s side=%s base_limit=%s pct=%.4f "
|
||||
"raw=%.2f effective=%s (tick-snapped)",
|
||||
slot.slot_id, side, slot.limit_price, slot.slippage_pct, raw, limit,
|
||||
)
|
||||
# REQ-084/085: correlation id (intent→order→fill join key = slot_id)
|
||||
# so submit/fill/reject logs for one slot share a key.
|
||||
set_correlation_id(str(slot.slot_id))
|
||||
ack = self._broker.submit_order(
|
||||
OrderRequest(
|
||||
slot_id=str(slot.slot_id),
|
||||
@@ -168,7 +175,21 @@ class ExecutionService:
|
||||
if ack.accepted and ack.broker_order_id:
|
||||
self._repo.mark_dispatched(slot.slot_id, ack.broker_order_id)
|
||||
r.dispatched += 1
|
||||
# REQ-085: order-result logging — tailable submit line.
|
||||
_log.info(
|
||||
"order submitted event=order_submit side=%s symbol=%s slot_id=%s "
|
||||
"qty=%s limit_price=%s order_type=%s",
|
||||
side, slot.symbol, slot.slot_id, qty, limit,
|
||||
"00" if limit is not None else "01",
|
||||
)
|
||||
else:
|
||||
# REQ-085/087: reject is visible (event key) and ERROR level
|
||||
# (no more silent rollback — issue #28 surfaced too late).
|
||||
_log.error(
|
||||
"order rejected event=order_reject side=%s symbol=%s slot_id=%s "
|
||||
"qty=%s reason=%s",
|
||||
side, slot.symbol, slot.slot_id, qty, ack.reason or "order_rejected",
|
||||
)
|
||||
self._rollback(slot, reason=ack.reason or "order_rejected")
|
||||
r.rolled_back += 1
|
||||
|
||||
@@ -176,6 +197,7 @@ class ExecutionService:
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
for slot in self._repo.list_dispatched_pending(self._instance_key):
|
||||
side = "buy" if slot.status is SlotState.PENDING_BUY else "sell"
|
||||
set_correlation_id(str(slot.slot_id)) # REQ-084: same key as submit
|
||||
fs = self._broker.poll_fill(slot.broker_order_id, side=side, symbol=slot.symbol or "")
|
||||
if fs.is_filled:
|
||||
self._on_filled(slot, fs, side, r)
|
||||
@@ -186,6 +208,12 @@ class ExecutionService:
|
||||
r.filled += 1
|
||||
elif fs.is_terminal_reject:
|
||||
# rejected or full external cancel (dcy-013 FR-1) -> rollback.
|
||||
# REQ-085/087: surface the reject (event key + ERROR level).
|
||||
_log.error(
|
||||
"order rejected event=order_reject side=%s symbol=%s slot_id=%s "
|
||||
"reason=order_%s",
|
||||
side, slot.symbol, slot.slot_id, fs.state,
|
||||
)
|
||||
self._rollback(slot, reason=f"order_{fs.state}")
|
||||
r.rolled_back += 1
|
||||
else:
|
||||
@@ -240,6 +268,14 @@ class ExecutionService:
|
||||
event_type = "slot_filled_sell"
|
||||
except SlotStateMismatchError:
|
||||
return # slot moved concurrently; next tick reconciles
|
||||
# REQ-085: order-result logging — tailable fill line (price/qty/fee/tax).
|
||||
_log.info(
|
||||
"order filled event=order_fill side=%s symbol=%s slot_id=%s "
|
||||
"fill_price=%s filled_qty=%s fee=%s tax=%s",
|
||||
side, slot.symbol, slot.slot_id,
|
||||
fs.avg_price, fs.filled_qty,
|
||||
getattr(fs, "fee", None), getattr(fs, "tax", None),
|
||||
)
|
||||
# REQ-018: enqueue ledger work item AFTER the slot transition is durable.
|
||||
# Slot transition is the critical path; ledger write is async (REQ-019).
|
||||
self._record_ledger(slot, fs, side, broker_order_id, r)
|
||||
|
||||
Reference in New Issue
Block a user