fix: preserve SOR execution venue separately

This commit is contained in:
Hermes Agent
2026-08-19 12:48:05 +09:00
parent 15aeae6930
commit 7366cadf03
15 changed files with 126 additions and 17 deletions
+1 -1
View File
@@ -49,7 +49,7 @@
- limit_price :: numeric? :: 현재 pending 주문 지정가(side 무관, dcy-012/REQ-002 — 과거 `entry_price`에서 rename). pending 진입(매수/매도 지정가) 시 set, pending 이탈(→holding/→closed) 시 None 으로 clear → 매수 지정가가 매도로 누수되지 않음(issue #10). None 이면 dispatch 시 시장가.
- allocated_capital :: numeric? :: 배정 자본
- order_id / broker_order_id :: str? :: 주문 식별자(자유 주석 / 브로커 핸들)
- venue :: TEXT NOT NULL DEFAULT 'KRX' :: 현재 주문 거래소 구분(KRX/NXT/SOR). venue 누락 intent는 SOR로 저장하고, 명시 venue는 보존한다. pending 주문·취소까지 보존하며 HOLDING 진입 시 다음 주문의 SOR, INACTIVE 진입 시 KRX로 초기화한다.
- venue :: TEXT NOT NULL DEFAULT 'KRX' :: 원주문/라우팅 거래소(KRX/NXT/SOR). venue 누락 intent는 SOR로 저장하고, pending 주문·재시작·취소까지 보존한다. 실제 KRX/NXT 체결 거래소는 `FillStatus.execution_venue`에서 별도로 확정해 `ledger_outbox.venue`에 기록하고 원주문은 `ledger_outbox.order_venue`에 보존한다.
- cancel_state :: TEXT? :: 취소 요청의 즉시 결과(requested/rejected/unknown). 최종 취소 상태가 아니다. requested는 재시도하지 않고, rejected/unknown만 authoritative open-row 확인과 bounded backoff 뒤 재시도할 수 있다.
- cancel_requested_at :: TIMESTAMPTZ? :: 마지막 취소 시도 예약 시각. 주문 종료·dispatch clear 때 함께 제거한다.
- order_submitted_at :: TIMESTAMPTZ? :: timeout deadline의 immutable 주문 제출 기준점. poll/cancel bookkeeping 및 daemon 재시작에서 보존하고 terminal dispatch clear 때 제거한다.
+3 -3
View File
@@ -396,7 +396,7 @@ resume_kill_switch 요청·응답 (REQ-046):
### Persistence
- PostgreSQL: `ledger_outbox(correlation_id PK, asset_id, order_id, execution_id, side, qty, price, fee, tax, executed_at, venue, status, attempts, last_outcome, last_error, created_at, updated_at)` — `ON CONFLICT (correlation_id) DO NOTHING` 이 outbox dedup 게이트. `venue`는 Core 내부 실제 제출 거래소이며 Portfolio Hub wire에는 아직 보내지 않는다.
- PostgreSQL: `ledger_outbox(..., venue, order_venue, ...)` — `ON CONFLICT (correlation_id) DO NOTHING` 이 outbox dedup 게이트. `order_venue`는 원주문 KRX/NXT/SOR, `venue`는 broker fill에서 확인한 실제 KRX/NXT 체결 거래소다. Portfolio Hub wire에는 아직 보내지 않는다.
## `OrderRequest` DTO (BAT-004 / REQ-011, BAT-056 / REQ-117)
@@ -417,7 +417,7 @@ resume_kill_switch 요청·응답 (REQ-046):
- `venue` 필드는 BAT-056에서 additive 추가. 기존 orb/tsl 미지정 호출 → 기본값 `"KRX"` 자동 적용 → 하위 호환.
- `OrderRequest`는 AMQP wire 위를 직접 흐르지 않는다. buy/sell intent의 선택적 `venue` 누락은 `SOR`이고, 명시한 KRX/NXT/SOR는 그대로 슬롯에 보존한다. `AUTO`와 그 밖의 값은 `invalid_venue`로 admission 전 거부한다.
- KIS 어댑터는 KRX/NXT/SOR를 시간과 무관하게 보존해 `EXCG_ID_DVSN_CD`로 전송한다.
- `OrderAck.venue`의 실제 제출 거래소는 `broker_order_id`와 함께 슬롯에 원자 저장되며 재시작·취소·Core 원장 outbox에서 재사용한다.
- `OrderAck.venue`는 원주문 KRX/NXT/SOR를 `broker_order_id`와 함께 슬롯에 원자 저장하며 재시작·취소에 재사용한다. 실제 체결 거래소는 `FillStatus.execution_venue`의 KRX/NXT로 별도 전달해 outbox `venue`에 기록한다. SOR 또는 미지원 코드는 실제 거래소로 추론하지 않는다.
## `BrokerPort` Protocol 메서드 (BAT-056 / REQ-118)
@@ -634,7 +634,7 @@ trex-core가 `source="trex-core"`, `severity="critical"`로 발행하는 초기
- 2026-06-05 :: BAT-007 — HTTP `/v1` API 인터페이스 제거(adapters/api + fastapi/uvicorn 의존 정리), RabbitMQ messaging 계약 추가(스냅샷 RPC `trex.snapshot.req`, intent RPC `trex.intent.req`, 이벤트 topic `trex.events`, 전략별 durable queue + TTL/max-length, DLQ, publisher confirms, ack-after-commit), `MessagingTransport` Protocol + `TransportHealth` 노출 (REQ-021~026).
- 2026-06-06 :: BAT-008 — `trex.snapshot.req` envelope 계약 정의(`assets[]/view/available_slots/degraded/reason/message`, no price field), `trex.pause.req` 제어 RPC 추가, `PortfolioHubReadPort`/`SnapshotStore`/`PauseStore`/`SnapshotScheduler` 인터페이스, `BrokerPort.get_balance()` 확장, `trex-core` CLI 추가 (REQ-027~033; REQ-030 옵션 D 는 portfolio-hub#8 배포 후).
- 2026-06-10 :: BAT-030 (dcy-020 마감) — buy intent 계약 키 `slot_id` 정본(+`portfolio_id` dual-accept, `trading_date` 제거), 거부 어휘(`unknown_slot`/`already_active`/`capacity_full`/`slot_id_or_portfolio_required`), `slots[]` 5-state + `universe_detached`(trading_date 키 제거), 이벤트 payload trading_date 제거, `available_slots`=admission headroom, SlotService/SlotRepository 자산 중심 계약, 매도 체결 `pending_sell→inactive` (REQ-056~065).
- 2026-06-15 :: BAT-043 (issue #27, REQ-007 CHG-20260615-01) — rejected SellIntent 응답에 additive `retryable: bool`(core 소유 reason→retryable 매핑, 미인식 기본 true, `bad_request:*`/terminal false). 손절매 재시도 안전 경로 decoupling. 완전 additive(admitted/processing/buy·기존 필드 불변).
- 2026-06-15 :: BAT-043 (issue #27, REQ-007 CHG-20260615-01) — rejected SellIntent 응답에 additive `retryable: bool`을 도입했으나, GitHub #60 승인 계약으로 폐기됐다. 현재 strategy RPC는 retryability·broker attempt·KIS failure를 전달하지 않는다.
- 2026-06-15 :: BAT-042 (dcy-028) — slot strategy-state 계약: 신규 write RPC `trex.slots.strategy_state.set`(occupancy_seq+version CAS, reject reason 집합 `occupancy_changed`/`version_conflict`/`slot_inactive`/`state_too_large`/`bad_request`), `trex.slots.req` 행에 additive `occupancy_seq`+`strategy_state` 맵(REQ-073 AC-7/REQ-074 AC-6, opaque UTF-8+size cap, strategy_* reasoning 필드와 구분). close(`→INACTIVE`) 시 state 자동 삭제 (REQ-079~082).
- 2026-06-11 :: BAT-035 (dcy-025) — `trex.snapshot.req` → `trex.slots.req` lockstep 교체: 요청 `{filter, spy}`, 응답 `{slots[], degraded, reason, message}`. 슬롯 row 에 hub `asset_quantity`/`cash_balance` + `warned`/`cash_estimated`/`watchdog`/`paused` 병합(REQ-073/074). `trex.snapshot.req` envelope(`assets[]`/`available_slots`/view enum) 제거(REQ-028 Deprecated). strategy 필드 제거(REQ-074 AC-5). CLI `universe`/`slots` → `trex.slots.req`(spy=true). `trex.direct` channel_type `snapshot` → `slots`.
- 2026-06-11 :: BAT-036 (dcy-024) — `safety_sell_enabled: bool=True` per-instance TOML 플래그 추가(REQ-048 CHG-01). `false` 시 `trex-safety-sell` worker 미기동 + WARNING 로그(REQ-005 CHG-01).
+38
View File
@@ -137,6 +137,44 @@ def test_submit_sor_preserves_sor(monkeypatch):
assert captured["body"]["EXCG_ID_DVSN_CD"] == "SOR"
@pytest.mark.parametrize(
("row", "expected"),
[
({"ord_exg_gb": "1"}, "KRX"),
({"ord_exg_gb": "2"}, "NXT"),
({"ord_exg_gb": "3"}, "KRX"),
({"ord_exg_gb": "4"}, "NXT"),
({"excg_id_dvsn_cd": "KRX"}, "KRX"),
({"excg_id_dvsn_cd": "NXT"}, "NXT"),
({"excg_id_dvsn_cd": "SOR-KRX"}, "KRX"),
({"excg_id_dvsn_Cd": "SOR-NXT"}, "NXT"),
({"excg_id_dvsn_cd": "SOR"}, None),
({"excg_dvsn_cd": "02"}, None),
({}, None),
],
)
def test_kis_execution_venue_parser_is_allowlisted(row, expected):
assert KISBroker._execution_venue_from_row(row) == expected
def test_kis_fill_status_carries_sor_actual_execution_venue():
status = KISBroker._fill_status_from_row(
{
"ord_qty": "10",
"tot_ccld_qty": "10",
"avg_prvs": "70000",
"ord_dt": "20260819",
"ord_gno_brno": "12345",
"odno": "0000000001",
"excg_id_dvsn_cd": "NXT",
},
side="buy",
today="20260819",
fallback_odno="0000000001",
)
assert status.execution_venue == "NXT"
# ---------------------------------------------------------------------------
# REQ-117 AC-4: 기존 위치 인수 순서 불변
# ---------------------------------------------------------------------------
@@ -20,7 +20,8 @@ def _trade(execution_id="paper:1:10") -> LedgerTrade:
return LedgerTrade(
asset_id="PF-1", order_id="ORD-1", execution_id=execution_id,
side="buy", qty=10, price=1000.0, fee=1.5, tax=0.0,
executed_at=datetime(2026, 6, 4, tzinfo=timezone.utc), venue="NXT",
executed_at=datetime(2026, 6, 4, tzinfo=timezone.utc),
venue="NXT", order_venue="SOR",
)
@@ -33,6 +34,7 @@ def test_enqueue_persists_and_is_idempotent(pg_clean):
assert len(pending) == 1
assert pending[0].status is WorkItemStatus.PENDING
assert pending[0].trade.venue == "NXT"
assert pending[0].trade.order_venue == "SOR"
def test_writer_completes_persisted_item(pg_clean):
+2 -1
View File
@@ -116,7 +116,8 @@ def test_partial_fill_then_cancel_records_and_keeps_remainder():
broker.balance = {"005930": 6}
broker.responder = lambda side: FillStatus(
state="partially_filled", filled_qty=4, avg_price=1100.0,
execution_id="exec-partial", fee=0.5, tax=0.0, cancelled=True,
execution_id="exec-partial", fee=0.5, tax=0.0,
execution_venue="KRX", cancelled=True,
)
r = execution.run_once()
slot = repo.fetch_slot(sid)
@@ -148,6 +148,7 @@ def _make_filled_status(
execution_id=execution_id,
fee=fee,
tax=tax,
execution_venue="KRX",
)
@@ -167,6 +168,7 @@ def _make_partial_cancel_status(
execution_id=execution_id,
fee=fee,
tax=tax,
execution_venue="KRX",
cancelled=True,
)
+2 -1
View File
@@ -59,7 +59,8 @@ def test_buy_fill_enqueues_one_ledger_item_and_writer_records_it():
assert (t.asset_id, t.side, t.qty, t.price) == ("PF-1", "buy", 10, 1000.0)
assert t.execution_id.startswith("paper:PAPER-")
assert t.fee > 0.0 and t.tax == 0.0
assert t.venue == "SOR"
assert t.order_venue == "SOR"
assert t.venue == "KRX" # paper's deterministic SOR execution exchange
def test_sell_fill_enqueues_sell_ledger_item():
+5 -3
View File
@@ -33,7 +33,7 @@ class RecordingBroker:
return OrderAck(
accepted=True,
broker_order_id=f"order-{len(self.orders)}",
venue=self.actual_venue or req.venue,
venue=req.venue,
)
def poll_fill(self, broker_order_id: str, *, side: str, symbol: str) -> FillStatus:
@@ -121,7 +121,7 @@ def test_invalid_venue_is_rejected_before_slot_transition(invalid_venue: str) ->
assert repo.list_slots("default")[0].status is SlotState.INACTIVE
def test_omitted_wire_venue_is_sor_then_actual_venue_is_persisted() -> None:
def test_omitted_wire_venue_remains_sor_for_cancel_and_restart() -> None:
repo = InMemorySlotRepository()
service = SlotService(repo)
intents = IntentService(
@@ -153,7 +153,9 @@ def test_omitted_wire_venue_is_sor_then_actual_venue_is_persisted() -> None:
execution.run_once()
dispatched = repo.fetch_slot(slot_id)
assert broker.orders[0].venue == "SOR"
assert dispatched is not None and dispatched.venue == "NXT"
# Ack cannot turn SOR routing provenance into an execution venue. Actual
# KRX/NXT is established by fill polling and stored on the ledger trade.
assert dispatched is not None and dispatched.venue == "SOR"
def test_inactive_transition_resets_venue_to_krx() -> None:
+25
View File
@@ -573,6 +573,27 @@ class KISBroker:
delta = (ordered_at - submitted_at).total_seconds()
return -5.0 <= delta <= 120.0
@staticmethod
def _execution_venue_from_row(row: dict) -> str | None:
"""Parse broker-authoritative KRX/NXT without time inference."""
notice_code = str(row.get("ord_exg_gb") or row.get("ORD_EXG_GB") or "").strip()
notice_map = {"1": "KRX", "2": "NXT", "3": "KRX", "4": "NXT"}
if notice_code in notice_map:
return notice_map[notice_code]
for key in (
"excg_id_dvsn_cd",
"excg_id_dvsn_Cd",
"EXCG_ID_DVSN_CD",
"execution_venue",
):
value = str(row.get(key) or "").strip().upper()
if value in {"KRX", "SOR-KRX"}:
return "KRX"
if value in {"NXT", "SOR-NXT"}:
return "NXT"
# SOR is routing provenance, not an execution exchange.
return None
@staticmethod
def _fill_status_from_row(
row: dict,
@@ -581,6 +602,7 @@ class KISBroker:
today: str,
fallback_odno: str,
) -> FillStatus:
execution_venue = KISBroker._execution_venue_from_row(row)
ord_qty = _safe_int(row.get("ord_qty")) or 0
ccld_qty = _safe_int(row.get("tot_ccld_qty")) or 0
rejected_qty = _safe_int(row.get("rjct_qty")) or 0
@@ -623,6 +645,7 @@ class KISBroker:
execution_id=execution_id,
fee=fee,
tax=tax,
execution_venue=execution_venue,
raw=row,
)
# 0 < ccld_qty < ord_qty
@@ -646,6 +669,7 @@ class KISBroker:
execution_id=execution_id,
fee=fee,
tax=tax,
execution_venue=execution_venue,
cancelled=True,
raw=row,
)
@@ -653,6 +677,7 @@ class KISBroker:
state="partially_filled",
filled_qty=ccld_qty,
avg_price=float(avg) if avg else None,
execution_venue=execution_venue,
raw=row,
)
+5
View File
@@ -31,6 +31,9 @@ class _Order:
self.side = req.side
self.symbol = req.symbol
self.qty = req.qty
# Deterministic SOR simulation: paper routes SOR to KRX while retaining
# req.venue on the acknowledgement as submission provenance.
self.execution_venue = "KRX" if req.venue == "SOR" else req.venue
self.price = float(req.limit_price) if req.limit_price is not None else 1000.0
self.polls = 0
self.filled = 0
@@ -118,11 +121,13 @@ class PaperBroker:
return FillStatus(
state="filled", filled_qty=o.filled, avg_price=o.price,
execution_id=execution_id, fee=fee, tax=tax,
execution_venue=o.execution_venue,
)
return FillStatus(
state="partially_filled",
filled_qty=o.filled,
avg_price=o.price,
execution_venue=o.execution_venue,
raw={"order_open_confirmed": True},
)
+5 -3
View File
@@ -548,7 +548,7 @@ class _PgIntentRecord:
_OUTBOX_COLUMNS = (
"correlation_id, asset_id, order_id, execution_id, side, qty, price, "
"fee, tax, executed_at, status, attempts, last_outcome, last_error, "
"created_at, updated_at, next_retry_at, venue"
"created_at, updated_at, next_retry_at, venue, order_venue"
)
@@ -565,8 +565,8 @@ class PostgreSQLLedgerOutbox:
cur.execute(
"INSERT INTO ledger_outbox "
"(correlation_id, asset_id, order_id, execution_id, side, "
" qty, price, fee, tax, executed_at, venue) "
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) "
" qty, price, fee, tax, executed_at, venue, order_venue) "
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) "
"ON CONFLICT (correlation_id) DO NOTHING",
(
trade.correlation_id,
@@ -580,6 +580,7 @@ class PostgreSQLLedgerOutbox:
trade.tax,
trade.executed_at,
trade.venue,
trade.order_venue,
),
)
conn.commit()
@@ -684,6 +685,7 @@ class PostgreSQLLedgerOutbox:
tax=f(row[8]),
executed_at=row[9],
venue=row[17],
order_venue=row[18],
)
return LedgerWorkItem(
correlation_id=row[0],
@@ -0,0 +1,12 @@
-- GitHub #61: keep the original submission venue separate from actual execution venue.
ALTER TABLE ledger_outbox
ADD COLUMN IF NOT EXISTS order_venue TEXT NOT NULL DEFAULT 'KRX';
ALTER TABLE ledger_outbox
DROP CONSTRAINT IF EXISTS ledger_outbox_order_venue_check;
ALTER TABLE ledger_outbox
ADD CONSTRAINT ledger_outbox_order_venue_check
CHECK (order_venue IN ('KRX', 'NXT', 'SOR'));
-- Historical rows used venue for both concepts. Preserve that provenance.
UPDATE ledger_outbox SET order_venue = venue WHERE order_venue = 'KRX' AND venue <> 'KRX';
+4 -1
View File
@@ -104,7 +104,10 @@ class FillStatus:
execution_id: Optional[str] = None
fee: float = 0.0
tax: float = 0.0
# dcy-013 / REQ-012 (issue #9): an external cancel was detected. When the
# Broker-authoritative execution exchange. For SOR orders this is KRX or
# NXT; None means the polling surface did not establish it.
execution_venue: Optional[str] = None
# dcy-013 / REQ-012: an external cancel was detected.
# order had a partial fill before the cancel, state stays "partially_filled"
# and this flag is set so execution records the filled portion and returns
# the remainder to holding (is_partial_cancel). A zero-fill external cancel
+16 -1
View File
@@ -773,6 +773,20 @@ class ExecutionService:
if resolved_price is not None
else (float(fs.avg_price) if fs.avg_price is not None else 0.0)
)
execution_venue = fs.execution_venue
if execution_venue is None and slot.venue in {"KRX", "NXT"}:
execution_venue = slot.venue
if execution_venue not in {"KRX", "NXT"}:
r.ledger_skipped += 1
_log.warning(
"ledger skip: slot=%s portfolio_id=%s side=%s "
"reason=missing_execution_venue order_venue=%s",
slot.slot_id,
slot.portfolio_id,
side,
slot.venue,
)
return
trade = LedgerTrade(
asset_id=str(slot.portfolio_id),
order_id=str(broker_order_id),
@@ -783,7 +797,8 @@ class ExecutionService:
fee=float(fs.fee),
tax=float(fs.tax),
executed_at=datetime.now(tz=timezone.utc),
venue=slot.venue,
venue=execution_venue,
order_venue=slot.venue,
)
self._ledger_outbox.enqueue(trade)
r.ledger_enqueued += 1
+3 -2
View File
@@ -57,9 +57,10 @@ class LedgerTrade:
fee: float = 0.0
tax: float = 0.0
executed_at: Optional[datetime] = None
# Actual order venue persisted in the Core outbox. Portfolio Hub remains
# unchanged until its API contract explicitly accepts this additive field.
# Broker-authoritative execution exchange (KRX/NXT).
venue: str = "KRX"
# Original submission/routing venue (KRX/NXT/SOR), retained for audit.
order_venue: str = "KRX"
@property
def correlation_id(self) -> str: