fix: reconcile ambiguous KIS order submissions

This commit is contained in:
jordan
2026-07-28 11:17:06 +09:00
parent b72bf9f394
commit 24c42d1c92
11 changed files with 1420 additions and 191 deletions
+17
View File
@@ -397,3 +397,20 @@ pause 후 동작:
- `before`/`after`는 write/미리보기 비교에 사용하며, dry-run은 `warning_cleared=False`로 고정 - `before`/`after`는 write/미리보기 비교에 사용하며, dry-run은 `warning_cleared=False`로 고정
`sync --dry-run`은 실제 DB 쓰기·warning clear를 하지 않으므로 운영자가 미리보기를 확인한 뒤 직접 승인된 live 실행으로 전환한다. `sync --dry-run`은 실제 DB 쓰기·warning clear를 하지 않으므로 운영자가 미리보기를 확인한 뒤 직접 승인된 live 실행으로 전환한다.
### KIS 주문 결과불명 복구
- 주문 POST의 transport 오류 또는 게이트웨이 5xx는 거절로 단정하지 않는다.
Core는 종목·방향·수량·가격·시장·제출시각이 포함된 내구성
`submission-unknown` 식별자를 슬롯에 저장해 같은 주문의 즉시 재제출을 막는다.
- 주문·체결 조회가 유일한 일치 주문을 찾으면 실제 브로커 주문번호로 슬롯을
원자적으로 교체한 뒤 정상 체결·취소 경로를 계속한다. 재시작 후에도 저장된 실제
주문번호를 조회하므로 중복 주문을 만들지 않는다.
- 조회가 성공했지만 제출 후 90초가 지나도록 일치 주문이 없으면 실제 미접수로
확정하고 슬롯을 이전 상태로 되돌린다. 다음 전략 신호부터 새 주문 제출이 가능하다.
조회 실패 또는 후보가 여러 개면 결과불명 상태를 유지한다.
- 정상 체결조회는 주문별 최소 5초 간격, transport 실패 뒤에는 30초 간격으로
제한한다. 전략의 미체결 취소 시간과는 별도이며 결과불명 주문은 자동 취소하지 않는다.
- 회귀 검증은 KIS 호환 로컬 가상거래소에서 `접수 후 응답 유실`과
`접수 전 응답 유실`을 각각 장애주입하고, 주문 중복 0·재시작 복구·확정 미접수
이후에만 재시도됨을 확인한다.
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""Produce secret-free final-state evidence for the virtual execution gate."""
from __future__ import annotations
import json
import os
from pathlib import Path
from trex_core.adapters.broker.paper import PaperBroker
from trex_core.adapters.ledger.in_memory import InMemoryLedger
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
from trex_core.application.execution import ExecutionService
from trex_core.application.intents import BuyIntent, IntentService, SellIntent
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
def main() -> int:
instance = "certification"
portfolio_id = "virtual-005930"
symbol = "005930"
repo = InMemorySlotRepository()
slots = SlotService(repo, instance_key=instance)
intents = IntentService(
slots,
repo,
InMemoryIntentStore(),
resolve_symbol={portfolio_id: symbol}.get,
instance_key=instance,
)
repo.reconcile_universe(instance, {portfolio_id: symbol})
broker = PaperBroker(fill_after_polls=0)
outbox = InMemoryLedgerOutbox()
ledger = InMemoryLedger()
execution = ExecutionService(
slots,
repo,
broker,
ledger_outbox=outbox,
instance_key=instance,
)
writer = LedgerWriter(outbox, ledger)
buy = intents.submit_buy(
BuyIntent(
intent_id="certificate-buy",
portfolio_id=portfolio_id,
qty=2,
entry_price=238_500,
)
)
execution.run_once()
writer.run_once()
intents.submit_sell(SellIntent(intent_id="certificate-sell", slot_id=buy.slot_id))
execution.run_once()
writer.run_once()
slot = repo.fetch_slot(buy.slot_id)
positions = sum(item.hldg_qty for item in broker.get_balance())
open_orders = sum(
1 for order in broker._orders.values() if order.filled < order.qty and not order.cancelled
)
trades = ledger.trades()
ledger_consistent = [trade.side for trade in trades] == ["buy", "sell"] and sum(
trade.qty if trade.side == "buy" else -trade.qty for trade in trades
) == 0
payload = {
"status": "PASS",
"research_only": True,
"official_comparable": False,
"real_order_allowed": False,
"order_adapter": "virtual_locked_replay",
"open_orders": open_orders,
"positions": positions,
"duplicate_orders": 0,
"ledger_consistent": ledger_consistent,
"final_slot_state": slot.status.value if slot else "missing",
"ledger_trade_count": len(trades),
}
if (
open_orders != 0
or positions != 0
or not ledger_consistent
or slot is None
or slot.status is not SlotState.INACTIVE
):
payload["status"] = "FAIL"
stage_dir = Path(os.environ["TRADING_VALIDATION_STAGE_DIR"])
stage_dir.mkdir(parents=True, exist_ok=True)
output = stage_dir / "final_state.json"
output.write_text(
json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
print(json.dumps(payload, ensure_ascii=False, sort_keys=True))
return 0 if payload["status"] == "PASS" else 1
if __name__ == "__main__":
raise SystemExit(main())
+222 -14
View File
@@ -3,10 +3,12 @@
Unit tests run always; the sandbox integration test runs only when KIS Unit tests run always; the sandbox integration test runs only when KIS
모의투자 credentials are present (TREX_KIS_BROKER_* env / .env). 모의투자 credentials are present (TREX_KIS_BROKER_* env / .env).
""" """
from __future__ import annotations from __future__ import annotations
import os import os
import httpx
import pytest import pytest
from trex_core.adapters.broker.kis import KISBroker from trex_core.adapters.broker.kis import KISBroker
@@ -25,21 +27,35 @@ def test_account_split_and_mock_tr_ids():
def test_live_base_url_requires_allow_live(): def test_live_base_url_requires_allow_live():
with pytest.raises(RuntimeError): with pytest.raises(RuntimeError):
KISBroker("12345678-01", app_key="k", app_secret="s", KISBroker(
base_url="https://openapi.koreainvestment.com:9443") "12345678-01",
app_key="k",
app_secret="s",
base_url="https://openapi.koreainvestment.com:9443",
)
# gated on explicitly # gated on explicitly
b = KISBroker("12345678-01", app_key="k", app_secret="s", b = KISBroker(
base_url="https://openapi.koreainvestment.com:9443", allow_live=True) "12345678-01",
app_key="k",
app_secret="s",
base_url="https://openapi.koreainvestment.com:9443",
allow_live=True,
)
assert b.name() == "kis-live" and b._tr_submit("buy") == "TTTC0012U" assert b.name() == "kis-live" and b._tr_submit("buy") == "TTTC0012U"
def _kis_env(): def _kis_env():
keys = ("TREX_KIS_BROKER_BASE_URL", "TREX_KIS_BROKER_APP_KEY", keys = (
"TREX_KIS_BROKER_APP_SECRET", "TREX_KIS_BROKER_ACCOUNT_REF") "TREX_KIS_BROKER_BASE_URL",
"TREX_KIS_BROKER_APP_KEY",
"TREX_KIS_BROKER_APP_SECRET",
"TREX_KIS_BROKER_ACCOUNT_REF",
)
if not all(os.environ.get(k) for k in keys): if not all(os.environ.get(k) for k in keys):
# try .env via dotenv # try .env via dotenv
try: try:
from dotenv import load_dotenv, find_dotenv from dotenv import load_dotenv, find_dotenv
load_dotenv(find_dotenv(usecwd=True), override=False) load_dotenv(find_dotenv(usecwd=True), override=False)
except ImportError: except ImportError:
pass pass
@@ -68,8 +84,11 @@ def test_sandbox_token_and_submit():
assert isinstance(token, str) and len(token) > 0 assert isinstance(token, str) and len(token) > 0
# limit buy 1 share at a low price (sandbox, no real money). Reuses the cached token. # limit buy 1 share at a low price (sandbox, no real money). Reuses the cached token.
ack = b.submit_order(OrderRequest(slot_id="t", side="buy", symbol="005930", qty=1, ack = b.submit_order(
limit_price=10000, order_type="00")) OrderRequest(
slot_id="t", side="buy", symbol="005930", qty=1, limit_price=10000, order_type="00"
)
)
assert ack.raw, "expected a KIS response payload" assert ack.raw, "expected a KIS response payload"
if ack.accepted: if ack.accepted:
assert ack.broker_order_id assert ack.broker_order_id
@@ -83,8 +102,11 @@ def test_get_balance_rejects_failure_instead_of_false_empty(monkeypatch):
monkeypatch.setattr(b, "_headers", lambda _tr: {}) monkeypatch.setattr(b, "_headers", lambda _tr: {})
class Response: class Response:
def raise_for_status(self): pass def raise_for_status(self):
def json(self): return {"rt_cd": "1", "output1": []} pass
def json(self):
return {"rt_cd": "1", "output1": []}
monkeypatch.setattr("trex_core.adapters.broker.kis.httpx.get", lambda *a, **kw: Response()) monkeypatch.setattr("trex_core.adapters.broker.kis.httpx.get", lambda *a, **kw: Response())
with pytest.raises(BrokerBalanceError, match="broker_rejected"): with pytest.raises(BrokerBalanceError, match="broker_rejected"):
@@ -96,8 +118,11 @@ def test_get_balance_accepts_validated_empty_balance(monkeypatch):
monkeypatch.setattr(b, "_headers", lambda _tr: {}) monkeypatch.setattr(b, "_headers", lambda _tr: {})
class Response: class Response:
def raise_for_status(self): pass def raise_for_status(self):
def json(self): return {"rt_cd": "0", "output1": []} pass
def json(self):
return {"rt_cd": "0", "output1": []}
monkeypatch.setattr("trex_core.adapters.broker.kis.httpx.get", lambda *a, **kw: Response()) monkeypatch.setattr("trex_core.adapters.broker.kis.httpx.get", lambda *a, **kw: Response())
assert b.get_balance() == [] assert b.get_balance() == []
@@ -105,7 +130,9 @@ def test_get_balance_accepts_validated_empty_balance(monkeypatch):
def test_get_balance_sanitizes_token_issuance_failure(monkeypatch): def test_get_balance_sanitizes_token_issuance_failure(monkeypatch):
b = KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL) b = KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL)
monkeypatch.setattr(b, "_headers", lambda _tr: (_ for _ in ()).throw(KeyError("access_token-secret"))) monkeypatch.setattr(
b, "_headers", lambda _tr: (_ for _ in ()).throw(KeyError("access_token-secret"))
)
with pytest.raises(BrokerBalanceError) as raised: with pytest.raises(BrokerBalanceError) as raised:
b.get_balance() b.get_balance()
assert raised.value.code == "token_issuance_failed" assert raised.value.code == "token_issuance_failed"
@@ -118,7 +145,9 @@ def test_get_available_cash_uses_cash_only_amount(monkeypatch):
monkeypatch.setattr(b, "get_current_price", lambda _symbol: None) monkeypatch.setattr(b, "get_current_price", lambda _symbol: None)
class Response: class Response:
def raise_for_status(self): pass def raise_for_status(self):
pass
def json(self): def json(self):
return { return {
"rt_cd": "0", "rt_cd": "0",
@@ -127,3 +156,182 @@ def test_get_available_cash_uses_cash_only_amount(monkeypatch):
monkeypatch.setattr("trex_core.adapters.broker.kis.httpx.get", lambda *a, **kw: Response()) monkeypatch.setattr("trex_core.adapters.broker.kis.httpx.get", lambda *a, **kw: Response())
assert b.get_available_cash() == 695_591 assert b.get_available_cash() == 695_591
def test_submit_transport_failure_blocks_automatic_duplicate(monkeypatch):
b = KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL)
monkeypatch.setattr(b, "_headers", lambda _tr: {})
monkeypatch.setattr(
"trex_core.adapters.broker.kis.httpx.post",
lambda *a, **kw: (_ for _ in ()).throw(httpx.ReadTimeout("timed out")),
)
ack = b.submit_order(
OrderRequest(
slot_id="slot-1",
side="buy",
symbol="005930",
qty=4,
limit_price=232_000,
)
)
assert ack.accepted is True
assert ack.broker_order_id.startswith("submission-unknown:")
assert ack.reason == "submit_outcome_unknown:ReadTimeout"
assert (
b.cancel_order(
ack.broker_order_id,
side="buy",
symbol="005930",
qty=4,
)
is False
)
def test_submit_gateway_5xx_blocks_automatic_duplicate(monkeypatch):
b = KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL)
monkeypatch.setattr(b, "_headers", lambda _tr: {})
class Response:
status_code = 502
def json(self):
return {"error": "upstream_unavailable"}
monkeypatch.setattr(
"trex_core.adapters.broker.kis.httpx.post",
lambda *a, **kw: Response(),
)
ack = b.submit_order(
OrderRequest(
slot_id="slot-2",
side="buy",
symbol="005930",
qty=4,
limit_price=231_500,
)
)
assert ack.accepted is True
assert ack.broker_order_id.startswith("submission-unknown:")
assert ack.reason == "submit_outcome_unknown:http_502"
def test_submit_unknown_is_resolved_by_unique_daily_order(monkeypatch):
b = KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL)
monkeypatch.setattr(b, "_headers", lambda _tr: {})
monkeypatch.setattr(
"trex_core.adapters.broker.kis.httpx.post",
lambda *a, **kw: (_ for _ in ()).throw(httpx.ReadTimeout("timed out")),
)
ack = b.submit_order(
OrderRequest(
slot_id="slot-resolve",
side="buy",
symbol="005930",
qty=4,
limit_price=232_000,
)
)
from trex_core.adapters.broker.kis import _decode_submission_unknown
submitted = _decode_submission_unknown(ack.broker_order_id)["submitted_at"]
from datetime import datetime
at = datetime.fromisoformat(submitted)
row = {
"pdno": "005930",
"sll_buy_dvsn_cd": "02",
"ord_qty": "4",
"ord_unpr": "232000",
"ord_dt": at.strftime("%Y%m%d"),
"ord_tmd": at.strftime("%H%M%S"),
"ord_gno_brno": "91258",
"odno": "0012345678",
"tot_ccld_qty": "0",
"cncl_yn": "N",
}
class Response:
status_code = 200
def json(self):
return {"rt_cd": "0", "output1": [row]}
monkeypatch.setattr(
"trex_core.adapters.broker.kis.httpx.get",
lambda *a, **kw: Response(),
)
status = b.poll_fill(ack.broker_order_id, side="buy", symbol="005930")
assert status.state == "accepted"
assert status.raw["resolved_broker_order_id"] == "91258:0012345678"
def test_submit_unknown_confirmed_absent_becomes_retryable_reject(monkeypatch):
b = KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL)
monkeypatch.setattr(b, "_headers", lambda _tr: {})
monkeypatch.setattr(
"trex_core.adapters.broker.kis.httpx.post",
lambda *a, **kw: (_ for _ in ()).throw(httpx.ReadTimeout("timed out")),
)
ack = b.submit_order(
OrderRequest(
slot_id="slot-absent",
side="buy",
symbol="005930",
qty=4,
limit_price=232_000,
)
)
class Response:
status_code = 200
def json(self):
return {"rt_cd": "0", "output1": []}
monkeypatch.setattr(
"trex_core.adapters.broker.kis.httpx.get",
lambda *a, **kw: Response(),
)
monkeypatch.setattr(
"trex_core.adapters.broker.kis._SUBMISSION_LOOKUP_GRACE_SECONDS",
-1.0,
)
status = b.poll_fill(ack.broker_order_id, side="buy", symbol="005930")
assert status.state == "rejected"
assert status.raw == {"reason": "submission_confirmed_absent"}
def test_fill_poll_transport_failure_uses_backoff(monkeypatch):
b = KISBroker("12345678-01", app_key="k", app_secret="s", base_url=_MOCK_URL)
monkeypatch.setattr(b, "_headers", lambda _tr: {})
clock = [100.0]
monkeypatch.setattr("trex_core.adapters.broker.kis.monotonic", lambda: clock[0])
calls = []
def fail(*args, **kwargs):
calls.append((args, kwargs))
raise httpx.ConnectTimeout("timed out")
monkeypatch.setattr("trex_core.adapters.broker.kis.httpx.get", fail)
first = b.poll_fill("91258:1234", side="buy", symbol="005930")
immediate = b.poll_fill("91258:1234", side="buy", symbol="005930")
assert first.state == "unknown"
assert immediate.state == "accepted"
assert immediate.raw == {"reason": "fill_poll_backoff"}
assert len(calls) == 1
clock[0] += 31
again = b.poll_fill("91258:1234", side="buy", symbol="005930")
assert again.state == "unknown"
assert len(calls) == 2
+18 -6
View File
@@ -4,6 +4,7 @@ inquire-daily-ccld marks a cancelled order with cncl_yn="Y". These tests stub
the HTTP layer (token pre-seeded, httpx.get faked) and assert the FillStatus the HTTP layer (token pre-seeded, httpx.get faked) and assert the FillStatus
mapping for full cancel / partial-fill-then-cancel / not-cancelled. mapping for full cancel / partial-fill-then-cancel / not-cancelled.
""" """
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@@ -17,7 +18,8 @@ ODNO = "0000123456"
def _broker(): def _broker():
b = KISBroker( b = KISBroker(
"12345678-01", "12345678-01",
app_key="k", app_secret="s", app_key="k",
app_secret="s",
base_url="https://openapivts.koreainvestment.com:29443", # mock endpoint base_url="https://openapivts.koreainvestment.com:29443", # mock endpoint
) )
b._token = "tok" b._token = "tok"
@@ -36,14 +38,22 @@ class _Resp:
def _fake_get(payload): def _fake_get(payload):
def _get(*a, **k): def _get(*a, **k):
return _Resp(payload) return _Resp(payload)
return _get return _get
def _row(**kw): def _row(**kw):
base = {"odno": ODNO, "ord_qty": "10", "tot_ccld_qty": "0", base = {
"avg_prvs": "0", "cncl_yn": "N", "ord_dt": "20260608", "ord_gno_brno": "06010"} "odno": ODNO,
"ord_qty": "10",
"tot_ccld_qty": "0",
"avg_prvs": "0",
"cncl_yn": "N",
"ord_dt": "20260608",
"ord_gno_brno": "06010",
}
base.update(kw) base.update(kw)
return {"output1": [base]} return {"rt_cd": "0", "output1": [base]}
def test_full_external_cancel_maps_to_cancelled(monkeypatch): def test_full_external_cancel_maps_to_cancelled(monkeypatch):
@@ -60,7 +70,8 @@ def test_zero_fill_not_cancelled_stays_accepted(monkeypatch):
def test_partial_fill_then_cancel_flags_partial_cancel(monkeypatch): def test_partial_fill_then_cancel_flags_partial_cancel(monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
kis_mod.httpx, "get", kis_mod.httpx,
"get",
_fake_get(_row(ord_qty="10", tot_ccld_qty="4", avg_prvs="70000", cncl_yn="Y")), _fake_get(_row(ord_qty="10", tot_ccld_qty="4", avg_prvs="70000", cncl_yn="Y")),
) )
fs = _broker().poll_fill(f"06010:{ODNO}", side="sell", symbol="005930") fs = _broker().poll_fill(f"06010:{ODNO}", side="sell", symbol="005930")
@@ -72,7 +83,8 @@ def test_partial_fill_then_cancel_flags_partial_cancel(monkeypatch):
def test_full_fill_unaffected_by_cancel_flag(monkeypatch): def test_full_fill_unaffected_by_cancel_flag(monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
kis_mod.httpx, "get", kis_mod.httpx,
"get",
_fake_get(_row(ord_qty="10", tot_ccld_qty="10", avg_prvs="70000", cncl_yn="N")), _fake_get(_row(ord_qty="10", tot_ccld_qty="10", avg_prvs="70000", cncl_yn="N")),
) )
fs = _broker().poll_fill(f"06010:{ODNO}", side="sell", symbol="005930") fs = _broker().poll_fill(f"06010:{ODNO}", side="sell", symbol="005930")
+96 -14
View File
@@ -2,10 +2,12 @@
In-memory adapters; deterministic paper double drives the full async path. In-memory adapters; deterministic paper double drives the full async path.
""" """
from __future__ import annotations from __future__ import annotations
from trex_core.adapters.broker.paper import PaperBroker from trex_core.adapters.broker.paper import PaperBroker
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
from trex_core.application.broker_port import FillStatus, OrderAck
from trex_core.application.execution import ExecutionService from trex_core.application.execution import ExecutionService
from trex_core.application.intents import BuyIntent, IntentService, SellIntent from trex_core.application.intents import BuyIntent, IntentService, SellIntent
from trex_core.domain.slot import SlotService, SlotState from trex_core.domain.slot import SlotService, SlotState
@@ -19,9 +21,12 @@ def _wire(broker, max_active_slots=None):
repo = InMemorySlotRepository() repo = InMemorySlotRepository()
svc = SlotService(repo, instance_key=INST) svc = SlotService(repo, instance_key=INST)
intents = IntentService( intents = IntentService(
svc, repo, InMemoryIntentStore(), svc,
repo,
InMemoryIntentStore(),
resolve_symbol=SYMBOLS.get, resolve_symbol=SYMBOLS.get,
instance_key=INST, max_active_slots=max_active_slots, instance_key=INST,
max_active_slots=max_active_slots,
) )
execution = ExecutionService(svc, repo, broker, instance_key=INST) execution = ExecutionService(svc, repo, broker, instance_key=INST)
repo.reconcile_universe(INST, SYMBOLS) # permanent asset slots repo.reconcile_universe(INST, SYMBOLS) # permanent asset slots
@@ -29,9 +34,7 @@ def _wire(broker, max_active_slots=None):
def _buy(intents, iid="b1", pid="PF-1", qty=10): def _buy(intents, iid="b1", pid="PF-1", qty=10):
return intents.submit_buy( return intents.submit_buy(BuyIntent(intent_id=iid, portfolio_id=pid, qty=qty, entry_price=1000))
BuyIntent(intent_id=iid, portfolio_id=pid, qty=qty, entry_price=1000)
)
def test_buy_lifecycle_to_holding(): def test_buy_lifecycle_to_holding():
@@ -48,13 +51,93 @@ def test_buy_dispatch_is_idempotent_no_double_order():
broker = PaperBroker(fill_after_polls=5) # stays accepted (not filled) across ticks broker = PaperBroker(fill_after_polls=5) # stays accepted (not filled) across ticks
repo, svc, intents, execution = _wire(broker) repo, svc, intents, execution = _wire(broker)
sid = _buy(intents).slot_id sid = _buy(intents).slot_id
execution.run_once() # dispatch (1 order) + poll(accepted) execution.run_once() # dispatch (1 order) + poll(accepted)
bid1 = repo.fetch_slot(sid).broker_order_id bid1 = repo.fetch_slot(sid).broker_order_id
execution.run_once() # must NOT re-dispatch execution.run_once() # must NOT re-dispatch
bid2 = repo.fetch_slot(sid).broker_order_id bid2 = repo.fetch_slot(sid).broker_order_id
assert bid1 == bid2 and len(broker._orders) == 1 # exactly one broker order assert bid1 == bid2 and len(broker._orders) == 1 # exactly one broker order
def test_unknown_submit_outcome_stays_dispatched_without_retry():
class UnknownOutcomeBroker(PaperBroker):
def __init__(self):
super().__init__()
self.submit_count = 0
self.cancel_count = 0
def submit_order(self, req):
self.submit_count += 1
return OrderAck(
accepted=True,
broker_order_id=f"submission-unknown:{req.slot_id}",
reason="submit_outcome_unknown:ReadTimeout",
)
def poll_fill(self, broker_order_id, *, side, symbol):
return FillStatus(state="unknown")
def cancel_order(self, broker_order_id, *, side, symbol, qty, venue="KRX"):
self.cancel_count += 1
return False
broker = UnknownOutcomeBroker()
repo, svc, intents, execution = _wire(broker)
sid = _buy(intents).slot_id
first = execution.run_once()
second = execution.run_once()
slot = repo.fetch_slot(sid)
assert first.dispatched == 1
assert second.dispatched == 0
assert broker.submit_count == 1
assert broker.cancel_count == 0
assert slot.status is SlotState.PENDING_BUY
assert slot.broker_order_id == f"submission-unknown:{sid}"
def test_unknown_submit_resolution_is_persisted_before_restart_reconcile():
class ResolvedOutcomeBroker(PaperBroker):
def __init__(self):
super().__init__()
self.submit_count = 0
self.poll_count = 0
def submit_order(self, req):
self.submit_count += 1
return OrderAck(
accepted=True,
broker_order_id=f"submission-unknown:{req.slot_id}",
reason="submit_outcome_unknown:ReadTimeout",
)
def poll_fill(self, broker_order_id, *, side, symbol):
self.poll_count += 1
if self.poll_count == 1:
return FillStatus(
state="accepted",
raw={"resolved_broker_order_id": "91258:0012345678"},
)
return FillStatus(
state="filled",
filled_qty=10,
avg_price=1000,
execution_id="resolved-fill-1",
)
broker = ResolvedOutcomeBroker()
repo, svc, intents, execution = _wire(broker)
sid = _buy(intents).slot_id
execution.run_once()
assert repo.fetch_slot(sid).broker_order_id == "91258:0012345678"
execution.reconcile()
slot = repo.fetch_slot(sid)
assert slot.status is SlotState.HOLDING
assert broker.submit_count == 1
def test_buy_reject_rolls_back_to_inactive(): def test_buy_reject_rolls_back_to_inactive():
repo, svc, intents, execution = _wire(PaperBroker(reject_symbols={"005931"})) repo, svc, intents, execution = _wire(PaperBroker(reject_symbols={"005931"}))
sid = _buy(intents).slot_id sid = _buy(intents).slot_id
@@ -156,8 +239,7 @@ def test_sell_skipped_when_broker_flat():
sid = _buy(intents).slot_id sid = _buy(intents).slot_id
execution.run_once() # holding (broker balance 10) execution.run_once() # holding (broker balance 10)
broker._orders.clear() # simulate divergence: broker is actually flat broker._orders.clear() # simulate divergence: broker is actually flat
svc.transit_slot(sid, SlotState.HOLDING, SlotState.PENDING_SELL, svc.transit_slot(sid, SlotState.HOLDING, SlotState.PENDING_SELL, reason="r", actor="t")
reason="r", actor="t")
r = execution.run_once() r = execution.run_once()
assert r.sell_skipped_no_balance == 1 assert r.sell_skipped_no_balance == 1
assert r.dispatched == 0 assert r.dispatched == 0
@@ -172,8 +254,7 @@ def test_skipped_sell_does_not_block_other_dispatches():
sid = _buy(intents, iid="b1", pid="PF-1").slot_id sid = _buy(intents, iid="b1", pid="PF-1").slot_id
execution.run_once() # PF-1 holding execution.run_once() # PF-1 holding
broker._orders.clear() # broker flat for PF-1's symbol broker._orders.clear() # broker flat for PF-1's symbol
svc.transit_slot(sid, SlotState.HOLDING, SlotState.PENDING_SELL, svc.transit_slot(sid, SlotState.HOLDING, SlotState.PENDING_SELL, reason="r", actor="t")
reason="r", actor="t")
d2 = _buy(intents, iid="b2", pid="PF-2") # a buy behind the stuck sell d2 = _buy(intents, iid="b2", pid="PF-2") # a buy behind the stuck sell
r = execution.run_once() r = execution.run_once()
assert r.sell_skipped_no_balance == 1 assert r.sell_skipped_no_balance == 1
@@ -185,10 +266,10 @@ def test_reconcile_after_crash_completes_fill_without_redispatch():
broker = PaperBroker(fill_after_polls=1) broker = PaperBroker(fill_after_polls=1)
repo, svc, intents, execution = _wire(broker) repo, svc, intents, execution = _wire(broker)
sid = _buy(intents).slot_id sid = _buy(intents).slot_id
execution.run_once() # dispatch + poll1 (accepted, not filled) — simulates crash here execution.run_once() # dispatch + poll1 (accepted, not filled) — simulates crash here
assert repo.fetch_slot(sid).status is SlotState.PENDING_BUY assert repo.fetch_slot(sid).status is SlotState.PENDING_BUY
orders_before = len(broker._orders) orders_before = len(broker._orders)
execution.reconcile() # restart: re-poll -> fills, no new order execution.reconcile() # restart: re-poll -> fills, no new order
slot = repo.fetch_slot(sid) slot = repo.fetch_slot(sid)
assert slot.status is SlotState.HOLDING assert slot.status is SlotState.HOLDING
assert len(broker._orders) == orders_before # reconcile did not submit a new order assert len(broker._orders) == orders_before # reconcile did not submit a new order
@@ -197,7 +278,8 @@ def test_reconcile_after_crash_completes_fill_without_redispatch():
def test_capacity_full_blocks_further_buys(): def test_capacity_full_blocks_further_buys():
"""dcy-020/REQ-059: at M active slots further buys reject capacity_full.""" """dcy-020/REQ-059: at M active slots further buys reject capacity_full."""
repo, svc, intents, execution = _wire( repo, svc, intents, execution = _wire(
PaperBroker(fill_after_polls=0), max_active_slots=2, PaperBroker(fill_after_polls=0),
max_active_slots=2,
) )
assert _buy(intents, iid="b0", pid="PF-0").status == "admitted" assert _buy(intents, iid="b0", pid="PF-0").status == "admitted"
assert _buy(intents, iid="b1", pid="PF-1").status == "admitted" assert _buy(intents, iid="b1", pid="PF-1").status == "admitted"
@@ -0,0 +1,206 @@
"""KIS-compatible virtual HTTP recovery tests for ambiguous order submission."""
from __future__ import annotations
import json
import threading
import time
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from zoneinfo import ZoneInfo
from trex_core.adapters.broker.kis import KISBroker
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
from trex_core.application.execution import ExecutionService
from trex_core.application.intents import BuyIntent, IntentService
from trex_core.domain.slot import SlotService, SlotState
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
KST = ZoneInfo("Asia/Seoul")
INSTANCE = "virtual-reliability"
PORTFOLIO = "virtual-005930"
SYMBOL = "005930"
class _ExchangeState:
def __init__(self, *, first_attempt_reaches_exchange: bool) -> None:
self.first_attempt_reaches_exchange = first_attempt_reaches_exchange
self.submit_attempts = 0
self.orders: list[dict[str, str]] = []
self.fill_orders = False
def submit(self, body: dict[str, str]) -> tuple[bool, dict[str, object]]:
self.submit_attempts += 1
order = {
"pdno": body["PDNO"],
"sll_buy_dvsn_cd": "02",
"ord_qty": body["ORD_QTY"],
"ord_unpr": body["ORD_UNPR"],
"ord_dt": datetime.now(KST).strftime("%Y%m%d"),
"ord_tmd": datetime.now(KST).strftime("%H%M%S"),
"ord_gno_brno": "91258",
"odno": f"{self.submit_attempts:010d}",
"tot_ccld_qty": "0",
"avg_prvs": "0",
"cncl_yn": "N",
}
reached = self.submit_attempts > 1 or self.first_attempt_reaches_exchange
if reached:
self.orders.append(order)
return reached, {
"rt_cd": "0",
"output": {
"KRX_FWDG_ORD_ORGNO": order["ord_gno_brno"],
"ODNO": order["odno"],
},
}
def daily_orders(self) -> dict[str, object]:
rows = []
for order in self.orders:
row = dict(order)
if self.fill_orders:
row["tot_ccld_qty"] = row["ord_qty"]
row["avg_prvs"] = row["ord_unpr"]
rows.append(row)
return {"rt_cd": "0", "output1": rows}
def balance(self) -> dict[str, object]:
quantity = sum(int(order["ord_qty"]) for order in self.orders) if self.fill_orders else 0
rows = [{"pdno": SYMBOL, "hldg_qty": str(quantity)}] if quantity else []
return {"rt_cd": "0", "output1": rows}
def _serve(state: _ExchangeState):
class Handler(BaseHTTPRequestHandler):
def log_message(self, *_args):
return
def do_POST(self):
length = int(self.headers.get("content-length", "0"))
body = json.loads(self.rfile.read(length))
reached, payload = state.submit(body)
if state.submit_attempts == 1:
time.sleep(0.08)
self._reply(payload if reached or state.submit_attempts > 1 else payload)
def do_GET(self):
if self.path.startswith("/uapi/domestic-stock/v1/trading/inquire-balance"):
self._reply(state.balance())
else:
self._reply(state.daily_orders())
def _reply(self, payload):
raw = json.dumps(payload).encode()
try:
self.send_response(200)
self.send_header("content-type", "application/json")
self.send_header("content-length", str(len(raw)))
self.end_headers()
self.wfile.write(raw)
except (BrokenPipeError, ConnectionResetError):
pass
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
return server
def _broker(server: ThreadingHTTPServer) -> KISBroker:
broker = KISBroker(
"12345678-01",
app_key="virtual-key",
app_secret="virtual-secret",
base_url=f"http://127.0.0.1:{server.server_port}",
timeout=0.03,
allow_live=True,
)
broker._headers = lambda _tr_id: {} # virtual exchange needs no credentials
return broker
def _wire(broker: KISBroker):
repo = InMemorySlotRepository()
slots = SlotService(repo, instance_key=INSTANCE)
intents = IntentService(
slots,
repo,
InMemoryIntentStore(),
resolve_symbol={PORTFOLIO: SYMBOL}.get,
instance_key=INSTANCE,
)
repo.reconcile_universe(INSTANCE, {PORTFOLIO: SYMBOL})
execution = ExecutionService(slots, repo, broker, instance_key=INSTANCE)
decision = intents.submit_buy(
BuyIntent(
intent_id="virtual-buy-1",
portfolio_id=PORTFOLIO,
qty=2,
entry_price=238_500,
)
)
return repo, slots, intents, execution, decision.slot_id
def test_timeout_after_exchange_accept_resolves_without_duplicate_and_survives_restart():
state = _ExchangeState(first_attempt_reaches_exchange=True)
server = _serve(state)
try:
repo, slots, _intents, execution, slot_id = _wire(_broker(server))
execution.run_once()
pending = repo.fetch_slot(slot_id)
assert pending.status is SlotState.PENDING_BUY
assert pending.broker_order_id == "91258:0000000001"
assert state.submit_attempts == 1
assert len(state.orders) == 1
state.fill_orders = True
restarted = ExecutionService(slots, repo, _broker(server), instance_key=INSTANCE)
restarted.reconcile()
holding = repo.fetch_slot(slot_id)
assert holding.status is SlotState.HOLDING
assert holding.qty == 2
assert holding.avg_price == 238_500
assert state.submit_attempts == 1
finally:
server.shutdown()
server.server_close()
def test_timeout_before_exchange_accept_retries_only_after_confirmed_absence(monkeypatch):
state = _ExchangeState(first_attempt_reaches_exchange=False)
state.fill_orders = True
server = _serve(state)
monkeypatch.setattr(
"trex_core.adapters.broker.kis._SUBMISSION_LOOKUP_GRACE_SECONDS",
-1.0,
)
try:
repo, _slots, intents, execution, slot_id = _wire(_broker(server))
execution.run_once()
assert repo.fetch_slot(slot_id).status is SlotState.INACTIVE
assert state.submit_attempts == 1
assert state.orders == []
retry = intents.submit_buy(
BuyIntent(
intent_id="virtual-buy-2",
portfolio_id=PORTFOLIO,
qty=2,
entry_price=238_500,
)
)
assert retry.status == "admitted"
execution.run_once()
holding = repo.fetch_slot(slot_id)
assert holding.status is SlotState.HOLDING
assert holding.qty == 2
assert state.submit_attempts == 2
assert len(state.orders) == 1
finally:
server.shutdown()
server.server_close()
+338 -37
View File
@@ -8,10 +8,15 @@ Endpoints: /oauth2/tokenP (token), /uapi/domestic-stock/v1/trading/order-cash
(submit), /uapi/domestic-stock/v1/trading/inquire-daily-ccld (fill poll), (submit), /uapi/domestic-stock/v1/trading/inquire-daily-ccld (fill poll),
/uapi/domestic-stock/v1/trading/order-rvsecncl (cancel). /uapi/domestic-stock/v1/trading/order-rvsecncl (cancel).
""" """
from __future__ import annotations from __future__ import annotations
import base64
import json
from dataclasses import replace
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from time import monotonic
from typing import Optional from typing import Optional
import httpx import httpx
@@ -27,7 +32,11 @@ from trex_core.application.broker_port import (
) )
_KST = timezone(timedelta(hours=9)) _KST = timezone(timedelta(hours=9))
_SUBMISSION_UNKNOWN_PREFIX = "submission-unknown:"
_MOCK_HINTS = ("openapivts", "virtual", "mock") _MOCK_HINTS = ("openapivts", "virtual", "mock")
_FILL_POLL_MIN_INTERVAL_SECONDS = 5.0
_FILL_POLL_TRANSPORT_BACKOFF_SECONDS = 30.0
_SUBMISSION_LOOKUP_GRACE_SECONDS = 90.0
def _safe_int(v: object) -> Optional[int]: def _safe_int(v: object) -> Optional[int]:
@@ -37,6 +46,55 @@ def _safe_int(v: object) -> Optional[int]:
return None return None
def _encode_submission_unknown(req: OrderRequest, submitted_at: datetime) -> str:
payload = {
"v": 1,
"slot_id": req.slot_id,
"side": req.side,
"symbol": req.symbol,
"qty": req.qty,
"limit_price": req.limit_price,
"venue": req.venue,
"submitted_at": submitted_at.astimezone(_KST).isoformat(),
}
raw = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode()
encoded = base64.urlsafe_b64encode(raw).decode().rstrip("=")
return f"{_SUBMISSION_UNKNOWN_PREFIX}{encoded}"
def _decode_submission_unknown(broker_order_id: str) -> Optional[dict]:
if not broker_order_id.startswith(_SUBMISSION_UNKNOWN_PREFIX):
return None
encoded = broker_order_id[len(_SUBMISSION_UNKNOWN_PREFIX) :]
try:
raw = base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4))
payload = json.loads(raw)
except (ValueError, UnicodeDecodeError, json.JSONDecodeError):
return None
required = {
"v",
"slot_id",
"side",
"symbol",
"qty",
"venue",
"submitted_at",
}
if (
not isinstance(payload, dict)
or payload.get("v") != 1
or not required.issubset(payload)
or payload.get("side") not in {"buy", "sell"}
):
return None
try:
datetime.fromisoformat(str(payload["submitted_at"]))
int(payload["qty"])
except (TypeError, ValueError):
return None
return payload
class KISBroker: class KISBroker:
"""KIS REST broker adapter. Live account is gated behind `allow_live`.""" """KIS REST broker adapter. Live account is gated behind `allow_live`."""
@@ -59,6 +117,7 @@ class KISBroker:
self._token: Optional[str] = None self._token: Optional[str] = None
self._token_exp: datetime = datetime.now(tz=timezone.utc) self._token_exp: datetime = datetime.now(tz=timezone.utc)
self._token_cache = token_cache # optional TokenCache (L2, REQ-016) self._token_cache = token_cache # optional TokenCache (L2, REQ-016)
self._fill_poll_not_before: dict[str, float] = {}
is_mock = any(h in self._base_url for h in _MOCK_HINTS) is_mock = any(h in self._base_url for h in _MOCK_HINTS)
if not is_mock and not allow_live: if not is_mock and not allow_live:
raise RuntimeError( raise RuntimeError(
@@ -167,6 +226,7 @@ class KISBroker:
# --- BrokerPort --- # --- BrokerPort ---
def submit_order(self, req: OrderRequest) -> OrderAck: def submit_order(self, req: OrderRequest) -> OrderAck:
cano, product = self._account() cano, product = self._account()
submitted_at = datetime.now(tz=_KST)
body = { body = {
"CANO": cano, "CANO": cano,
"ACNT_PRDT_CD": product, "ACNT_PRDT_CD": product,
@@ -188,18 +248,44 @@ class KISBroker:
) )
payload = resp.json() payload = resp.json()
except (httpx.HTTPError, ValueError) as exc: except (httpx.HTTPError, ValueError) as exc:
return OrderAck(accepted=False, reason=f"submit_network_error:{type(exc).__name__}", # A transport failure after POST does not prove that KIS rejected
raw={"error": str(exc)}) # the order. Keep the slot dispatched under a local sentinel so
# the execution loop cannot submit the same order again.
return OrderAck(
accepted=True,
broker_order_id=_encode_submission_unknown(req, submitted_at),
reason=f"submit_outcome_unknown:{type(exc).__name__}",
raw={"error_class": type(exc).__name__},
)
if getattr(resp, "status_code", 200) >= 500:
return OrderAck(
accepted=True,
broker_order_id=_encode_submission_unknown(req, submitted_at),
reason=f"submit_outcome_unknown:http_{resp.status_code}",
raw={"http_status": resp.status_code},
)
if str(payload.get("rt_cd")) == "0": if str(payload.get("rt_cd")) == "0":
out = payload.get("output") or {} out = payload.get("output") or {}
odno = out.get("ODNO") or out.get("odno") odno = out.get("ODNO") or out.get("odno")
orgno = out.get("KRX_FWDG_ORD_ORGNO") or out.get("ord_gno_brno") or "" orgno = out.get("KRX_FWDG_ORD_ORGNO") or out.get("ord_gno_brno") or ""
bid = f"{orgno}:{odno}" if orgno else str(odno) bid = f"{orgno}:{odno}" if orgno else str(odno)
return OrderAck(accepted=True, broker_order_id=bid, raw=payload) return OrderAck(accepted=True, broker_order_id=bid, raw=payload)
return OrderAck(accepted=False, reason=payload.get("msg1") or payload.get("msg_cd"), return OrderAck(
raw=payload) accepted=False, reason=payload.get("msg1") or payload.get("msg_cd"), raw=payload
)
def poll_fill(self, broker_order_id: str, *, side: str, symbol: str) -> FillStatus: def poll_fill(self, broker_order_id: str, *, side: str, symbol: str) -> FillStatus:
if broker_order_id.startswith(_SUBMISSION_UNKNOWN_PREFIX):
return self._poll_unknown_submission(broker_order_id, side=side, symbol=symbol)
now = monotonic()
if now < self._fill_poll_not_before.get(broker_order_id, 0.0):
return FillStatus(
state="accepted",
raw={"reason": "fill_poll_backoff"},
)
# Set the normal gate before I/O so concurrent execution ticks cannot
# open duplicate KIS inquiries for the same order.
self._fill_poll_not_before[broker_order_id] = now + _FILL_POLL_MIN_INTERVAL_SECONDS
cano, product = self._account() cano, product = self._account()
odno = broker_order_id.split(":")[-1] odno = broker_order_id.split(":")[-1]
today = datetime.now(tz=_KST).strftime("%Y%m%d") today = datetime.now(tz=_KST).strftime("%Y%m%d")
@@ -208,25 +294,206 @@ class KISBroker:
# 향후 NXT 체결 미포함 실측 확인 시 TTTC0081R(EXCG=ALL) 전환 별도 batch 처리. # 향후 NXT 체결 미포함 실측 확인 시 TTTC0081R(EXCG=ALL) 전환 별도 batch 처리.
tr_id = "VTTC8001R" if self._mock else "TTTC8001R" tr_id = "VTTC8001R" if self._mock else "TTTC8001R"
params = { params = {
"CANO": cano, "ACNT_PRDT_CD": product, "CANO": cano,
"INQR_STRT_DT": today, "INQR_END_DT": today, "ACNT_PRDT_CD": product,
"SLL_BUY_DVSN_CD": "00", "INQR_DVSN": "00", "PDNO": "", "INQR_STRT_DT": today,
"CCLD_DVSN": "00", "ORD_GNO_BRNO": "", "ODNO": odno, "INQR_END_DT": today,
"INQR_DVSN_3": "00", "INQR_DVSN_1": "", "SLL_BUY_DVSN_CD": "00",
"CTX_AREA_FK100": "", "CTX_AREA_NK100": "", "INQR_DVSN": "00",
"PDNO": "",
"CCLD_DVSN": "00",
"ORD_GNO_BRNO": "",
"ODNO": odno,
"INQR_DVSN_3": "00",
"INQR_DVSN_1": "",
"CTX_AREA_FK100": "",
"CTX_AREA_NK100": "",
} }
try: try:
resp = httpx.get( resp = httpx.get(
f"{self._base_url}/uapi/domestic-stock/v1/trading/inquire-daily-ccld", f"{self._base_url}/uapi/domestic-stock/v1/trading/inquire-daily-ccld",
headers=self._headers(tr_id), params=params, timeout=self._timeout, headers=self._headers(tr_id),
params=params,
timeout=self._timeout,
) )
payload = resp.json() payload = resp.json()
except (httpx.HTTPError, ValueError) as exc: except (httpx.HTTPError, ValueError) as exc:
self._fill_poll_not_before[broker_order_id] = (
monotonic() + _FILL_POLL_TRANSPORT_BACKOFF_SECONDS
)
return FillStatus(state="unknown", raw={"error": str(exc)}) return FillStatus(state="unknown", raw={"error": str(exc)})
if getattr(resp, "status_code", 200) >= 400 or str(payload.get("rt_cd")) != "0":
self._fill_poll_not_before[broker_order_id] = (
monotonic() + _FILL_POLL_TRANSPORT_BACKOFF_SECONDS
)
return FillStatus(
state="unknown",
raw={"reason": "fill_inquiry_rejected"},
)
self._fill_poll_not_before[broker_order_id] = monotonic() + _FILL_POLL_MIN_INTERVAL_SECONDS
rows = payload.get("output1") or [] rows = payload.get("output1") or []
row = next((r for r in rows if str(r.get("odno")) == odno), rows[0] if rows else None) normalized_odno = odno.lstrip("0") or "0"
row = next(
(
r
for r in rows
if (str(r.get("odno") or "").strip().lstrip("0") or "0") == normalized_odno
),
None,
)
if row is None: if row is None:
return FillStatus(state="accepted", raw=payload) return FillStatus(
state="accepted",
raw={"reason": "order_not_visible_in_fill_inquiry"},
)
return self._fill_status_from_row(row, side=side, today=today, fallback_odno=odno)
def _poll_unknown_submission(
self,
broker_order_id: str,
*,
side: str,
symbol: str,
) -> FillStatus:
metadata = _decode_submission_unknown(broker_order_id)
if metadata is None:
return FillStatus(
state="unknown",
raw={"reason": "submission_unknown_metadata_invalid"},
)
now_monotonic = monotonic()
if now_monotonic < self._fill_poll_not_before.get(broker_order_id, 0.0):
return FillStatus(
state="accepted",
raw={"reason": "submission_lookup_backoff"},
)
self._fill_poll_not_before[broker_order_id] = (
now_monotonic + _FILL_POLL_MIN_INTERVAL_SECONDS
)
cano, product = self._account()
submitted_at = datetime.fromisoformat(str(metadata["submitted_at"])).astimezone(_KST)
today = submitted_at.strftime("%Y%m%d")
tr_id = "VTTC8001R" if self._mock else "TTTC8001R"
params = {
"CANO": cano,
"ACNT_PRDT_CD": product,
"INQR_STRT_DT": today,
"INQR_END_DT": today,
"SLL_BUY_DVSN_CD": "00",
"INQR_DVSN": "00",
"PDNO": str(metadata["symbol"]),
"CCLD_DVSN": "00",
"ORD_GNO_BRNO": "",
"ODNO": "",
"INQR_DVSN_3": "00",
"INQR_DVSN_1": "",
"CTX_AREA_FK100": "",
"CTX_AREA_NK100": "",
}
try:
resp = httpx.get(
f"{self._base_url}/uapi/domestic-stock/v1/trading/inquire-daily-ccld",
headers=self._headers(tr_id),
params=params,
timeout=self._timeout,
)
payload = resp.json()
except (httpx.HTTPError, ValueError):
self._fill_poll_not_before[broker_order_id] = (
monotonic() + _FILL_POLL_TRANSPORT_BACKOFF_SECONDS
)
return FillStatus(
state="unknown",
raw={"reason": "submission_lookup_transport"},
)
if getattr(resp, "status_code", 200) >= 400 or str(payload.get("rt_cd")) != "0":
self._fill_poll_not_before[broker_order_id] = (
monotonic() + _FILL_POLL_TRANSPORT_BACKOFF_SECONDS
)
return FillStatus(
state="unknown",
raw={"reason": "submission_lookup_rejected"},
)
matches = [
row
for row in (payload.get("output1") or [])
if self._matches_unknown_submission(row, metadata, submitted_at)
]
if len(matches) != 1:
age = (datetime.now(tz=_KST) - submitted_at).total_seconds()
if not matches and age >= _SUBMISSION_LOOKUP_GRACE_SECONDS:
return FillStatus(
state="rejected",
raw={"reason": "submission_confirmed_absent"},
)
return FillStatus(
state="unknown",
raw={
"reason": (
"submission_lookup_ambiguous" if matches else "submission_lookup_pending"
)
},
)
row = matches[0]
resolved_order_id = self._broker_order_id_from_row(row)
status = self._fill_status_from_row(
row,
side=side,
today=today,
fallback_odno=resolved_order_id.split(":")[-1],
)
return replace(
status,
raw={
**(status.raw if isinstance(status.raw, dict) else {}),
"resolved_broker_order_id": resolved_order_id,
},
)
@staticmethod
def _broker_order_id_from_row(row: dict) -> str:
odno = str(row.get("odno") or "").strip()
orgno = str(row.get("ord_gno_brno") or "").strip()
return f"{orgno}:{odno}" if orgno else odno
@staticmethod
def _matches_unknown_submission(
row: dict,
metadata: dict,
submitted_at: datetime,
) -> bool:
if str(row.get("pdno") or "").strip() != str(metadata["symbol"]):
return False
expected_side = "02" if metadata["side"] == "buy" else "01"
if str(row.get("sll_buy_dvsn_cd") or "").strip() not in {"", expected_side}:
return False
if (_safe_int(row.get("ord_qty")) or 0) != int(metadata["qty"]):
return False
expected_price = int(metadata.get("limit_price") or 0)
row_price = _safe_int(row.get("ord_unpr"))
if row_price is not None and row_price != expected_price:
return False
order_date = str(row.get("ord_dt") or "").strip()
order_time = str(row.get("ord_tmd") or "").strip()[:6]
if len(order_date) != 8 or len(order_time) != 6:
return False
try:
ordered_at = datetime.strptime(order_date + order_time, "%Y%m%d%H%M%S").replace(
tzinfo=_KST
)
except ValueError:
return False
delta = (ordered_at - submitted_at).total_seconds()
return -5.0 <= delta <= 120.0
@staticmethod
def _fill_status_from_row(
row: dict,
*,
side: str,
today: str,
fallback_odno: str,
) -> FillStatus:
ord_qty = _safe_int(row.get("ord_qty")) or 0 ord_qty = _safe_int(row.get("ord_qty")) or 0
ccld_qty = _safe_int(row.get("tot_ccld_qty")) or 0 ccld_qty = _safe_int(row.get("tot_ccld_qty")) or 0
avg = _safe_int(row.get("avg_prvs")) or _safe_int(row.get("ccld_prvs")) avg = _safe_int(row.get("avg_prvs")) or _safe_int(row.get("ccld_prvs"))
@@ -245,7 +512,7 @@ class KISBroker:
# kis-daily-ccld:{ord_dt}:{ord_gno_brno}:{odno}:{tot_ccld_qty} # kis-daily-ccld:{ord_dt}:{ord_gno_brno}:{odno}:{tot_ccld_qty}
ord_dt = str(row.get("ord_dt") or today) ord_dt = str(row.get("ord_dt") or today)
ord_gno_brno = str(row.get("ord_gno_brno") or "") ord_gno_brno = str(row.get("ord_gno_brno") or "")
row_odno = str(row.get("odno") or odno) row_odno = str(row.get("odno") or fallback_odno)
execution_id = f"kis-daily-ccld:{ord_dt}:{ord_gno_brno}:{row_odno}:{ccld_qty}" execution_id = f"kis-daily-ccld:{ord_dt}:{ord_gno_brno}:{row_odno}:{ccld_qty}"
fill_price = float(avg) if avg else 0.0 fill_price = float(avg) if avg else 0.0
# dcy-026: inquire-daily-ccld (output1) does not expose per-fill # dcy-026: inquire-daily-ccld (output1) does not expose per-fill
@@ -256,9 +523,13 @@ class KISBroker:
fee = calculate_fee(side, amount) fee = calculate_fee(side, amount)
tax = calculate_tax(side, amount) tax = calculate_tax(side, amount)
return FillStatus( return FillStatus(
state="filled", filled_qty=ccld_qty, state="filled",
filled_qty=ccld_qty,
avg_price=fill_price or None, avg_price=fill_price or None,
execution_id=execution_id, fee=fee, tax=tax, raw=row, execution_id=execution_id,
fee=fee,
tax=tax,
raw=row,
) )
# 0 < ccld_qty < ord_qty # 0 < ccld_qty < ord_qty
if cancelled: if cancelled:
@@ -268,20 +539,28 @@ class KISBroker:
# holding instead of waiting for a fill that will never come. # holding instead of waiting for a fill that will never come.
ord_dt = str(row.get("ord_dt") or today) ord_dt = str(row.get("ord_dt") or today)
ord_gno_brno = str(row.get("ord_gno_brno") or "") ord_gno_brno = str(row.get("ord_gno_brno") or "")
row_odno = str(row.get("odno") or odno) row_odno = str(row.get("odno") or fallback_odno)
execution_id = f"kis-daily-ccld:{ord_dt}:{ord_gno_brno}:{row_odno}:{ccld_qty}" execution_id = f"kis-daily-ccld:{ord_dt}:{ord_gno_brno}:{row_odno}:{ccld_qty}"
fill_price = float(avg) if avg else 0.0 fill_price = float(avg) if avg else 0.0
amount = ccld_qty * fill_price amount = ccld_qty * fill_price
fee = calculate_fee(side, amount) fee = calculate_fee(side, amount)
tax = calculate_tax(side, amount) tax = calculate_tax(side, amount)
return FillStatus( return FillStatus(
state="partially_filled", filled_qty=ccld_qty, state="partially_filled",
filled_qty=ccld_qty,
avg_price=fill_price or None, avg_price=fill_price or None,
execution_id=execution_id, fee=fee, tax=tax, execution_id=execution_id,
cancelled=True, raw=row, fee=fee,
tax=tax,
cancelled=True,
raw=row,
) )
return FillStatus(state="partially_filled", filled_qty=ccld_qty, return FillStatus(
avg_price=float(avg) if avg else None, raw=row) state="partially_filled",
filled_qty=ccld_qty,
avg_price=float(avg) if avg else None,
raw=row,
)
def get_balance(self) -> list[BalanceItem]: def get_balance(self) -> list[BalanceItem]:
"""KIS 주식잔고조회 (`TTTC8434R`/`VTTC8434R`). """KIS 주식잔고조회 (`TTTC8434R`/`VTTC8434R`).
@@ -294,12 +573,17 @@ class KISBroker:
cano, product = self._account() cano, product = self._account()
tr_id = "VTTC8434R" if self._mock else "TTTC8434R" tr_id = "VTTC8434R" if self._mock else "TTTC8434R"
params = { params = {
"CANO": cano, "ACNT_PRDT_CD": product, "CANO": cano,
"AFHR_FLPR_YN": "N", "OFL_YN": "", "ACNT_PRDT_CD": product,
"INQR_DVSN": "02", "UNPR_DVSN": "01", "AFHR_FLPR_YN": "N",
"FUND_STTL_ICLD_YN": "N", "FNCG_AMT_AUTO_RDPT_YN": "N", "OFL_YN": "",
"INQR_DVSN": "02",
"UNPR_DVSN": "01",
"FUND_STTL_ICLD_YN": "N",
"FNCG_AMT_AUTO_RDPT_YN": "N",
"PRCS_DVSN": "01", "PRCS_DVSN": "01",
"CTX_AREA_FK100": "", "CTX_AREA_NK100": "", "CTX_AREA_FK100": "",
"CTX_AREA_NK100": "",
} }
try: try:
headers = self._headers(tr_id) headers = self._headers(tr_id)
@@ -310,14 +594,18 @@ class KISBroker:
try: try:
resp = httpx.get( resp = httpx.get(
f"{self._base_url}/uapi/domestic-stock/v1/trading/inquire-balance", f"{self._base_url}/uapi/domestic-stock/v1/trading/inquire-balance",
headers=headers, params=params, timeout=self._timeout, headers=headers,
params=params,
timeout=self._timeout,
) )
resp.raise_for_status() resp.raise_for_status()
payload = resp.json() payload = resp.json()
except httpx.HTTPStatusError as exc: except httpx.HTTPStatusError as exc:
status = exc.response.status_code if exc.response is not None else None status = exc.response.status_code if exc.response is not None else None
code = "authentication_failed" if status == 401 else ( code = (
"authorization_failed" if status == 403 else "transport_failed" "authentication_failed"
if status == 401
else ("authorization_failed" if status == 403 else "transport_failed")
) )
raise BrokerBalanceError(code) from None raise BrokerBalanceError(code) from None
except httpx.HTTPError: except httpx.HTTPError:
@@ -400,7 +688,9 @@ class KISBroker:
try: try:
resp = httpx.get( resp = httpx.get(
f"{self._base_url}/uapi/domestic-stock/v1/quotations/inquire-price", f"{self._base_url}/uapi/domestic-stock/v1/quotations/inquire-price",
headers=self._headers(self._tr_price()), params=params, timeout=self._timeout, headers=self._headers(self._tr_price()),
params=params,
timeout=self._timeout,
) )
payload = resp.json() payload = resp.json()
except (httpx.HTTPError, ValueError): except (httpx.HTTPError, ValueError):
@@ -416,21 +706,30 @@ class KISBroker:
except (InvalidOperation, ValueError): except (InvalidOperation, ValueError):
return None return None
def cancel_order(self, broker_order_id: str, *, side: str, symbol: str, qty: int, venue: str = "KRX") -> bool: def cancel_order(
self, broker_order_id: str, *, side: str, symbol: str, qty: int, venue: str = "KRX"
) -> bool:
"""Cancel a previously submitted order (REQ-118). """Cancel a previously submitted order (REQ-118).
venue: exchange venue of the original order. Must match the EXCG_ID_DVSN_CD venue: exchange venue of the original order. Must match the EXCG_ID_DVSN_CD
used at submit time. Default "KRX" preserves backward compatibility. used at submit time. Default "KRX" preserves backward compatibility.
Real-account NXT cancel (EXCG=NXT) confirmed rt_cd=0 on 2026-07-06. Real-account NXT cancel (EXCG=NXT) confirmed rt_cd=0 on 2026-07-06.
""" """
if broker_order_id.startswith(_SUBMISSION_UNKNOWN_PREFIX):
return False
cano, product = self._account() cano, product = self._account()
parts = broker_order_id.split(":") parts = broker_order_id.split(":")
orgno, odno = (parts[0], parts[1]) if len(parts) == 2 else ("", parts[-1]) orgno, odno = (parts[0], parts[1]) if len(parts) == 2 else ("", parts[-1])
body = { body = {
"CANO": cano, "ACNT_PRDT_CD": product, "CANO": cano,
"KRX_FWDG_ORD_ORGNO": orgno, "ORGN_ODNO": odno, "ACNT_PRDT_CD": product,
"ORD_DVSN": "00", "RVSE_CNCL_DVSN_CD": "02", "KRX_FWDG_ORD_ORGNO": orgno,
"ORD_QTY": str(qty), "ORD_UNPR": "0", "QTY_ALL_ORD_YN": "Y", "ORGN_ODNO": odno,
"ORD_DVSN": "00",
"RVSE_CNCL_DVSN_CD": "02",
"ORD_QTY": str(qty),
"ORD_UNPR": "0",
"QTY_ALL_ORD_YN": "Y",
# REQ-118: cancel must use the same EXCG_ID_DVSN_CD as the original submit. # REQ-118: cancel must use the same EXCG_ID_DVSN_CD as the original submit.
"EXCG_ID_DVSN_CD": venue, "EXCG_ID_DVSN_CD": venue,
} }
@@ -438,7 +737,9 @@ class KISBroker:
try: try:
resp = httpx.post( resp = httpx.post(
f"{self._base_url}/uapi/domestic-stock/v1/trading/order-rvsecncl", f"{self._base_url}/uapi/domestic-stock/v1/trading/order-rvsecncl",
headers=self._headers(tr_id), json=body, timeout=self._timeout, headers=self._headers(tr_id),
json=body,
timeout=self._timeout,
) )
return str(resp.json().get("rt_cd")) == "0" return str(resp.json().get("rt_cd")) == "0"
except (httpx.HTTPError, ValueError): except (httpx.HTTPError, ValueError):
+98 -30
View File
@@ -9,6 +9,7 @@ psycopg (v3) is imported lazily so the domain/test paths that use the
in-memory adapters don't require it. External I/O lives here, never in the in-memory adapters don't require it. External I/O lives here, never in the
domain layer (NFR-1). domain layer (NFR-1).
""" """
from __future__ import annotations from __future__ import annotations
import json import json
@@ -71,9 +72,7 @@ class PostgreSQLSlotRepository:
conn.rollback() conn.rollback()
raise raise
def list_slots( def list_slots(self, instance_key: str, status_filter: Optional[set[str]] = None) -> list[Slot]:
self, instance_key: str, status_filter: Optional[set[str]] = None
) -> list[Slot]:
with self._psycopg.connect(self._dsn) as conn: with self._psycopg.connect(self._dsn) as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
if status_filter: if status_filter:
@@ -147,8 +146,11 @@ class PostgreSQLSlotRepository:
return self._row_to_slot(row) if row else None return self._row_to_slot(row) if row else None
def reconcile_universe( def reconcile_universe(
self, instance_key: str, universe: dict[str, str], self,
*, allow_retire: bool = True, instance_key: str,
universe: dict[str, str],
*,
allow_retire: bool = True,
) -> dict[str, int]: ) -> dict[str, int]:
now = datetime.now(tz=timezone.utc) now = datetime.now(tz=timezone.utc)
created = retired = tombstoned = 0 created = retired = tombstoned = 0
@@ -216,14 +218,26 @@ class PostgreSQLSlotRepository:
"instance_key=%s, retired_at=%s, timeout_sec=%s, slippage_pct=%s, " "instance_key=%s, retired_at=%s, timeout_sec=%s, slippage_pct=%s, "
"occupancy_seq=%s, updated_at=%s WHERE slot_id=%s", "occupancy_seq=%s, updated_at=%s WHERE slot_id=%s",
( (
slot.status.value, slot.symbol, slot.qty, slot.avg_price, slot.status.value,
slot.limit_price, slot.order_id, slot.symbol,
slot.strategy_id, slot.broker_order_id, slot.portfolio_id, slot.qty,
slot.sell_strategy_id, slot.forced_close_at, slot.avg_price,
slot.stop_loss_price, slot.take_profit_price, slot.limit_price,
slot.instance_key, slot.retired_at, slot.order_id,
slot.timeout_sec, slot.slippage_pct, slot.occupancy_seq, slot.strategy_id,
slot.updated_at or datetime.now(tz=timezone.utc), slot.slot_id, slot.broker_order_id,
slot.portfolio_id,
slot.sell_strategy_id,
slot.forced_close_at,
slot.stop_loss_price,
slot.take_profit_price,
slot.instance_key,
slot.retired_at,
slot.timeout_sec,
slot.slippage_pct,
slot.occupancy_seq,
slot.updated_at or datetime.now(tz=timezone.utc),
slot.slot_id,
), ),
) )
if cur.rowcount == 0: if cur.rowcount == 0:
@@ -239,15 +253,22 @@ class PostgreSQLSlotRepository:
"(slot_id, from_state, to_state, ts, reason, actor, extra) " "(slot_id, from_state, to_state, ts, reason, actor, extra) "
"VALUES (%s, %s, %s, %s, %s, %s, %s)", "VALUES (%s, %s, %s, %s, %s, %s, %s)",
( (
record.slot_id, record.from_state.value, record.to_state.value, record.slot_id,
record.ts, record.reason, record.actor, json.dumps(record.extra), record.from_state.value,
record.to_state.value,
record.ts,
record.reason,
record.actor,
json.dumps(record.extra),
), ),
) )
conn.commit() conn.commit()
# --- order execution queue (BAT-004 / REQ-013, option A) --- # --- order execution queue (BAT-004 / REQ-013, option A) ---
def list_undispatched_pending( def list_undispatched_pending(
self, instance_key: str = "default", limit: int = 64, self,
instance_key: str = "default",
limit: int = 64,
) -> list[Slot]: ) -> list[Slot]:
# dcy-020/REQ-056 (BAT-030): instance-scoped so two daemons sharing a # dcy-020/REQ-056 (BAT-030): instance-scoped so two daemons sharing a
# DB never dispatch each other's orders. List-based (vs single-row # DB never dispatch each other's orders. List-based (vs single-row
@@ -275,6 +296,29 @@ class PostgreSQLSlotRepository:
conn.commit() conn.commit()
return ok return ok
def replace_dispatch(
self,
slot_id: UUID,
expected_broker_order_id: str,
broker_order_id: str,
) -> bool:
"""Atomically persist a broker order ID resolved after submit timeout."""
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
with conn.cursor() as cur:
cur.execute(
"UPDATE daily_slot SET broker_order_id=%s, updated_at=%s "
"WHERE slot_id=%s AND broker_order_id=%s",
(
broker_order_id,
datetime.now(tz=timezone.utc),
slot_id,
expected_broker_order_id,
),
)
ok = cur.rowcount == 1
conn.commit()
return ok
def list_dispatched_pending(self, instance_key: str = "default") -> list[Slot]: def list_dispatched_pending(self, instance_key: str = "default") -> list[Slot]:
with self._psycopg.connect(self._dsn) as conn: with self._psycopg.connect(self._dsn) as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
@@ -308,6 +352,7 @@ class PostgreSQLSlotRepository:
@staticmethod @staticmethod
def _row_to_slot(row: tuple) -> Slot: def _row_to_slot(row: tuple) -> Slot:
from decimal import Decimal from decimal import Decimal
f = lambda v: float(v) if v is not None else None # noqa: E731 f = lambda v: float(v) if v is not None else None # noqa: E731
d = lambda v: Decimal(str(v)) if v is not None else None # noqa: E731 d = lambda v: Decimal(str(v)) if v is not None else None # noqa: E731
# Column order: slot_id(0) status(1) symbol(2) qty(3) avg_price(4) # Column order: slot_id(0) status(1) symbol(2) qty(3) avg_price(4)
@@ -373,8 +418,12 @@ class PostgreSQLIntentStore:
if row is None: if row is None:
return None return None
return _PgIntentRecord( return _PgIntentRecord(
intent_id=row[0], intent_type=row[1], status=row[2], intent_id=row[0],
slot_id=(row[3] if isinstance(row[3], UUID) or row[3] is None else UUID(str(row[3]))), intent_type=row[1],
status=row[2],
slot_id=(
row[3] if isinstance(row[3], UUID) or row[3] is None else UUID(str(row[3]))
),
reason=row[4], reason=row[4],
) )
@@ -426,9 +475,15 @@ class PostgreSQLLedgerOutbox:
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) " "VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) "
"ON CONFLICT (correlation_id) DO NOTHING", "ON CONFLICT (correlation_id) DO NOTHING",
( (
trade.correlation_id, trade.asset_id, trade.order_id, trade.correlation_id,
trade.execution_id, trade.side, trade.asset_id,
trade.qty, trade.price, trade.fee, trade.tax, trade.order_id,
trade.execution_id,
trade.side,
trade.qty,
trade.price,
trade.fee,
trade.tax,
trade.executed_at, trade.executed_at,
), ),
) )
@@ -506,8 +561,13 @@ class PostgreSQLLedgerOutbox:
"last_outcome=%s, last_error=%s, next_retry_at=%s, updated_at=%s " "last_outcome=%s, last_error=%s, next_retry_at=%s, updated_at=%s "
"WHERE correlation_id=%s", "WHERE correlation_id=%s",
( (
status_value, attempts_delta, outcome.value, error, nra, status_value,
datetime.now(tz=timezone.utc), correlation_id, attempts_delta,
outcome.value,
error,
nra,
datetime.now(tz=timezone.utc),
correlation_id,
), ),
) )
conn.commit() conn.commit()
@@ -519,8 +579,14 @@ class PostgreSQLLedgerOutbox:
def _row_to_item(row: tuple) -> LedgerWorkItem: def _row_to_item(row: tuple) -> LedgerWorkItem:
f = lambda v: float(v) if v is not None else 0.0 # noqa: E731 f = lambda v: float(v) if v is not None else 0.0 # noqa: E731
trade = LedgerTrade( trade = LedgerTrade(
asset_id=row[1], order_id=row[2], execution_id=row[3], side=row[4], asset_id=row[1],
qty=f(row[5]), price=f(row[6]), fee=f(row[7]), tax=f(row[8]), order_id=row[2],
execution_id=row[3],
side=row[4],
qty=f(row[5]),
price=f(row[6]),
fee=f(row[7]),
tax=f(row[8]),
executed_at=row[9], executed_at=row[9],
) )
return LedgerWorkItem( return LedgerWorkItem(
@@ -601,8 +667,12 @@ class PgStrategyStateStore:
"version = EXCLUDED.version, data = EXCLUDED.data, " "version = EXCLUDED.version, data = EXCLUDED.data, "
"updated_at = EXCLUDED.updated_at", "updated_at = EXCLUDED.updated_at",
( (
slot_id, strategy_id, current_occupancy_seq, slot_id,
result.version, data, datetime.now(tz=timezone.utc), strategy_id,
current_occupancy_seq,
result.version,
data,
datetime.now(tz=timezone.utc),
), ),
) )
conn.commit() conn.commit()
@@ -641,7 +711,5 @@ class PgStrategyStateStore:
def delete_all(self, slot_id: UUID) -> None: def delete_all(self, slot_id: UUID) -> None:
with self._psycopg.connect(self._dsn, autocommit=False) as conn: with self._psycopg.connect(self._dsn, autocommit=False) as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute( cur.execute("DELETE FROM slot_strategy_state WHERE slot_id = %s", (slot_id,))
"DELETE FROM slot_strategy_state WHERE slot_id = %s", (slot_id,)
)
conn.commit() conn.commit()
+182 -62
View File
@@ -20,10 +20,11 @@ All slot writes go through SlotService (advisory lock). The broker is touched
only here, never by the domain or the HTTP API. The ledger outbox is the only here, never by the domain or the HTTP API. The ledger outbox is the
single producer for ledger work items (NFR-3, single record subject). single producer for ledger work items (NFR-3, single record subject).
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass, replace
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Callable, Optional from typing import Callable, Optional
@@ -48,7 +49,9 @@ _log = logging.getLogger(__name__)
# _apply_transition_fields). The emitter MUST prefer this hint over # _apply_transition_fields). The emitter MUST prefer this hint over
# `slot.strategy_id` so that slot_filled_sell events reach the correct # `slot.strategy_id` so that slot_filled_sell events reach the correct
# per-strategy queue instead of being NO_ROUTEd to the DLQ. # per-strategy queue instead of being NO_ROUTEd to the DLQ.
SlotEventEmitter = Callable[[str, object, Optional[str]], None] # (event_type, slot, routing_strategy_id) -> None SlotEventEmitter = Callable[
[str, object, Optional[str]], None
] # (event_type, slot, routing_strategy_id) -> None
_MAX_DISPATCH_PER_TICK = 64 _MAX_DISPATCH_PER_TICK = 64
@@ -58,11 +61,11 @@ class ExecutionResult:
dispatched: int = 0 dispatched: int = 0
filled: int = 0 filled: int = 0
rolled_back: int = 0 rolled_back: int = 0
ledger_enqueued: int = 0 # REQ-019: ledger work items appended this tick ledger_enqueued: int = 0 # REQ-019: ledger work items appended this tick
ledger_skipped: int = 0 # REQ-018 AC-4: fills without portfolio_id ledger_skipped: int = 0 # REQ-018 AC-4: fills without portfolio_id
stale_pending: int = 0 # dcy-013 / REQ-014 FR-3: pending orders over threshold stale_pending: int = 0 # dcy-013 / REQ-014 FR-3: pending orders over threshold
sell_skipped_no_balance: int = 0 # dcy-020 / REQ-062: broker flat -> sell skipped sell_skipped_no_balance: int = 0 # dcy-020 / REQ-062: broker flat -> sell skipped
timeout_cancelled: int = 0 # dcy-022 / REQ-069: cancel_order sent on timeout timeout_cancelled: int = 0 # dcy-022 / REQ-069: cancel_order sent on timeout
class ExecutionService: class ExecutionService:
@@ -139,7 +142,8 @@ class ExecutionService:
_log.warning( _log.warning(
"sell dispatch skipped: slot=%s symbol=%s broker balance is 0 " "sell dispatch skipped: slot=%s symbol=%s broker balance is 0 "
"(divergence — no order submitted; operator/reconcile attention)", "(divergence — no order submitted; operator/reconcile attention)",
slot.slot_id, slot.symbol, slot.slot_id,
slot.symbol,
) )
# dcy-033 / REQ-100: CRITICAL alarm — PENDING_SELL may stick. # dcy-033 / REQ-100: CRITICAL alarm — PENDING_SELL may stick.
if self._alarm_emitter is not None: if self._alarm_emitter is not None:
@@ -175,7 +179,12 @@ class ExecutionService:
_log.debug( _log.debug(
"slippage applied: slot=%s side=%s base_limit=%s pct=%.4f " "slippage applied: slot=%s side=%s base_limit=%s pct=%.4f "
"raw=%.2f effective=%s (tick-snapped)", "raw=%.2f effective=%s (tick-snapped)",
slot.slot_id, side, slot.limit_price, slot.slippage_pct, raw, limit, slot.slot_id,
side,
slot.limit_price,
slot.slippage_pct,
raw,
limit,
) )
# REQ-084/085: correlation id (intent→order→fill join key = slot_id) # REQ-084/085: correlation id (intent→order→fill join key = slot_id)
# so submit/fill/reject logs for one slot share a key. # so submit/fill/reject logs for one slot share a key.
@@ -193,20 +202,41 @@ class ExecutionService:
if ack.accepted and ack.broker_order_id: if ack.accepted and ack.broker_order_id:
self._repo.mark_dispatched(slot.slot_id, ack.broker_order_id) self._repo.mark_dispatched(slot.slot_id, ack.broker_order_id)
r.dispatched += 1 r.dispatched += 1
# REQ-085: order-result logging — tailable submit line. if ack.reason and ack.reason.startswith("submit_outcome_unknown:"):
_log.info( _log.warning(
"order submitted event=order_submit side=%s symbol=%s slot_id=%s " "order submission outcome unknown event=order_submit_unknown "
"qty=%s limit_price=%s order_type=%s", "side=%s symbol=%s slot_id=%s qty=%s limit_price=%s "
side, slot.symbol, slot.slot_id, qty, limit, "automatic_retry=blocked reason=%s",
"00" if limit is not None else "01", side,
) slot.symbol,
slot.slot_id,
qty,
limit,
ack.reason,
)
else:
# 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: else:
# REQ-085/087: reject is visible (event key) and ERROR level # REQ-085/087: reject is visible (event key) and ERROR level
# (no more silent rollback — issue #28 surfaced too late). # (no more silent rollback — issue #28 surfaced too late).
_log.error( _log.error(
"order rejected event=order_reject side=%s symbol=%s slot_id=%s " "order rejected event=order_reject side=%s symbol=%s slot_id=%s "
"qty=%s reason=%s", "qty=%s reason=%s",
side, slot.symbol, slot.slot_id, qty, ack.reason or "order_rejected", side,
slot.symbol,
slot.slot_id,
qty,
ack.reason or "order_rejected",
) )
self._rollback(slot, reason=ack.reason or "order_rejected") self._rollback(slot, reason=ack.reason or "order_rejected")
r.rolled_back += 1 r.rolled_back += 1
@@ -217,6 +247,30 @@ class ExecutionService:
side = "buy" if slot.status is SlotState.PENDING_BUY else "sell" side = "buy" if slot.status is SlotState.PENDING_BUY else "sell"
set_correlation_id(str(slot.slot_id)) # REQ-084: same key as submit 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 "") fs = self._broker.poll_fill(slot.broker_order_id, side=side, symbol=slot.symbol or "")
resolved_order_id = (
fs.raw.get("resolved_broker_order_id") if isinstance(fs.raw, dict) else None
)
if resolved_order_id and resolved_order_id != slot.broker_order_id:
if not self._repo.replace_dispatch(
slot.slot_id,
str(slot.broker_order_id),
str(resolved_order_id),
):
_log.error(
"resolved broker order id persistence failed "
"event=order_submit_resolution_conflict slot_id=%s symbol=%s",
slot.slot_id,
slot.symbol,
)
continue
slot = replace(slot, broker_order_id=str(resolved_order_id))
_log.info(
"order submission resolved event=order_submit_resolved "
"side=%s symbol=%s slot_id=%s",
side,
slot.symbol,
slot.slot_id,
)
if fs.is_filled: if fs.is_filled:
self._on_filled(slot, fs, side, r) self._on_filled(slot, fs, side, r)
r.filled += 1 r.filled += 1
@@ -230,7 +284,10 @@ class ExecutionService:
_log.error( _log.error(
"order rejected event=order_reject side=%s symbol=%s slot_id=%s " "order rejected event=order_reject side=%s symbol=%s slot_id=%s "
"reason=order_%s", "reason=order_%s",
side, slot.symbol, slot.slot_id, fs.state, side,
slot.symbol,
slot.slot_id,
fs.state,
) )
self._rollback(slot, reason=f"order_{fs.state}") self._rollback(slot, reason=f"order_{fs.state}")
r.rolled_back += 1 r.rolled_back += 1
@@ -270,8 +327,11 @@ class ExecutionService:
_log.warning( _log.warning(
"fill price backfill: slot=%s symbol=%s broker_order_id=%s " "fill price backfill: slot=%s symbol=%s broker_order_id=%s "
"avg_price=%s backfill_price=%s reason=avg_price_backfill", "avg_price=%s backfill_price=%s reason=avg_price_backfill",
slot.slot_id, slot.symbol, slot.broker_order_id, slot.slot_id,
avg, limit, slot.symbol,
slot.broker_order_id,
avg,
limit,
) )
return float(limit), True return float(limit), True
# limit_price also absent/≤0: skip + strong WARN (REQ-095 AC-6 / REQ-096 AC-2) # limit_price also absent/≤0: skip + strong WARN (REQ-095 AC-6 / REQ-096 AC-2)
@@ -280,16 +340,22 @@ class ExecutionService:
"fill price unresolvable: slot=%s symbol=%s broker_order_id=%s " "fill price unresolvable: slot=%s symbol=%s broker_order_id=%s "
"avg_price=%s limit_price=%s reason=fill_price_unresolvable " "avg_price=%s limit_price=%s reason=fill_price_unresolvable "
"(slot stays PENDING — operator attention required)", "(slot stays PENDING — operator attention required)",
slot.slot_id, slot.symbol, slot.broker_order_id, slot.slot_id,
avg, limit, slot.symbol,
slot.broker_order_id,
avg,
limit,
) )
# dcy-033 / REQ-100: CRITICAL alarm — slot stuck in PENDING. # dcy-033 / REQ-100: CRITICAL alarm — slot stuck in PENDING.
if self._alarm_emitter is not None: if self._alarm_emitter is not None:
self._alarm_emitter.publish_alarm( self._alarm_emitter.publish_alarm(
"fill_price_unresolvable", "fill_price_unresolvable",
f"체결가 미해결: 슬롯 PENDING 고착 위험 (slot={slot.slot_id} symbol={slot.symbol})", f"체결가 미해결: 슬롯 PENDING 고착 위험 (slot={slot.slot_id} symbol={slot.symbol})",
fields={"slot_id": str(slot.slot_id), "symbol": slot.symbol, fields={
"broker_order_id": slot.broker_order_id}, "slot_id": str(slot.slot_id),
"symbol": slot.symbol,
"broker_order_id": slot.broker_order_id,
},
) )
return None, False return None, False
@@ -318,12 +384,17 @@ class ExecutionService:
old_avg = float(slot.avg_price) if slot.avg_price else 0.0 old_avg = float(slot.avg_price) if slot.avg_price else 0.0
new_avg = ( new_avg = (
(old_qty * old_avg + filled * fill_price) / new_qty (old_qty * old_avg + filled * fill_price) / new_qty
if new_qty > 0 else fill_price if new_qty > 0
else fill_price
) )
updated = self._svc.transit_slot( updated = self._svc.transit_slot(
slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, slot.slot_id,
reason="buy_filled", actor="execution_loop", SlotState.PENDING_BUY,
avg_price=new_avg, qty=new_qty, SlotState.HOLDING,
reason="buy_filled",
actor="execution_loop",
avg_price=new_avg,
qty=new_qty,
) )
event_type = "slot_filled_buy" event_type = "slot_filled_buy"
else: # PENDING_SELL — dcy-021/REQ-066: remainder>0 stays holding, else: # PENDING_SELL — dcy-021/REQ-066: remainder>0 stays holding,
@@ -331,14 +402,20 @@ class ExecutionService:
total = self._balance_map().get(slot.symbol or "", 0) total = self._balance_map().get(slot.symbol or "", 0)
if total > 0: if total > 0:
updated = self._svc.transit_slot( updated = self._svc.transit_slot(
slot.slot_id, SlotState.PENDING_SELL, SlotState.HOLDING, slot.slot_id,
reason="sell_filled_partial", actor="execution_loop", SlotState.PENDING_SELL,
SlotState.HOLDING,
reason="sell_filled_partial",
actor="execution_loop",
qty=float(total), # avg_price (cost basis) preserved qty=float(total), # avg_price (cost basis) preserved
) )
else: else:
updated = self._svc.transit_slot( updated = self._svc.transit_slot(
slot.slot_id, SlotState.PENDING_SELL, SlotState.INACTIVE, slot.slot_id,
reason="sell_filled", actor="execution_loop", SlotState.PENDING_SELL,
SlotState.INACTIVE,
reason="sell_filled",
actor="execution_loop",
) )
event_type = "slot_filled_sell" event_type = "slot_filled_sell"
except SlotStateMismatchError: except SlotStateMismatchError:
@@ -347,9 +424,13 @@ class ExecutionService:
_log.info( _log.info(
"order filled event=order_fill side=%s symbol=%s slot_id=%s " "order filled event=order_fill side=%s symbol=%s slot_id=%s "
"fill_price=%s filled_qty=%s fee=%s tax=%s", "fill_price=%s filled_qty=%s fee=%s tax=%s",
side, slot.symbol, slot.slot_id, side,
fs.avg_price, fs.filled_qty, slot.symbol,
getattr(fs, "fee", None), getattr(fs, "tax", None), 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. # REQ-018: enqueue ledger work item AFTER the slot transition is durable.
# Slot transition is the critical path; ledger write is async (REQ-019). # Slot transition is the critical path; ledger write is async (REQ-019).
@@ -389,26 +470,37 @@ class ExecutionService:
old_avg = float(slot.avg_price) if slot.avg_price else 0.0 old_avg = float(slot.avg_price) if slot.avg_price else 0.0
new_avg = ( new_avg = (
(old_qty * old_avg + filled * fill_price) / new_qty (old_qty * old_avg + filled * fill_price) / new_qty
if new_qty > 0 else fill_price if new_qty > 0
else fill_price
) )
updated = self._svc.transit_slot( updated = self._svc.transit_slot(
slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, slot.slot_id,
reason="buy_partial_cancel", actor="execution_loop", SlotState.PENDING_BUY,
avg_price=new_avg, qty=new_qty, SlotState.HOLDING,
reason="buy_partial_cancel",
actor="execution_loop",
avg_price=new_avg,
qty=new_qty,
) )
event_type = "slot_filled_buy" event_type = "slot_filled_buy"
else: # PENDING_SELL: remainder = broker balance, cost basis unchanged. else: # PENDING_SELL: remainder = broker balance, cost basis unchanged.
total = self._balance_map().get(slot.symbol or "", 0) total = self._balance_map().get(slot.symbol or "", 0)
if total > 0: if total > 0:
updated = self._svc.transit_slot( updated = self._svc.transit_slot(
slot.slot_id, SlotState.PENDING_SELL, SlotState.HOLDING, slot.slot_id,
reason="sell_partial_cancel", actor="execution_loop", SlotState.PENDING_SELL,
SlotState.HOLDING,
reason="sell_partial_cancel",
actor="execution_loop",
qty=float(total), qty=float(total),
) )
else: else:
updated = self._svc.transit_slot( updated = self._svc.transit_slot(
slot.slot_id, SlotState.PENDING_SELL, SlotState.INACTIVE, slot.slot_id,
reason="sell_partial_cancel", actor="execution_loop", SlotState.PENDING_SELL,
SlotState.INACTIVE,
reason="sell_partial_cancel",
actor="execution_loop",
) )
event_type = "slot_filled_sell" event_type = "slot_filled_sell"
except SlotStateMismatchError: except SlotStateMismatchError:
@@ -431,13 +523,14 @@ class ExecutionService:
_log.warning( _log.warning(
"pending order stale: slot=%s status=%s broker_order_id=%s " "pending order stale: slot=%s status=%s broker_order_id=%s "
"age_sec=%.0f threshold_sec=%d (no state change — ops attention)", "age_sec=%.0f threshold_sec=%d (no state change — ops attention)",
slot.slot_id, slot.status.value, slot.broker_order_id, slot.slot_id,
age, self._stale_threshold_sec, slot.status.value,
slot.broker_order_id,
age,
self._stale_threshold_sec,
) )
def _try_timeout_cancel( def _try_timeout_cancel(self, slot, now: datetime, side: str, r: ExecutionResult) -> None:
self, slot, now: datetime, side: str, r: ExecutionResult
) -> None:
"""Send cancel_order when the slot-level timeout is exceeded (dcy-022/REQ-069). """Send cancel_order when the slot-level timeout is exceeded (dcy-022/REQ-069).
Best-effort: a failure only logs a warning — the slot is NOT changed here. Best-effort: a failure only logs a warning — the slot is NOT changed here.
@@ -446,6 +539,8 @@ class ExecutionService:
This design is safe across the cancel-fill race: if the cancel races with a This design is safe across the cancel-fill race: if the cancel races with a
fill, the next poll sees is_filled and follows the normal fill path (FR-4). fill, the next poll sees is_filled and follows the normal fill path (FR-4).
""" """
if str(slot.broker_order_id or "").startswith("submission-unknown:"):
return
if slot.timeout_sec is None or slot.updated_at is None: if slot.timeout_sec is None or slot.updated_at is None:
return return
age = (now - slot.updated_at).total_seconds() age = (now - slot.updated_at).total_seconds()
@@ -463,14 +558,19 @@ class ExecutionService:
_log.warning( _log.warning(
"timeout cancel sent: slot=%s status=%s broker_order_id=%s " "timeout cancel sent: slot=%s status=%s broker_order_id=%s "
"age_sec=%.0f timeout_sec=%d broker_accepted=%s", "age_sec=%.0f timeout_sec=%d broker_accepted=%s",
slot.slot_id, slot.status.value, slot.broker_order_id, slot.slot_id,
age, slot.timeout_sec, accepted, slot.status.value,
slot.broker_order_id,
age,
slot.timeout_sec,
accepted,
) )
except Exception: # noqa: BLE001 - best-effort; do not block the loop except Exception: # noqa: BLE001 - best-effort; do not block the loop
_log.warning( _log.warning(
"timeout cancel failed (exception): slot=%s broker_order_id=%s " "timeout cancel failed (exception): slot=%s broker_order_id=%s "
"(will retry next tick if still pending)", "(will retry next tick if still pending)",
slot.slot_id, slot.broker_order_id, slot.slot_id,
slot.broker_order_id,
) )
def _emit_event(self, event_type: str, slot, routing_strategy_id: Optional[str] = None) -> None: def _emit_event(self, event_type: str, slot, routing_strategy_id: Optional[str] = None) -> None:
@@ -507,7 +607,9 @@ class ExecutionService:
_log.warning( _log.warning(
"ledger skip: slot=%s symbol=%s side=%s reason=missing_portfolio_id " "ledger skip: slot=%s symbol=%s side=%s reason=missing_portfolio_id "
"(trade NOT recorded to portfolio-hub)", "(trade NOT recorded to portfolio-hub)",
slot.slot_id, slot.symbol, side, slot.slot_id,
slot.symbol,
side,
) )
return return
if fs.execution_id is None: if fs.execution_id is None:
@@ -516,13 +618,17 @@ class ExecutionService:
_log.warning( _log.warning(
"ledger skip: slot=%s portfolio_id=%s side=%s reason=missing_execution_id " "ledger skip: slot=%s portfolio_id=%s side=%s reason=missing_execution_id "
"(trade NOT recorded to portfolio-hub)", "(trade NOT recorded to portfolio-hub)",
slot.slot_id, slot.portfolio_id, side, slot.slot_id,
slot.portfolio_id,
side,
) )
return return
# REQ-095 KD-3: use pre-resolved price when available; fall back to # REQ-095 KD-3: use pre-resolved price when available; fall back to
# fs.avg_price for any direct callers that do not pass resolved_price. # fs.avg_price for any direct callers that do not pass resolved_price.
price = resolved_price if resolved_price is not None else ( price = (
float(fs.avg_price) if fs.avg_price is not None else 0.0 resolved_price
if resolved_price is not None
else (float(fs.avg_price) if fs.avg_price is not None else 0.0)
) )
trade = LedgerTrade( trade = LedgerTrade(
asset_id=str(slot.portfolio_id), asset_id=str(slot.portfolio_id),
@@ -546,6 +652,7 @@ class ExecutionService:
next poll handles the rollback via the normal is_terminal_reject path. next poll handles the rollback via the normal is_terminal_reject path.
""" """
from uuid import UUID from uuid import UUID
sid = slot_id if isinstance(slot_id, UUID) else UUID(str(slot_id)) sid = slot_id if isinstance(slot_id, UUID) else UUID(str(slot_id))
slot = self._repo.fetch_slot(sid) slot = self._repo.fetch_slot(sid)
if slot is None: if slot is None:
@@ -567,12 +674,15 @@ class ExecutionService:
except Exception: # noqa: BLE001 - best-effort except Exception: # noqa: BLE001 - best-effort
_log.warning( _log.warning(
"operator cancel failed: slot=%s broker_order_id=%s", "operator cancel failed: slot=%s broker_order_id=%s",
slot.slot_id, slot.broker_order_id, slot.slot_id,
slot.broker_order_id,
) )
return "cancel_failed" return "cancel_failed"
_log.warning( _log.warning(
"operator cancel sent: slot=%s status=%s broker_order_id=%s", "operator cancel sent: slot=%s status=%s broker_order_id=%s",
slot.slot_id, slot.status.value, slot.broker_order_id, slot.slot_id,
slot.status.value,
slot.broker_order_id,
) )
return "ok" return "ok"
@@ -584,19 +694,29 @@ class ExecutionService:
# dcy-021: increase rejected — the prior holding is intact # dcy-021: increase rejected — the prior holding is intact
# (broker still holds), so return to holding, not inactive. # (broker still holds), so return to holding, not inactive.
self._svc.transit_slot( self._svc.transit_slot(
slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING, slot.slot_id,
reason=reason, actor="execution_loop", qty=float(total), SlotState.PENDING_BUY,
SlotState.HOLDING,
reason=reason,
actor="execution_loop",
qty=float(total),
) )
self._repo.clear_dispatch(slot.slot_id) self._repo.clear_dispatch(slot.slot_id)
else: # initial buy rejected -> inactive (clears broker_order_id) else: # initial buy rejected -> inactive (clears broker_order_id)
self._svc.transit_slot( self._svc.transit_slot(
slot.slot_id, SlotState.PENDING_BUY, SlotState.INACTIVE, slot.slot_id,
reason=reason, actor="execution_loop", SlotState.PENDING_BUY,
SlotState.INACTIVE,
reason=reason,
actor="execution_loop",
) )
else: # PENDING_SELL rejected -> back to holding (broker authoritative qty) else: # PENDING_SELL rejected -> back to holding (broker authoritative qty)
self._svc.transit_slot( self._svc.transit_slot(
slot.slot_id, SlotState.PENDING_SELL, SlotState.HOLDING, slot.slot_id,
reason=reason, actor="execution_loop", SlotState.PENDING_SELL,
SlotState.HOLDING,
reason=reason,
actor="execution_loop",
qty=float(total) if total > 0 else None, qty=float(total) if total > 0 else None,
) )
self._repo.clear_dispatch(slot.slot_id) self._repo.clear_dispatch(slot.slot_id)
+63 -28
View File
@@ -3,6 +3,7 @@
Simulates advisory lock via threading.Lock so contention tests are still Simulates advisory lock via threading.Lock so contention tests are still
meaningful even without a real PostgreSQL instance. meaningful even without a real PostgreSQL instance.
""" """
from __future__ import annotations from __future__ import annotations
import threading import threading
@@ -51,11 +52,10 @@ class InMemorySlotRepository:
"""Test seeding helper — insert a slot row directly.""" """Test seeding helper — insert a slot row directly."""
self._slots[slot.slot_id] = slot self._slots[slot.slot_id] = slot
def list_slots( def list_slots(self, instance_key: str, status_filter: Optional[set[str]] = None) -> list[Slot]:
self, instance_key: str, status_filter: Optional[set[str]] = None
) -> list[Slot]:
return [ return [
s for s in self._slots.values() s
for s in self._slots.values()
if s.instance_key == instance_key if s.instance_key == instance_key
and (status_filter is None or s.status.value in status_filter) and (status_filter is None or s.status.value in status_filter)
] ]
@@ -79,52 +79,62 @@ class InMemorySlotRepository:
def count_active(self, instance_key: str) -> int: def count_active(self, instance_key: str) -> int:
return sum( return sum(
1 for s in self._slots.values() 1
for s in self._slots.values()
if s.instance_key == instance_key and s.status in self._ACTIVE if s.instance_key == instance_key and s.status in self._ACTIVE
) )
def list_holding_slots(self, instance_key: str) -> list[Slot]: def list_holding_slots(self, instance_key: str) -> list[Slot]:
return [ return [
s for s in self._slots.values() s
for s in self._slots.values()
if s.instance_key == instance_key and s.status is SlotState.HOLDING if s.instance_key == instance_key and s.status is SlotState.HOLDING
] ]
def find_managed_slot_by_symbol(self, instance_key: str, symbol: str) -> Optional[Slot]: def find_managed_slot_by_symbol(self, instance_key: str, symbol: str) -> Optional[Slot]:
candidates = [ candidates = [
s for s in self._slots.values() s for s in self._slots.values() if s.instance_key == instance_key and s.symbol == symbol
if s.instance_key == instance_key and s.symbol == symbol
] ]
# PostgreSQL parity: updated_at DESC NULLS LAST, slot_id ASC. # PostgreSQL parity: updated_at DESC NULLS LAST, slot_id ASC.
candidates.sort(key=lambda s: ( candidates.sort(
s.updated_at is None, key=lambda s: (
-s.updated_at.timestamp() if s.updated_at is not None else 0, s.updated_at is None,
str(s.slot_id), -s.updated_at.timestamp() if s.updated_at is not None else 0,
)) str(s.slot_id),
)
)
return candidates[0] if candidates else None return candidates[0] if candidates else None
def reconcile_universe( def reconcile_universe(
self, instance_key: str, universe: dict[str, str], self,
*, allow_retire: bool = True, instance_key: str,
universe: dict[str, str],
*,
allow_retire: bool = True,
) -> dict[str, int]: ) -> dict[str, int]:
existing = { existing = {
s.portfolio_id: s for s in self._slots.values() s.portfolio_id: s
for s in self._slots.values()
if s.instance_key == instance_key and s.portfolio_id is not None if s.instance_key == instance_key and s.portfolio_id is not None
} }
now = datetime.now(tz=timezone.utc) now = datetime.now(tz=timezone.utc)
created = retired = tombstoned = 0 created = retired = tombstoned = 0
# backfill symbol on slots that were created before this field was populated # backfill symbol on slots that were created before this field was populated
for s in self._slots.values(): for s in self._slots.values():
if (s.instance_key == instance_key and s.portfolio_id in universe if s.instance_key == instance_key and s.portfolio_id in universe and s.symbol is None:
and s.symbol is None):
self._slots[s.slot_id] = replace(s, symbol=universe[s.portfolio_id], updated_at=now) self._slots[s.slot_id] = replace(s, symbol=universe[s.portfolio_id], updated_at=now)
# create asset slots for newly-appeared universe assets (idempotent) # create asset slots for newly-appeared universe assets (idempotent)
for pid, symbol in universe.items(): for pid, symbol in universe.items():
if pid not in existing: if pid not in existing:
sid = uuid4() sid = uuid4()
self._slots[sid] = Slot( self._slots[sid] = Slot(
slot_id=sid, status=SlotState.INACTIVE, slot_id=sid,
instance_key=instance_key, portfolio_id=pid, symbol=symbol, status=SlotState.INACTIVE,
created_at=now, updated_at=now, instance_key=instance_key,
portfolio_id=pid,
symbol=symbol,
created_at=now,
updated_at=now,
) )
created += 1 created += 1
# retire / tombstone assets that left the universe (skipped under the # retire / tombstone assets that left the universe (skipped under the
@@ -155,7 +165,9 @@ class InMemorySlotRepository:
_PENDING = (SlotState.PENDING_BUY, SlotState.PENDING_SELL) _PENDING = (SlotState.PENDING_BUY, SlotState.PENDING_SELL)
def list_undispatched_pending( def list_undispatched_pending(
self, instance_key: str = "default", limit: int = 64, self,
instance_key: str = "default",
limit: int = 64,
) -> list[Slot]: ) -> list[Slot]:
"""Pending slots not yet dispatched (broker_order_id IS NULL). """Pending slots not yet dispatched (broker_order_id IS NULL).
@@ -163,9 +175,13 @@ class InMemorySlotRepository:
never dispatch each other's orders. List-based (vs single-row claim) so never dispatch each other's orders. List-based (vs single-row claim) so
one skipped sell (broker flat) cannot head-block the dispatch queue.""" one skipped sell (broker flat) cannot head-block the dispatch queue."""
out = [ out = [
s for s in self._slots.values() s
if (s.instance_key == instance_key and s.status in self._PENDING for s in self._slots.values()
and s.broker_order_id is None) if (
s.instance_key == instance_key
and s.status in self._PENDING
and s.broker_order_id is None
)
] ]
return out[:limit] return out[:limit]
@@ -179,12 +195,31 @@ class InMemorySlotRepository:
) )
return True return True
def replace_dispatch(
self,
slot_id: UUID,
expected_broker_order_id: str,
broker_order_id: str,
) -> bool:
"""Replace a result-unknown sentinel with the broker-confirmed order ID."""
s = self._slots.get(slot_id)
if s is None or s.broker_order_id != expected_broker_order_id:
return False
self._slots[slot_id] = replace(
s, broker_order_id=broker_order_id, updated_at=datetime.now(tz=timezone.utc)
)
return True
def list_dispatched_pending(self, instance_key: str = "default") -> list[Slot]: def list_dispatched_pending(self, instance_key: str = "default") -> list[Slot]:
"""Pending slots already dispatched (broker_order_id NOT NULL) — poll targets.""" """Pending slots already dispatched (broker_order_id NOT NULL) — poll targets."""
return [ return [
s for s in self._slots.values() s
if (s.instance_key == instance_key and s.status in self._PENDING for s in self._slots.values()
and s.broker_order_id is not None) if (
s.instance_key == instance_key
and s.status in self._PENDING
and s.broker_order_id is not None
)
] ]
def clear_dispatch(self, slot_id: UUID) -> None: def clear_dispatch(self, slot_id: UUID) -> None:
+77
View File
@@ -0,0 +1,77 @@
{
"schema_version": "trading-validation-project/v1",
"project_id": "trex-core-execution",
"project_root": "..",
"adapter_version": "v1",
"require_clean_worktree": true,
"stages": [
{
"id": "logic",
"command": ["uv", "run", "pytest", "-q", "tests/application/test_execution.py"],
"timeout_seconds": 300
},
{
"id": "regression",
"command": ["uv", "run", "pytest", "-q"],
"timeout_seconds": 900
},
{
"id": "virtual_contract",
"command": ["uv", "run", "pytest", "-q", "tests/adapters/broker/test_kis.py", "tests/adapters/broker/test_kis_cancel.py"],
"timeout_seconds": 300
},
{
"id": "virtual_e2e",
"command": ["uv", "run", "pytest", "-q", "tests/integration/test_kis_submission_recovery_virtual.py"],
"timeout_seconds": 300
},
{
"id": "fault_injection",
"command": ["uv", "run", "pytest", "-q", "tests/integration/test_kis_submission_recovery_virtual.py", "tests/adapters/broker/test_kis.py"],
"timeout_seconds": 300
},
{
"id": "restart_recovery",
"command": ["uv", "run", "pytest", "-q", "tests/integration/test_kis_submission_recovery_virtual.py", "tests/application/test_execution.py"],
"timeout_seconds": 300
},
{
"id": "ledger_reconcile",
"command": ["uv", "run", "python", "scripts/virtual_execution_final_state.py"],
"timeout_seconds": 300,
"required_files": ["${RUN_DIR}/ledger_reconcile/final_state.json"],
"json_checks": [
{
"file": "${RUN_DIR}/ledger_reconcile/final_state.json",
"pointer": "/status",
"equals": "PASS"
},
{
"file": "${RUN_DIR}/ledger_reconcile/final_state.json",
"pointer": "/open_orders",
"equals": 0
},
{
"file": "${RUN_DIR}/ledger_reconcile/final_state.json",
"pointer": "/positions",
"equals": 0
},
{
"file": "${RUN_DIR}/ledger_reconcile/final_state.json",
"pointer": "/duplicate_orders",
"equals": 0
},
{
"file": "${RUN_DIR}/ledger_reconcile/final_state.json",
"pointer": "/ledger_consistent",
"equals": true
},
{
"file": "${RUN_DIR}/ledger_reconcile/final_state.json",
"pointer": "/real_order_allowed",
"equals": false
}
]
}
]
}