fix: reconcile ambiguous KIS order submissions
This commit is contained in:
@@ -397,3 +397,20 @@ pause 후 동작:
|
||||
- `before`/`after`는 write/미리보기 비교에 사용하며, dry-run은 `warning_cleared=False`로 고정
|
||||
|
||||
`sync --dry-run`은 실제 DB 쓰기·warning clear를 하지 않으므로 운영자가 미리보기를 확인한 뒤 직접 승인된 live 실행으로 전환한다.
|
||||
|
||||
### KIS 주문 결과불명 복구
|
||||
|
||||
- 주문 POST의 transport 오류 또는 게이트웨이 5xx는 거절로 단정하지 않는다.
|
||||
Core는 종목·방향·수량·가격·시장·제출시각이 포함된 내구성
|
||||
`submission-unknown` 식별자를 슬롯에 저장해 같은 주문의 즉시 재제출을 막는다.
|
||||
- 주문·체결 조회가 유일한 일치 주문을 찾으면 실제 브로커 주문번호로 슬롯을
|
||||
원자적으로 교체한 뒤 정상 체결·취소 경로를 계속한다. 재시작 후에도 저장된 실제
|
||||
주문번호를 조회하므로 중복 주문을 만들지 않는다.
|
||||
- 조회가 성공했지만 제출 후 90초가 지나도록 일치 주문이 없으면 실제 미접수로
|
||||
확정하고 슬롯을 이전 상태로 되돌린다. 다음 전략 신호부터 새 주문 제출이 가능하다.
|
||||
조회 실패 또는 후보가 여러 개면 결과불명 상태를 유지한다.
|
||||
- 정상 체결조회는 주문별 최소 5초 간격, transport 실패 뒤에는 30초 간격으로
|
||||
제한한다. 전략의 미체결 취소 시간과는 별도이며 결과불명 주문은 자동 취소하지 않는다.
|
||||
- 회귀 검증은 KIS 호환 로컬 가상거래소에서 `접수 후 응답 유실`과
|
||||
`접수 전 응답 유실`을 각각 장애주입하고, 주문 중복 0·재시작 복구·확정 미접수
|
||||
이후에만 재시도됨을 확인한다.
|
||||
|
||||
@@ -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())
|
||||
@@ -3,10 +3,12 @@
|
||||
Unit tests run always; the sandbox integration test runs only when KIS
|
||||
모의투자 credentials are present (TREX_KIS_BROKER_* env / .env).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
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():
|
||||
with pytest.raises(RuntimeError):
|
||||
KISBroker("12345678-01", app_key="k", app_secret="s",
|
||||
base_url="https://openapi.koreainvestment.com:9443")
|
||||
KISBroker(
|
||||
"12345678-01",
|
||||
app_key="k",
|
||||
app_secret="s",
|
||||
base_url="https://openapi.koreainvestment.com:9443",
|
||||
)
|
||||
# gated on explicitly
|
||||
b = KISBroker("12345678-01", app_key="k", app_secret="s",
|
||||
base_url="https://openapi.koreainvestment.com:9443", allow_live=True)
|
||||
b = KISBroker(
|
||||
"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"
|
||||
|
||||
|
||||
def _kis_env():
|
||||
keys = ("TREX_KIS_BROKER_BASE_URL", "TREX_KIS_BROKER_APP_KEY",
|
||||
"TREX_KIS_BROKER_APP_SECRET", "TREX_KIS_BROKER_ACCOUNT_REF")
|
||||
keys = (
|
||||
"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):
|
||||
# try .env via dotenv
|
||||
try:
|
||||
from dotenv import load_dotenv, find_dotenv
|
||||
|
||||
load_dotenv(find_dotenv(usecwd=True), override=False)
|
||||
except ImportError:
|
||||
pass
|
||||
@@ -68,8 +84,11 @@ def test_sandbox_token_and_submit():
|
||||
assert isinstance(token, str) and len(token) > 0
|
||||
|
||||
# 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,
|
||||
limit_price=10000, order_type="00"))
|
||||
ack = b.submit_order(
|
||||
OrderRequest(
|
||||
slot_id="t", side="buy", symbol="005930", qty=1, limit_price=10000, order_type="00"
|
||||
)
|
||||
)
|
||||
assert ack.raw, "expected a KIS response payload"
|
||||
if ack.accepted:
|
||||
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: {})
|
||||
|
||||
class Response:
|
||||
def raise_for_status(self): pass
|
||||
def json(self): return {"rt_cd": "1", "output1": []}
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
def json(self):
|
||||
return {"rt_cd": "1", "output1": []}
|
||||
|
||||
monkeypatch.setattr("trex_core.adapters.broker.kis.httpx.get", lambda *a, **kw: Response())
|
||||
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: {})
|
||||
|
||||
class Response:
|
||||
def raise_for_status(self): pass
|
||||
def json(self): return {"rt_cd": "0", "output1": []}
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
def json(self):
|
||||
return {"rt_cd": "0", "output1": []}
|
||||
|
||||
monkeypatch.setattr("trex_core.adapters.broker.kis.httpx.get", lambda *a, **kw: Response())
|
||||
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):
|
||||
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:
|
||||
b.get_balance()
|
||||
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)
|
||||
|
||||
class Response:
|
||||
def raise_for_status(self): pass
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
def json(self):
|
||||
return {
|
||||
"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())
|
||||
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
|
||||
|
||||
@@ -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
|
||||
mapping for full cancel / partial-fill-then-cancel / not-cancelled.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -17,7 +18,8 @@ ODNO = "0000123456"
|
||||
def _broker():
|
||||
b = KISBroker(
|
||||
"12345678-01",
|
||||
app_key="k", app_secret="s",
|
||||
app_key="k",
|
||||
app_secret="s",
|
||||
base_url="https://openapivts.koreainvestment.com:29443", # mock endpoint
|
||||
)
|
||||
b._token = "tok"
|
||||
@@ -36,14 +38,22 @@ class _Resp:
|
||||
def _fake_get(payload):
|
||||
def _get(*a, **k):
|
||||
return _Resp(payload)
|
||||
|
||||
return _get
|
||||
|
||||
|
||||
def _row(**kw):
|
||||
base = {"odno": ODNO, "ord_qty": "10", "tot_ccld_qty": "0",
|
||||
"avg_prvs": "0", "cncl_yn": "N", "ord_dt": "20260608", "ord_gno_brno": "06010"}
|
||||
base = {
|
||||
"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)
|
||||
return {"output1": [base]}
|
||||
return {"rt_cd": "0", "output1": [base]}
|
||||
|
||||
|
||||
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):
|
||||
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")),
|
||||
)
|
||||
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):
|
||||
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")),
|
||||
)
|
||||
fs = _broker().poll_fill(f"06010:{ODNO}", side="sell", symbol="005930")
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
In-memory adapters; deterministic paper double drives the full async path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from trex_core.adapters.broker.paper import PaperBroker
|
||||
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.intents import BuyIntent, IntentService, SellIntent
|
||||
from trex_core.domain.slot import SlotService, SlotState
|
||||
@@ -19,9 +21,12 @@ def _wire(broker, max_active_slots=None):
|
||||
repo = InMemorySlotRepository()
|
||||
svc = SlotService(repo, instance_key=INST)
|
||||
intents = IntentService(
|
||||
svc, repo, InMemoryIntentStore(),
|
||||
svc,
|
||||
repo,
|
||||
InMemoryIntentStore(),
|
||||
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)
|
||||
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):
|
||||
return intents.submit_buy(
|
||||
BuyIntent(intent_id=iid, portfolio_id=pid, qty=qty, entry_price=1000)
|
||||
)
|
||||
return intents.submit_buy(BuyIntent(intent_id=iid, portfolio_id=pid, qty=qty, entry_price=1000))
|
||||
|
||||
|
||||
def test_buy_lifecycle_to_holding():
|
||||
@@ -55,6 +58,86 @@ def test_buy_dispatch_is_idempotent_no_double_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():
|
||||
repo, svc, intents, execution = _wire(PaperBroker(reject_symbols={"005931"}))
|
||||
sid = _buy(intents).slot_id
|
||||
@@ -156,8 +239,7 @@ def test_sell_skipped_when_broker_flat():
|
||||
sid = _buy(intents).slot_id
|
||||
execution.run_once() # holding (broker balance 10)
|
||||
broker._orders.clear() # simulate divergence: broker is actually flat
|
||||
svc.transit_slot(sid, SlotState.HOLDING, SlotState.PENDING_SELL,
|
||||
reason="r", actor="t")
|
||||
svc.transit_slot(sid, SlotState.HOLDING, SlotState.PENDING_SELL, reason="r", actor="t")
|
||||
r = execution.run_once()
|
||||
assert r.sell_skipped_no_balance == 1
|
||||
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
|
||||
execution.run_once() # PF-1 holding
|
||||
broker._orders.clear() # broker flat for PF-1's symbol
|
||||
svc.transit_slot(sid, SlotState.HOLDING, SlotState.PENDING_SELL,
|
||||
reason="r", actor="t")
|
||||
svc.transit_slot(sid, SlotState.HOLDING, SlotState.PENDING_SELL, reason="r", actor="t")
|
||||
d2 = _buy(intents, iid="b2", pid="PF-2") # a buy behind the stuck sell
|
||||
r = execution.run_once()
|
||||
assert r.sell_skipped_no_balance == 1
|
||||
@@ -197,7 +278,8 @@ def test_reconcile_after_crash_completes_fill_without_redispatch():
|
||||
def test_capacity_full_blocks_further_buys():
|
||||
"""dcy-020/REQ-059: at M active slots further buys reject capacity_full."""
|
||||
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="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()
|
||||
@@ -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),
|
||||
/uapi/domestic-stock/v1/trading/order-rvsecncl (cancel).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from time import monotonic
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
@@ -27,7 +32,11 @@ from trex_core.application.broker_port import (
|
||||
)
|
||||
|
||||
_KST = timezone(timedelta(hours=9))
|
||||
_SUBMISSION_UNKNOWN_PREFIX = "submission-unknown:"
|
||||
_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]:
|
||||
@@ -37,6 +46,55 @@ def _safe_int(v: object) -> Optional[int]:
|
||||
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:
|
||||
"""KIS REST broker adapter. Live account is gated behind `allow_live`."""
|
||||
|
||||
@@ -59,6 +117,7 @@ class KISBroker:
|
||||
self._token: Optional[str] = None
|
||||
self._token_exp: datetime = datetime.now(tz=timezone.utc)
|
||||
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)
|
||||
if not is_mock and not allow_live:
|
||||
raise RuntimeError(
|
||||
@@ -167,6 +226,7 @@ class KISBroker:
|
||||
# --- BrokerPort ---
|
||||
def submit_order(self, req: OrderRequest) -> OrderAck:
|
||||
cano, product = self._account()
|
||||
submitted_at = datetime.now(tz=_KST)
|
||||
body = {
|
||||
"CANO": cano,
|
||||
"ACNT_PRDT_CD": product,
|
||||
@@ -188,18 +248,44 @@ class KISBroker:
|
||||
)
|
||||
payload = resp.json()
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
return OrderAck(accepted=False, reason=f"submit_network_error:{type(exc).__name__}",
|
||||
raw={"error": str(exc)})
|
||||
# A transport failure after POST does not prove that KIS rejected
|
||||
# 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":
|
||||
out = payload.get("output") or {}
|
||||
odno = out.get("ODNO") or out.get("odno")
|
||||
orgno = out.get("KRX_FWDG_ORD_ORGNO") or out.get("ord_gno_brno") or ""
|
||||
bid = f"{orgno}:{odno}" if orgno else str(odno)
|
||||
return OrderAck(accepted=True, broker_order_id=bid, raw=payload)
|
||||
return OrderAck(accepted=False, reason=payload.get("msg1") or payload.get("msg_cd"),
|
||||
raw=payload)
|
||||
return OrderAck(
|
||||
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:
|
||||
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()
|
||||
odno = broker_order_id.split(":")[-1]
|
||||
today = datetime.now(tz=_KST).strftime("%Y%m%d")
|
||||
@@ -208,25 +294,206 @@ class KISBroker:
|
||||
# 향후 NXT 체결 미포함 실측 확인 시 TTTC0081R(EXCG=ALL) 전환 별도 batch 처리.
|
||||
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": "",
|
||||
"CCLD_DVSN": "00", "ORD_GNO_BRNO": "", "ODNO": odno,
|
||||
"INQR_DVSN_3": "00", "INQR_DVSN_1": "",
|
||||
"CTX_AREA_FK100": "", "CTX_AREA_NK100": "",
|
||||
"CANO": cano,
|
||||
"ACNT_PRDT_CD": product,
|
||||
"INQR_STRT_DT": today,
|
||||
"INQR_END_DT": today,
|
||||
"SLL_BUY_DVSN_CD": "00",
|
||||
"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:
|
||||
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,
|
||||
headers=self._headers(tr_id),
|
||||
params=params,
|
||||
timeout=self._timeout,
|
||||
)
|
||||
payload = resp.json()
|
||||
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)})
|
||||
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 []
|
||||
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:
|
||||
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
|
||||
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"))
|
||||
@@ -245,7 +512,7 @@ class KISBroker:
|
||||
# kis-daily-ccld:{ord_dt}:{ord_gno_brno}:{odno}:{tot_ccld_qty}
|
||||
ord_dt = str(row.get("ord_dt") or today)
|
||||
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}"
|
||||
fill_price = float(avg) if avg else 0.0
|
||||
# dcy-026: inquire-daily-ccld (output1) does not expose per-fill
|
||||
@@ -256,9 +523,13 @@ class KISBroker:
|
||||
fee = calculate_fee(side, amount)
|
||||
tax = calculate_tax(side, amount)
|
||||
return FillStatus(
|
||||
state="filled", filled_qty=ccld_qty,
|
||||
state="filled",
|
||||
filled_qty=ccld_qty,
|
||||
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
|
||||
if cancelled:
|
||||
@@ -268,20 +539,28 @@ class KISBroker:
|
||||
# holding instead of waiting for a fill that will never come.
|
||||
ord_dt = str(row.get("ord_dt") or today)
|
||||
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}"
|
||||
fill_price = float(avg) if avg else 0.0
|
||||
amount = ccld_qty * fill_price
|
||||
fee = calculate_fee(side, amount)
|
||||
tax = calculate_tax(side, amount)
|
||||
return FillStatus(
|
||||
state="partially_filled", filled_qty=ccld_qty,
|
||||
state="partially_filled",
|
||||
filled_qty=ccld_qty,
|
||||
avg_price=fill_price or None,
|
||||
execution_id=execution_id, fee=fee, tax=tax,
|
||||
cancelled=True, raw=row,
|
||||
execution_id=execution_id,
|
||||
fee=fee,
|
||||
tax=tax,
|
||||
cancelled=True,
|
||||
raw=row,
|
||||
)
|
||||
return FillStatus(
|
||||
state="partially_filled",
|
||||
filled_qty=ccld_qty,
|
||||
avg_price=float(avg) if avg else None,
|
||||
raw=row,
|
||||
)
|
||||
return FillStatus(state="partially_filled", filled_qty=ccld_qty,
|
||||
avg_price=float(avg) if avg else None, raw=row)
|
||||
|
||||
def get_balance(self) -> list[BalanceItem]:
|
||||
"""KIS 주식잔고조회 (`TTTC8434R`/`VTTC8434R`).
|
||||
@@ -294,12 +573,17 @@ class KISBroker:
|
||||
cano, product = self._account()
|
||||
tr_id = "VTTC8434R" if self._mock else "TTTC8434R"
|
||||
params = {
|
||||
"CANO": cano, "ACNT_PRDT_CD": product,
|
||||
"AFHR_FLPR_YN": "N", "OFL_YN": "",
|
||||
"INQR_DVSN": "02", "UNPR_DVSN": "01",
|
||||
"FUND_STTL_ICLD_YN": "N", "FNCG_AMT_AUTO_RDPT_YN": "N",
|
||||
"CANO": cano,
|
||||
"ACNT_PRDT_CD": product,
|
||||
"AFHR_FLPR_YN": "N",
|
||||
"OFL_YN": "",
|
||||
"INQR_DVSN": "02",
|
||||
"UNPR_DVSN": "01",
|
||||
"FUND_STTL_ICLD_YN": "N",
|
||||
"FNCG_AMT_AUTO_RDPT_YN": "N",
|
||||
"PRCS_DVSN": "01",
|
||||
"CTX_AREA_FK100": "", "CTX_AREA_NK100": "",
|
||||
"CTX_AREA_FK100": "",
|
||||
"CTX_AREA_NK100": "",
|
||||
}
|
||||
try:
|
||||
headers = self._headers(tr_id)
|
||||
@@ -310,14 +594,18 @@ class KISBroker:
|
||||
try:
|
||||
resp = httpx.get(
|
||||
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()
|
||||
payload = resp.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
status = exc.response.status_code if exc.response is not None else None
|
||||
code = "authentication_failed" if status == 401 else (
|
||||
"authorization_failed" if status == 403 else "transport_failed"
|
||||
code = (
|
||||
"authentication_failed"
|
||||
if status == 401
|
||||
else ("authorization_failed" if status == 403 else "transport_failed")
|
||||
)
|
||||
raise BrokerBalanceError(code) from None
|
||||
except httpx.HTTPError:
|
||||
@@ -400,7 +688,9 @@ class KISBroker:
|
||||
try:
|
||||
resp = httpx.get(
|
||||
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()
|
||||
except (httpx.HTTPError, ValueError):
|
||||
@@ -416,21 +706,30 @@ class KISBroker:
|
||||
except (InvalidOperation, ValueError):
|
||||
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).
|
||||
|
||||
venue: exchange venue of the original order. Must match the EXCG_ID_DVSN_CD
|
||||
used at submit time. Default "KRX" preserves backward compatibility.
|
||||
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()
|
||||
parts = broker_order_id.split(":")
|
||||
orgno, odno = (parts[0], parts[1]) if len(parts) == 2 else ("", parts[-1])
|
||||
body = {
|
||||
"CANO": cano, "ACNT_PRDT_CD": product,
|
||||
"KRX_FWDG_ORD_ORGNO": orgno, "ORGN_ODNO": odno,
|
||||
"ORD_DVSN": "00", "RVSE_CNCL_DVSN_CD": "02",
|
||||
"ORD_QTY": str(qty), "ORD_UNPR": "0", "QTY_ALL_ORD_YN": "Y",
|
||||
"CANO": cano,
|
||||
"ACNT_PRDT_CD": product,
|
||||
"KRX_FWDG_ORD_ORGNO": orgno,
|
||||
"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.
|
||||
"EXCG_ID_DVSN_CD": venue,
|
||||
}
|
||||
@@ -438,7 +737,9 @@ class KISBroker:
|
||||
try:
|
||||
resp = httpx.post(
|
||||
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"
|
||||
except (httpx.HTTPError, ValueError):
|
||||
|
||||
@@ -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
|
||||
domain layer (NFR-1).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
@@ -71,9 +72,7 @@ class PostgreSQLSlotRepository:
|
||||
conn.rollback()
|
||||
raise
|
||||
|
||||
def list_slots(
|
||||
self, instance_key: str, status_filter: Optional[set[str]] = None
|
||||
) -> list[Slot]:
|
||||
def list_slots(self, instance_key: str, status_filter: Optional[set[str]] = None) -> list[Slot]:
|
||||
with self._psycopg.connect(self._dsn) as conn:
|
||||
with conn.cursor() as cur:
|
||||
if status_filter:
|
||||
@@ -147,8 +146,11 @@ class PostgreSQLSlotRepository:
|
||||
return self._row_to_slot(row) if row else None
|
||||
|
||||
def reconcile_universe(
|
||||
self, instance_key: str, universe: dict[str, str],
|
||||
*, allow_retire: bool = True,
|
||||
self,
|
||||
instance_key: str,
|
||||
universe: dict[str, str],
|
||||
*,
|
||||
allow_retire: bool = True,
|
||||
) -> dict[str, int]:
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
created = retired = tombstoned = 0
|
||||
@@ -216,14 +218,26 @@ class PostgreSQLSlotRepository:
|
||||
"instance_key=%s, retired_at=%s, timeout_sec=%s, slippage_pct=%s, "
|
||||
"occupancy_seq=%s, updated_at=%s WHERE slot_id=%s",
|
||||
(
|
||||
slot.status.value, slot.symbol, slot.qty, slot.avg_price,
|
||||
slot.limit_price, slot.order_id,
|
||||
slot.strategy_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,
|
||||
slot.status.value,
|
||||
slot.symbol,
|
||||
slot.qty,
|
||||
slot.avg_price,
|
||||
slot.limit_price,
|
||||
slot.order_id,
|
||||
slot.strategy_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:
|
||||
@@ -239,15 +253,22 @@ class PostgreSQLSlotRepository:
|
||||
"(slot_id, from_state, to_state, ts, reason, actor, extra) "
|
||||
"VALUES (%s, %s, %s, %s, %s, %s, %s)",
|
||||
(
|
||||
record.slot_id, record.from_state.value, record.to_state.value,
|
||||
record.ts, record.reason, record.actor, json.dumps(record.extra),
|
||||
record.slot_id,
|
||||
record.from_state.value,
|
||||
record.to_state.value,
|
||||
record.ts,
|
||||
record.reason,
|
||||
record.actor,
|
||||
json.dumps(record.extra),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# --- order execution queue (BAT-004 / REQ-013, option A) ---
|
||||
def list_undispatched_pending(
|
||||
self, instance_key: str = "default", limit: int = 64,
|
||||
self,
|
||||
instance_key: str = "default",
|
||||
limit: int = 64,
|
||||
) -> list[Slot]:
|
||||
# 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
|
||||
@@ -275,6 +296,29 @@ class PostgreSQLSlotRepository:
|
||||
conn.commit()
|
||||
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]:
|
||||
with self._psycopg.connect(self._dsn) as conn:
|
||||
with conn.cursor() as cur:
|
||||
@@ -308,6 +352,7 @@ class PostgreSQLSlotRepository:
|
||||
@staticmethod
|
||||
def _row_to_slot(row: tuple) -> Slot:
|
||||
from decimal import Decimal
|
||||
|
||||
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
|
||||
# Column order: slot_id(0) status(1) symbol(2) qty(3) avg_price(4)
|
||||
@@ -373,8 +418,12 @@ class PostgreSQLIntentStore:
|
||||
if row is None:
|
||||
return None
|
||||
return _PgIntentRecord(
|
||||
intent_id=row[0], 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]))),
|
||||
intent_id=row[0],
|
||||
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],
|
||||
)
|
||||
|
||||
@@ -426,9 +475,15 @@ class PostgreSQLLedgerOutbox:
|
||||
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) "
|
||||
"ON CONFLICT (correlation_id) DO NOTHING",
|
||||
(
|
||||
trade.correlation_id, trade.asset_id, trade.order_id,
|
||||
trade.execution_id, trade.side,
|
||||
trade.qty, trade.price, trade.fee, trade.tax,
|
||||
trade.correlation_id,
|
||||
trade.asset_id,
|
||||
trade.order_id,
|
||||
trade.execution_id,
|
||||
trade.side,
|
||||
trade.qty,
|
||||
trade.price,
|
||||
trade.fee,
|
||||
trade.tax,
|
||||
trade.executed_at,
|
||||
),
|
||||
)
|
||||
@@ -506,8 +561,13 @@ class PostgreSQLLedgerOutbox:
|
||||
"last_outcome=%s, last_error=%s, next_retry_at=%s, updated_at=%s "
|
||||
"WHERE correlation_id=%s",
|
||||
(
|
||||
status_value, attempts_delta, outcome.value, error, nra,
|
||||
datetime.now(tz=timezone.utc), correlation_id,
|
||||
status_value,
|
||||
attempts_delta,
|
||||
outcome.value,
|
||||
error,
|
||||
nra,
|
||||
datetime.now(tz=timezone.utc),
|
||||
correlation_id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
@@ -519,8 +579,14 @@ class PostgreSQLLedgerOutbox:
|
||||
def _row_to_item(row: tuple) -> LedgerWorkItem:
|
||||
f = lambda v: float(v) if v is not None else 0.0 # noqa: E731
|
||||
trade = LedgerTrade(
|
||||
asset_id=row[1], 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]),
|
||||
asset_id=row[1],
|
||||
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],
|
||||
)
|
||||
return LedgerWorkItem(
|
||||
@@ -601,8 +667,12 @@ class PgStrategyStateStore:
|
||||
"version = EXCLUDED.version, data = EXCLUDED.data, "
|
||||
"updated_at = EXCLUDED.updated_at",
|
||||
(
|
||||
slot_id, strategy_id, current_occupancy_seq,
|
||||
result.version, data, datetime.now(tz=timezone.utc),
|
||||
slot_id,
|
||||
strategy_id,
|
||||
current_occupancy_seq,
|
||||
result.version,
|
||||
data,
|
||||
datetime.now(tz=timezone.utc),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
@@ -641,7 +711,5 @@ class PgStrategyStateStore:
|
||||
def delete_all(self, slot_id: UUID) -> None:
|
||||
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"DELETE FROM slot_strategy_state WHERE slot_id = %s", (slot_id,)
|
||||
)
|
||||
cur.execute("DELETE FROM slot_strategy_state WHERE slot_id = %s", (slot_id,))
|
||||
conn.commit()
|
||||
|
||||
@@ -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
|
||||
single producer for ledger work items (NFR-3, single record subject).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime, timezone
|
||||
from typing import Callable, Optional
|
||||
|
||||
@@ -48,7 +49,9 @@ _log = logging.getLogger(__name__)
|
||||
# _apply_transition_fields). The emitter MUST prefer this hint over
|
||||
# `slot.strategy_id` so that slot_filled_sell events reach the correct
|
||||
# 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
|
||||
|
||||
@@ -139,7 +142,8 @@ class ExecutionService:
|
||||
_log.warning(
|
||||
"sell dispatch skipped: slot=%s symbol=%s broker balance is 0 "
|
||||
"(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.
|
||||
if self._alarm_emitter is not None:
|
||||
@@ -175,7 +179,12 @@ class ExecutionService:
|
||||
_log.debug(
|
||||
"slippage applied: slot=%s side=%s base_limit=%s pct=%.4f "
|
||||
"raw=%.2f effective=%s (tick-snapped)",
|
||||
slot.slot_id, side, slot.limit_price, slot.slippage_pct, raw, limit,
|
||||
slot.slot_id,
|
||||
side,
|
||||
slot.limit_price,
|
||||
slot.slippage_pct,
|
||||
raw,
|
||||
limit,
|
||||
)
|
||||
# REQ-084/085: correlation id (intent→order→fill join key = slot_id)
|
||||
# so submit/fill/reject logs for one slot share a key.
|
||||
@@ -193,11 +202,28 @@ class ExecutionService:
|
||||
if ack.accepted and ack.broker_order_id:
|
||||
self._repo.mark_dispatched(slot.slot_id, ack.broker_order_id)
|
||||
r.dispatched += 1
|
||||
if ack.reason and ack.reason.startswith("submit_outcome_unknown:"):
|
||||
_log.warning(
|
||||
"order submission outcome unknown event=order_submit_unknown "
|
||||
"side=%s symbol=%s slot_id=%s qty=%s limit_price=%s "
|
||||
"automatic_retry=blocked reason=%s",
|
||||
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,
|
||||
side,
|
||||
slot.symbol,
|
||||
slot.slot_id,
|
||||
qty,
|
||||
limit,
|
||||
"00" if limit is not None else "01",
|
||||
)
|
||||
else:
|
||||
@@ -206,7 +232,11 @@ class ExecutionService:
|
||||
_log.error(
|
||||
"order rejected event=order_reject side=%s symbol=%s slot_id=%s "
|
||||
"qty=%s reason=%s",
|
||||
side, slot.symbol, slot.slot_id, qty, ack.reason or "order_rejected",
|
||||
side,
|
||||
slot.symbol,
|
||||
slot.slot_id,
|
||||
qty,
|
||||
ack.reason or "order_rejected",
|
||||
)
|
||||
self._rollback(slot, reason=ack.reason or "order_rejected")
|
||||
r.rolled_back += 1
|
||||
@@ -217,6 +247,30 @@ class ExecutionService:
|
||||
side = "buy" if slot.status is SlotState.PENDING_BUY else "sell"
|
||||
set_correlation_id(str(slot.slot_id)) # REQ-084: same key as submit
|
||||
fs = self._broker.poll_fill(slot.broker_order_id, side=side, symbol=slot.symbol or "")
|
||||
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:
|
||||
self._on_filled(slot, fs, side, r)
|
||||
r.filled += 1
|
||||
@@ -230,7 +284,10 @@ class ExecutionService:
|
||||
_log.error(
|
||||
"order rejected event=order_reject side=%s symbol=%s slot_id=%s "
|
||||
"reason=order_%s",
|
||||
side, slot.symbol, slot.slot_id, fs.state,
|
||||
side,
|
||||
slot.symbol,
|
||||
slot.slot_id,
|
||||
fs.state,
|
||||
)
|
||||
self._rollback(slot, reason=f"order_{fs.state}")
|
||||
r.rolled_back += 1
|
||||
@@ -270,8 +327,11 @@ class ExecutionService:
|
||||
_log.warning(
|
||||
"fill price backfill: slot=%s symbol=%s broker_order_id=%s "
|
||||
"avg_price=%s backfill_price=%s reason=avg_price_backfill",
|
||||
slot.slot_id, slot.symbol, slot.broker_order_id,
|
||||
avg, limit,
|
||||
slot.slot_id,
|
||||
slot.symbol,
|
||||
slot.broker_order_id,
|
||||
avg,
|
||||
limit,
|
||||
)
|
||||
return float(limit), True
|
||||
# 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 "
|
||||
"avg_price=%s limit_price=%s reason=fill_price_unresolvable "
|
||||
"(slot stays PENDING — operator attention required)",
|
||||
slot.slot_id, slot.symbol, slot.broker_order_id,
|
||||
avg, limit,
|
||||
slot.slot_id,
|
||||
slot.symbol,
|
||||
slot.broker_order_id,
|
||||
avg,
|
||||
limit,
|
||||
)
|
||||
# dcy-033 / REQ-100: CRITICAL alarm — slot stuck in PENDING.
|
||||
if self._alarm_emitter is not None:
|
||||
self._alarm_emitter.publish_alarm(
|
||||
"fill_price_unresolvable",
|
||||
f"체결가 미해결: 슬롯 PENDING 고착 위험 (slot={slot.slot_id} symbol={slot.symbol})",
|
||||
fields={"slot_id": str(slot.slot_id), "symbol": slot.symbol,
|
||||
"broker_order_id": slot.broker_order_id},
|
||||
fields={
|
||||
"slot_id": str(slot.slot_id),
|
||||
"symbol": slot.symbol,
|
||||
"broker_order_id": slot.broker_order_id,
|
||||
},
|
||||
)
|
||||
return None, False
|
||||
|
||||
@@ -318,12 +384,17 @@ class ExecutionService:
|
||||
old_avg = float(slot.avg_price) if slot.avg_price else 0.0
|
||||
new_avg = (
|
||||
(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(
|
||||
slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="buy_filled", actor="execution_loop",
|
||||
avg_price=new_avg, qty=new_qty,
|
||||
slot.slot_id,
|
||||
SlotState.PENDING_BUY,
|
||||
SlotState.HOLDING,
|
||||
reason="buy_filled",
|
||||
actor="execution_loop",
|
||||
avg_price=new_avg,
|
||||
qty=new_qty,
|
||||
)
|
||||
event_type = "slot_filled_buy"
|
||||
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)
|
||||
if total > 0:
|
||||
updated = self._svc.transit_slot(
|
||||
slot.slot_id, SlotState.PENDING_SELL, SlotState.HOLDING,
|
||||
reason="sell_filled_partial", actor="execution_loop",
|
||||
slot.slot_id,
|
||||
SlotState.PENDING_SELL,
|
||||
SlotState.HOLDING,
|
||||
reason="sell_filled_partial",
|
||||
actor="execution_loop",
|
||||
qty=float(total), # avg_price (cost basis) preserved
|
||||
)
|
||||
else:
|
||||
updated = self._svc.transit_slot(
|
||||
slot.slot_id, SlotState.PENDING_SELL, SlotState.INACTIVE,
|
||||
reason="sell_filled", actor="execution_loop",
|
||||
slot.slot_id,
|
||||
SlotState.PENDING_SELL,
|
||||
SlotState.INACTIVE,
|
||||
reason="sell_filled",
|
||||
actor="execution_loop",
|
||||
)
|
||||
event_type = "slot_filled_sell"
|
||||
except SlotStateMismatchError:
|
||||
@@ -347,9 +424,13 @@ class ExecutionService:
|
||||
_log.info(
|
||||
"order filled event=order_fill side=%s symbol=%s slot_id=%s "
|
||||
"fill_price=%s filled_qty=%s fee=%s tax=%s",
|
||||
side, slot.symbol, slot.slot_id,
|
||||
fs.avg_price, fs.filled_qty,
|
||||
getattr(fs, "fee", None), getattr(fs, "tax", None),
|
||||
side,
|
||||
slot.symbol,
|
||||
slot.slot_id,
|
||||
fs.avg_price,
|
||||
fs.filled_qty,
|
||||
getattr(fs, "fee", None),
|
||||
getattr(fs, "tax", None),
|
||||
)
|
||||
# REQ-018: enqueue ledger work item AFTER the slot transition is durable.
|
||||
# Slot transition is the critical path; ledger write is async (REQ-019).
|
||||
@@ -389,26 +470,37 @@ class ExecutionService:
|
||||
old_avg = float(slot.avg_price) if slot.avg_price else 0.0
|
||||
new_avg = (
|
||||
(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(
|
||||
slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="buy_partial_cancel", actor="execution_loop",
|
||||
avg_price=new_avg, qty=new_qty,
|
||||
slot.slot_id,
|
||||
SlotState.PENDING_BUY,
|
||||
SlotState.HOLDING,
|
||||
reason="buy_partial_cancel",
|
||||
actor="execution_loop",
|
||||
avg_price=new_avg,
|
||||
qty=new_qty,
|
||||
)
|
||||
event_type = "slot_filled_buy"
|
||||
else: # PENDING_SELL: remainder = broker balance, cost basis unchanged.
|
||||
total = self._balance_map().get(slot.symbol or "", 0)
|
||||
if total > 0:
|
||||
updated = self._svc.transit_slot(
|
||||
slot.slot_id, SlotState.PENDING_SELL, SlotState.HOLDING,
|
||||
reason="sell_partial_cancel", actor="execution_loop",
|
||||
slot.slot_id,
|
||||
SlotState.PENDING_SELL,
|
||||
SlotState.HOLDING,
|
||||
reason="sell_partial_cancel",
|
||||
actor="execution_loop",
|
||||
qty=float(total),
|
||||
)
|
||||
else:
|
||||
updated = self._svc.transit_slot(
|
||||
slot.slot_id, SlotState.PENDING_SELL, SlotState.INACTIVE,
|
||||
reason="sell_partial_cancel", actor="execution_loop",
|
||||
slot.slot_id,
|
||||
SlotState.PENDING_SELL,
|
||||
SlotState.INACTIVE,
|
||||
reason="sell_partial_cancel",
|
||||
actor="execution_loop",
|
||||
)
|
||||
event_type = "slot_filled_sell"
|
||||
except SlotStateMismatchError:
|
||||
@@ -431,13 +523,14 @@ class ExecutionService:
|
||||
_log.warning(
|
||||
"pending order stale: slot=%s status=%s broker_order_id=%s "
|
||||
"age_sec=%.0f threshold_sec=%d (no state change — ops attention)",
|
||||
slot.slot_id, slot.status.value, slot.broker_order_id,
|
||||
age, self._stale_threshold_sec,
|
||||
slot.slot_id,
|
||||
slot.status.value,
|
||||
slot.broker_order_id,
|
||||
age,
|
||||
self._stale_threshold_sec,
|
||||
)
|
||||
|
||||
def _try_timeout_cancel(
|
||||
self, slot, now: datetime, side: str, r: ExecutionResult
|
||||
) -> None:
|
||||
def _try_timeout_cancel(self, slot, now: datetime, side: str, r: ExecutionResult) -> None:
|
||||
"""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.
|
||||
@@ -446,6 +539,8 @@ class ExecutionService:
|
||||
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).
|
||||
"""
|
||||
if str(slot.broker_order_id or "").startswith("submission-unknown:"):
|
||||
return
|
||||
if slot.timeout_sec is None or slot.updated_at is None:
|
||||
return
|
||||
age = (now - slot.updated_at).total_seconds()
|
||||
@@ -463,14 +558,19 @@ class ExecutionService:
|
||||
_log.warning(
|
||||
"timeout cancel sent: slot=%s status=%s broker_order_id=%s "
|
||||
"age_sec=%.0f timeout_sec=%d broker_accepted=%s",
|
||||
slot.slot_id, slot.status.value, slot.broker_order_id,
|
||||
age, slot.timeout_sec, accepted,
|
||||
slot.slot_id,
|
||||
slot.status.value,
|
||||
slot.broker_order_id,
|
||||
age,
|
||||
slot.timeout_sec,
|
||||
accepted,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - best-effort; do not block the loop
|
||||
_log.warning(
|
||||
"timeout cancel failed (exception): slot=%s broker_order_id=%s "
|
||||
"(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:
|
||||
@@ -507,7 +607,9 @@ class ExecutionService:
|
||||
_log.warning(
|
||||
"ledger skip: slot=%s symbol=%s side=%s reason=missing_portfolio_id "
|
||||
"(trade NOT recorded to portfolio-hub)",
|
||||
slot.slot_id, slot.symbol, side,
|
||||
slot.slot_id,
|
||||
slot.symbol,
|
||||
side,
|
||||
)
|
||||
return
|
||||
if fs.execution_id is None:
|
||||
@@ -516,13 +618,17 @@ class ExecutionService:
|
||||
_log.warning(
|
||||
"ledger skip: slot=%s portfolio_id=%s side=%s reason=missing_execution_id "
|
||||
"(trade NOT recorded to portfolio-hub)",
|
||||
slot.slot_id, slot.portfolio_id, side,
|
||||
slot.slot_id,
|
||||
slot.portfolio_id,
|
||||
side,
|
||||
)
|
||||
return
|
||||
# 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.
|
||||
price = resolved_price if resolved_price is not None else (
|
||||
float(fs.avg_price) if fs.avg_price is not None else 0.0
|
||||
price = (
|
||||
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(
|
||||
asset_id=str(slot.portfolio_id),
|
||||
@@ -546,6 +652,7 @@ class ExecutionService:
|
||||
next poll handles the rollback via the normal is_terminal_reject path.
|
||||
"""
|
||||
from uuid import UUID
|
||||
|
||||
sid = slot_id if isinstance(slot_id, UUID) else UUID(str(slot_id))
|
||||
slot = self._repo.fetch_slot(sid)
|
||||
if slot is None:
|
||||
@@ -567,12 +674,15 @@ class ExecutionService:
|
||||
except Exception: # noqa: BLE001 - best-effort
|
||||
_log.warning(
|
||||
"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"
|
||||
_log.warning(
|
||||
"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"
|
||||
|
||||
@@ -584,19 +694,29 @@ class ExecutionService:
|
||||
# dcy-021: increase rejected — the prior holding is intact
|
||||
# (broker still holds), so return to holding, not inactive.
|
||||
self._svc.transit_slot(
|
||||
slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason=reason, actor="execution_loop", qty=float(total),
|
||||
slot.slot_id,
|
||||
SlotState.PENDING_BUY,
|
||||
SlotState.HOLDING,
|
||||
reason=reason,
|
||||
actor="execution_loop",
|
||||
qty=float(total),
|
||||
)
|
||||
self._repo.clear_dispatch(slot.slot_id)
|
||||
else: # initial buy rejected -> inactive (clears broker_order_id)
|
||||
self._svc.transit_slot(
|
||||
slot.slot_id, SlotState.PENDING_BUY, SlotState.INACTIVE,
|
||||
reason=reason, actor="execution_loop",
|
||||
slot.slot_id,
|
||||
SlotState.PENDING_BUY,
|
||||
SlotState.INACTIVE,
|
||||
reason=reason,
|
||||
actor="execution_loop",
|
||||
)
|
||||
else: # PENDING_SELL rejected -> back to holding (broker authoritative qty)
|
||||
self._svc.transit_slot(
|
||||
slot.slot_id, SlotState.PENDING_SELL, SlotState.HOLDING,
|
||||
reason=reason, actor="execution_loop",
|
||||
slot.slot_id,
|
||||
SlotState.PENDING_SELL,
|
||||
SlotState.HOLDING,
|
||||
reason=reason,
|
||||
actor="execution_loop",
|
||||
qty=float(total) if total > 0 else None,
|
||||
)
|
||||
self._repo.clear_dispatch(slot.slot_id)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
Simulates advisory lock via threading.Lock so contention tests are still
|
||||
meaningful even without a real PostgreSQL instance.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
@@ -51,11 +52,10 @@ class InMemorySlotRepository:
|
||||
"""Test seeding helper — insert a slot row directly."""
|
||||
self._slots[slot.slot_id] = slot
|
||||
|
||||
def list_slots(
|
||||
self, instance_key: str, status_filter: Optional[set[str]] = None
|
||||
) -> list[Slot]:
|
||||
def list_slots(self, instance_key: str, status_filter: Optional[set[str]] = None) -> list[Slot]:
|
||||
return [
|
||||
s for s in self._slots.values()
|
||||
s
|
||||
for s in self._slots.values()
|
||||
if s.instance_key == instance_key
|
||||
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:
|
||||
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
|
||||
)
|
||||
|
||||
def list_holding_slots(self, instance_key: str) -> list[Slot]:
|
||||
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
|
||||
]
|
||||
|
||||
def find_managed_slot_by_symbol(self, instance_key: str, symbol: str) -> Optional[Slot]:
|
||||
candidates = [
|
||||
s for s in self._slots.values()
|
||||
if s.instance_key == instance_key and s.symbol == symbol
|
||||
s for s in self._slots.values() if s.instance_key == instance_key and s.symbol == symbol
|
||||
]
|
||||
# PostgreSQL parity: updated_at DESC NULLS LAST, slot_id ASC.
|
||||
candidates.sort(key=lambda s: (
|
||||
candidates.sort(
|
||||
key=lambda s: (
|
||||
s.updated_at is None,
|
||||
-s.updated_at.timestamp() if s.updated_at is not None else 0,
|
||||
str(s.slot_id),
|
||||
))
|
||||
)
|
||||
)
|
||||
return candidates[0] if candidates else None
|
||||
|
||||
def reconcile_universe(
|
||||
self, instance_key: str, universe: dict[str, str],
|
||||
*, allow_retire: bool = True,
|
||||
self,
|
||||
instance_key: str,
|
||||
universe: dict[str, str],
|
||||
*,
|
||||
allow_retire: bool = True,
|
||||
) -> dict[str, int]:
|
||||
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
|
||||
}
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
created = retired = tombstoned = 0
|
||||
# backfill symbol on slots that were created before this field was populated
|
||||
for s in self._slots.values():
|
||||
if (s.instance_key == instance_key and s.portfolio_id in universe
|
||||
and s.symbol is None):
|
||||
if s.instance_key == instance_key and s.portfolio_id in universe and s.symbol is None:
|
||||
self._slots[s.slot_id] = replace(s, symbol=universe[s.portfolio_id], updated_at=now)
|
||||
# create asset slots for newly-appeared universe assets (idempotent)
|
||||
for pid, symbol in universe.items():
|
||||
if pid not in existing:
|
||||
sid = uuid4()
|
||||
self._slots[sid] = Slot(
|
||||
slot_id=sid, status=SlotState.INACTIVE,
|
||||
instance_key=instance_key, portfolio_id=pid, symbol=symbol,
|
||||
created_at=now, updated_at=now,
|
||||
slot_id=sid,
|
||||
status=SlotState.INACTIVE,
|
||||
instance_key=instance_key,
|
||||
portfolio_id=pid,
|
||||
symbol=symbol,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
created += 1
|
||||
# retire / tombstone assets that left the universe (skipped under the
|
||||
@@ -155,7 +165,9 @@ class InMemorySlotRepository:
|
||||
_PENDING = (SlotState.PENDING_BUY, SlotState.PENDING_SELL)
|
||||
|
||||
def list_undispatched_pending(
|
||||
self, instance_key: str = "default", limit: int = 64,
|
||||
self,
|
||||
instance_key: str = "default",
|
||||
limit: int = 64,
|
||||
) -> list[Slot]:
|
||||
"""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
|
||||
one skipped sell (broker flat) cannot head-block the dispatch queue."""
|
||||
out = [
|
||||
s for s in self._slots.values()
|
||||
if (s.instance_key == instance_key and s.status in self._PENDING
|
||||
and s.broker_order_id is None)
|
||||
s
|
||||
for s in self._slots.values()
|
||||
if (
|
||||
s.instance_key == instance_key
|
||||
and s.status in self._PENDING
|
||||
and s.broker_order_id is None
|
||||
)
|
||||
]
|
||||
return out[:limit]
|
||||
|
||||
@@ -179,12 +195,31 @@ class InMemorySlotRepository:
|
||||
)
|
||||
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]:
|
||||
"""Pending slots already dispatched (broker_order_id NOT NULL) — poll targets."""
|
||||
return [
|
||||
s for s in self._slots.values()
|
||||
if (s.instance_key == instance_key and s.status in self._PENDING
|
||||
and s.broker_order_id is not None)
|
||||
s
|
||||
for s in self._slots.values()
|
||||
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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user