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
+51 -15
View File
@@ -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)
+123
View File
@@ -0,0 +1,123 @@
"""운영 조회 리포트 — 보유·오늘 체결·실현 PnL·대기 주문 (REQ-088, dcy-029 FR-5).
CLI(`trex-core report`)가 DB에서 읽은 행을 이 순수 함수들에 넘겨 집계한다(테스트
가능). 실현 PnL은 `ledger_outbox`를 단일 권위 출처로 하는 **당일 net 매매 현금흐름**:
realized_pnl = Σ_sell (qty*price - fee - tax) - Σ_buy (qty*price + fee)
ledger_outbox에는 매도별 취득원가(cost basis)가 없으므로, 운영자가 수기로 계산하던
당일 net(매도 대금 − 매수 대금 − 수수료 − 세금)과 동일한 정의를 사용한다(dcy-029
NFR-3, BAT-038 fee/tax 정책 반영). 일중 진입·청산 전략에서는 net ≈ 실현손익.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
_KST = timezone(timedelta(hours=9))
@dataclass(frozen=True)
class Holding:
symbol: str
portfolio_id: str
qty: float
avg_price: float
@dataclass(frozen=True)
class PendingOrder:
symbol: str
portfolio_id: str
status: str # pending_buy | pending_sell
@dataclass(frozen=True)
class TradeRow:
asset_id: str
side: str # buy | sell
qty: float
price: float
fee: float = 0.0
tax: float = 0.0
@dataclass
class OperatorReport:
holdings: list[Holding] = field(default_factory=list)
pending: list[PendingOrder] = field(default_factory=list)
fills_count: int = 0
realized_pnl: float = 0.0
realized_pnl_by_asset: dict[str, float] = field(default_factory=dict)
def kst_day_bounds_utc(now: datetime | None = None) -> tuple[datetime, datetime]:
"""오늘(KST trading_date) 경계를 UTC `[start, end)` 로 반환한다."""
now = now or datetime.now(tz=timezone.utc)
now_kst = now.astimezone(_KST)
start_kst = now_kst.replace(hour=0, minute=0, second=0, microsecond=0)
end_kst = start_kst + timedelta(days=1)
return start_kst.astimezone(timezone.utc), end_kst.astimezone(timezone.utc)
def realized_pnl(trades: list[TradeRow]) -> tuple[float, dict[str, float]]:
"""당일 net 매매 현금흐름(실현 PnL)과 자산별 분해를 계산한다."""
total = 0.0
by_asset: dict[str, float] = {}
for t in trades:
gross = float(t.qty) * float(t.price)
if t.side == "sell":
net = gross - float(t.fee) - float(t.tax)
else: # buy
net = -(gross + float(t.fee))
total += net
by_asset[t.asset_id] = by_asset.get(t.asset_id, 0.0) + net
return total, by_asset
def build_report(
holdings: list[Holding],
pending: list[PendingOrder],
trades: list[TradeRow],
) -> OperatorReport:
total, by_asset = realized_pnl(trades)
return OperatorReport(
holdings=list(holdings),
pending=list(pending),
fills_count=len(trades),
realized_pnl=total,
realized_pnl_by_asset=by_asset,
)
def render_text(rep: OperatorReport) -> str:
"""사람용 표 요약."""
lines: list[str] = []
lines.append(f"=== 현재 보유 ({len(rep.holdings)}) ===")
for h in rep.holdings:
lines.append(f" {h.symbol} pf={h.portfolio_id} qty={h.qty} avg={h.avg_price}")
lines.append(f"=== 대기 주문 ({len(rep.pending)}) ===")
for p in rep.pending:
lines.append(f" {p.symbol} pf={p.portfolio_id} status={p.status}")
lines.append(f"=== 오늘 체결 {rep.fills_count}건 / 실현 PnL {rep.realized_pnl:,.0f} ===")
for asset, pnl in sorted(rep.realized_pnl_by_asset.items()):
lines.append(f" {asset}: {pnl:,.0f}")
return "\n".join(lines)
def render_json(rep: OperatorReport) -> dict:
"""머신 파싱용(`--json`)."""
return {
"holdings": [
{"symbol": h.symbol, "portfolio_id": h.portfolio_id,
"qty": h.qty, "avg_price": h.avg_price}
for h in rep.holdings
],
"pending": [
{"symbol": p.symbol, "portfolio_id": p.portfolio_id, "status": p.status}
for p in rep.pending
],
"fills_count": rep.fills_count,
"realized_pnl": rep.realized_pnl,
"realized_pnl_by_asset": rep.realized_pnl_by_asset,
}
+6 -2
View File
@@ -85,10 +85,14 @@ class SafetySellService:
"""Visibility for an actual safety-sell fire (dcy-017 FR-1, OQ-2)."""
self._count(f"fire:{reason}")
level = logging.WARNING if reason in self._WARN_REASONS else logging.INFO
# REQ-086 (dcy-029): expose reason AND actor together so an operator can
# tell a safety-net autonomous liquidation (actor=trex-safety-sell) apart
# from a strategy's own stop-loss. No new reason values / fields.
_log.log(
level,
"safety-sell fired: phase=%s slot=%s portfolio_id=%s reason=%s instance_key=%s",
reason, slot.slot_id, slot.portfolio_id, reason, self._instance_key,
"safety-sell fired: phase=%s slot=%s portfolio_id=%s reason=%s "
"actor=%s instance_key=%s",
reason, slot.slot_id, slot.portfolio_id, reason, _ACTOR, self._instance_key,
)
def _update_ownership(self, now: datetime) -> None: