fix: reconcile ambiguous KIS order submissions
This commit is contained in:
@@ -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·재시작 복구·확정 미접수
|
||||||
|
이후에만 재시도됨을 확인한다.
|
||||||
|
|||||||
@@ -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
|
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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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