feat: resolve and persist actual order venue
This commit is contained in:
+3
-1
@@ -49,7 +49,7 @@
|
|||||||
- limit_price :: numeric? :: 현재 pending 주문 지정가(side 무관, dcy-012/REQ-002 — 과거 `entry_price`에서 rename). pending 진입(매수/매도 지정가) 시 set, pending 이탈(→holding/→closed) 시 None 으로 clear → 매수 지정가가 매도로 누수되지 않음(issue #10). None 이면 dispatch 시 시장가.
|
- limit_price :: numeric? :: 현재 pending 주문 지정가(side 무관, dcy-012/REQ-002 — 과거 `entry_price`에서 rename). pending 진입(매수/매도 지정가) 시 set, pending 이탈(→holding/→closed) 시 None 으로 clear → 매수 지정가가 매도로 누수되지 않음(issue #10). None 이면 dispatch 시 시장가.
|
||||||
- allocated_capital :: numeric? :: 배정 자본
|
- allocated_capital :: numeric? :: 배정 자본
|
||||||
- order_id / broker_order_id :: str? :: 주문 식별자(자유 주석 / 브로커 핸들)
|
- order_id / broker_order_id :: str? :: 주문 식별자(자유 주석 / 브로커 핸들)
|
||||||
- venue :: TEXT NOT NULL DEFAULT 'KRX' :: 현재 주문 거래소 구분(KRX/NXT/SOR). pending 주문·취소까지 보존하고 INACTIVE 진입 시 KRX 초기화.
|
- venue :: TEXT NOT NULL DEFAULT 'KRX' :: 현재 주문 거래소 구분(AUTO/KRX/NXT/SOR). intent 단계의 AUTO는 KIS 제출 시 실제 KRX/NXT로 교체되며 주문번호와 원자 저장한다. pending 주문·취소까지 보존하고 HOLDING 진입 시 다음 자율주문의 AUTO, INACTIVE 진입 시 KRX로 초기화한다.
|
||||||
- strategy_id / sell_strategy_id :: str? :: 진입/청산 전략 식별
|
- strategy_id / sell_strategy_id :: str? :: 진입/청산 전략 식별
|
||||||
- portfolio_id :: str? :: 포트폴리오 식별
|
- portfolio_id :: str? :: 포트폴리오 식별
|
||||||
- forced_close_at :: datetime? :: 강제 청산 마감선
|
- forced_close_at :: datetime? :: 강제 청산 마감선
|
||||||
@@ -128,6 +128,7 @@
|
|||||||
- fee :: float :: BrokerPort `calculate_fee(side, qty*price)`
|
- fee :: float :: BrokerPort `calculate_fee(side, qty*price)`
|
||||||
- tax :: float :: 기본 0(정밀 모델 후속)
|
- tax :: float :: 기본 0(정밀 모델 후속)
|
||||||
- executed_at :: datetime?
|
- executed_at :: datetime?
|
||||||
|
- venue :: str :: Core가 실제 제출에 사용한 거래소. 외부 Portfolio Hub API에는 아직 전달하지 않는다.
|
||||||
- correlation_id (derived) :: `{asset_id}:{order_id}:{execution_id}`
|
- correlation_id (derived) :: `{asset_id}:{order_id}:{execution_id}`
|
||||||
|
|
||||||
### State Rules
|
### State Rules
|
||||||
@@ -147,6 +148,7 @@
|
|||||||
- side :: TEXT CHECK ('buy','sell')
|
- side :: TEXT CHECK ('buy','sell')
|
||||||
- qty / price / fee / tax :: NUMERIC(18,4)
|
- qty / price / fee / tax :: NUMERIC(18,4)
|
||||||
- executed_at :: TIMESTAMPTZ?
|
- executed_at :: TIMESTAMPTZ?
|
||||||
|
- venue :: TEXT NOT NULL DEFAULT 'KRX' :: 실제 제출 거래소. 기존 행은 KRX로 보존한다.
|
||||||
- status :: TEXT CHECK ('pending','completed','conflict_blocked','delivery_failed') — `delivery_failed` 추가(dcy-015, 마이그레이션 007)
|
- status :: TEXT CHECK ('pending','completed','conflict_blocked','delivery_failed') — `delivery_failed` 추가(dcy-015, 마이그레이션 007)
|
||||||
- attempts :: INTEGER :: 재시도 누적
|
- attempts :: INTEGER :: 재시도 누적
|
||||||
- last_outcome :: TEXT? CHECK ('completed','duplicate_completed','retryable_failure','conflict_blocked','permanent_failure','dead_lettered')
|
- last_outcome :: TEXT? CHECK ('completed','duplicate_completed','retryable_failure','conflict_blocked','permanent_failure','dead_lettered')
|
||||||
|
|||||||
@@ -396,7 +396,7 @@ resume_kill_switch 요청·응답 (REQ-046):
|
|||||||
|
|
||||||
### Persistence
|
### Persistence
|
||||||
|
|
||||||
- PostgreSQL: `ledger_outbox(correlation_id PK, asset_id, order_id, execution_id, side, qty, price, fee, tax, executed_at, status, attempts, last_outcome, last_error, created_at, updated_at)` — `ON CONFLICT (correlation_id) DO NOTHING` 이 outbox dedup 게이트.
|
- PostgreSQL: `ledger_outbox(correlation_id PK, asset_id, order_id, execution_id, side, qty, price, fee, tax, executed_at, venue, status, attempts, last_outcome, last_error, created_at, updated_at)` — `ON CONFLICT (correlation_id) DO NOTHING` 이 outbox dedup 게이트. `venue`는 Core 내부 실제 제출 거래소이며 Portfolio Hub wire에는 아직 보내지 않는다.
|
||||||
|
|
||||||
## `OrderRequest` DTO (BAT-004 / REQ-011, BAT-056 / REQ-117)
|
## `OrderRequest` DTO (BAT-004 / REQ-011, BAT-056 / REQ-117)
|
||||||
|
|
||||||
@@ -415,8 +415,9 @@ resume_kill_switch 요청·응답 (REQ-046):
|
|||||||
### Additive Notes (REQ-117)
|
### Additive Notes (REQ-117)
|
||||||
|
|
||||||
- `venue` 필드는 BAT-056에서 additive 추가. 기존 orb/tsl 미지정 호출 → 기본값 `"KRX"` 자동 적용 → 하위 호환.
|
- `venue` 필드는 BAT-056에서 additive 추가. 기존 orb/tsl 미지정 호출 → 기본값 `"KRX"` 자동 적용 → 하위 호환.
|
||||||
- `OrderRequest`는 AMQP wire 위를 직접 흐르지 않는다. BAT-059부터 buy/sell intent의 선택적 `venue`(기본 KRX)를 슬롯에 보존해 이 DTO까지 전달한다. 허용값 외에는 `invalid_venue`로 admission 전 거부한다.
|
- `OrderRequest`는 AMQP wire 위를 직접 흐르지 않는다. buy/sell intent의 선택적 `venue` 기본은 `AUTO`이고, 명시한 KRX/NXT/SOR는 그대로 슬롯에 보존한다. 허용값 외에는 `invalid_venue`로 admission 전 거부한다.
|
||||||
- KIS 어댑터(REQ-118)는 `venue` → `EXCG_ID_DVSN_CD` 로 변환해 KIS HTTP 요청에 항상 명시 전송.
|
- KIS 어댑터는 `AUTO`를 KST 세션 기준 실제 KRX/NXT로 확정한 뒤 `EXCG_ID_DVSN_CD`로 전송한다. 결정 불가능 구간은 KRX로 대체하지 않고 주문을 거부한다.
|
||||||
|
- `OrderAck.venue`의 실제 제출 거래소는 `broker_order_id`와 함께 슬롯에 원자 저장되며 재시작·취소·Core 원장 outbox에서 재사용한다.
|
||||||
|
|
||||||
## `BrokerPort` Protocol 메서드 (BAT-056 / REQ-118)
|
## `BrokerPort` Protocol 메서드 (BAT-056 / REQ-118)
|
||||||
|
|
||||||
@@ -446,6 +447,7 @@ get_current_price(symbol: str, market: str = "J") -> Optional[Decimal]
|
|||||||
### KIS 어댑터 EXCG 명시화 (REQ-118)
|
### KIS 어댑터 EXCG 명시화 (REQ-118)
|
||||||
|
|
||||||
- `submit_order`: body에 `EXCG_ID_DVSN_CD: req.venue` 항상 명시 전송 (이전: 미전송·서버 기본값 의존 → 명시화로 견고성 향상). 실측(2026-07-06): NXT(EXCG=NXT) 주문 rt_cd=0 확인.
|
- `submit_order`: body에 `EXCG_ID_DVSN_CD: req.venue` 항상 명시 전송 (이전: 미전송·서버 기본값 의존 → 명시화로 견고성 향상). 실측(2026-07-06): NXT(EXCG=NXT) 주문 rt_cd=0 확인.
|
||||||
|
- `AUTO` 세션: 08:00~08:49:59 NXT, 09:00:30~15:29:59 KRX, 15:30~20:00 NXT. 그 밖의 시간은 `auto_venue_session_closed`로 fail-closed 한다.
|
||||||
- `cancel_order`: body에 `EXCG_ID_DVSN_CD: venue` 명시 전송. 실측(2026-07-06): NXT 취소 rt_cd=0 확인.
|
- `cancel_order`: body에 `EXCG_ID_DVSN_CD: venue` 명시 전송. 실측(2026-07-06): NXT 취소 rt_cd=0 확인.
|
||||||
- `poll_fill`: 구 TR(`TTTC8001R`/`VTTC8001R`) 유지. NXT 체결 포함 여부 미확인(OPEN ITEM — 운영 스모크 필요). 확인 후 미포함이면 `TTTC0081R`(EXCG=ALL) 전환 별도 batch 처리.
|
- `poll_fill`: 구 TR(`TTTC8001R`/`VTTC8001R`) 유지. NXT 체결 포함 여부 미확인(OPEN ITEM — 운영 스모크 필요). 확인 후 미포함이면 `TTTC0081R`(EXCG=ALL) 전환 별도 batch 처리.
|
||||||
|
|
||||||
|
|||||||
@@ -26,13 +26,12 @@ from __future__ import annotations
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
import httpx
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from trex_core.adapters.broker import kis as kis_mod
|
from trex_core.adapters.broker import kis as kis_mod
|
||||||
from trex_core.adapters.broker.kis import KISBroker
|
from trex_core.adapters.broker.kis import KISBroker, resolve_order_venue
|
||||||
from trex_core.adapters.broker.paper import PaperBroker
|
from trex_core.adapters.broker.paper import PaperBroker
|
||||||
from trex_core.application.broker_port import BrokerPort, OrderRequest
|
from trex_core.application.broker_port import OrderRequest
|
||||||
|
|
||||||
_MOCK_URL = "https://openapivts.koreainvestment.com:29443"
|
_MOCK_URL = "https://openapivts.koreainvestment.com:29443"
|
||||||
_LIVE_URL = "https://openapi.koreainvestment.com:9443"
|
_LIVE_URL = "https://openapi.koreainvestment.com:9443"
|
||||||
@@ -42,8 +41,14 @@ _LIVE_URL = "https://openapi.koreainvestment.com:9443"
|
|||||||
# Helpers
|
# Helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _mock_broker() -> KISBroker:
|
def _mock_broker(*, clock=None) -> KISBroker:
|
||||||
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,
|
||||||
|
clock=clock,
|
||||||
|
)
|
||||||
b._token = "tok"
|
b._token = "tok"
|
||||||
b._token_exp = datetime.now(tz=timezone.utc) + timedelta(hours=1)
|
b._token_exp = datetime.now(tz=timezone.utc) + timedelta(hours=1)
|
||||||
return b
|
return b
|
||||||
@@ -85,6 +90,84 @@ def test_order_request_venue_nxt():
|
|||||||
assert req.venue == "NXT"
|
assert req.venue == "NXT"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("at", "expected"),
|
||||||
|
[
|
||||||
|
(datetime(2026, 7, 29, 8, 0, tzinfo=timezone(timedelta(hours=9))), "NXT"),
|
||||||
|
(datetime(2026, 7, 29, 8, 49, 59, tzinfo=timezone(timedelta(hours=9))), "NXT"),
|
||||||
|
(datetime(2026, 7, 29, 9, 0, 30, tzinfo=timezone(timedelta(hours=9))), "KRX"),
|
||||||
|
(datetime(2026, 7, 29, 15, 29, 59, tzinfo=timezone(timedelta(hours=9))), "KRX"),
|
||||||
|
(datetime(2026, 7, 29, 15, 30, tzinfo=timezone(timedelta(hours=9))), "NXT"),
|
||||||
|
(datetime(2026, 7, 29, 20, 0, tzinfo=timezone(timedelta(hours=9))), "NXT"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_auto_venue_resolves_by_kst_session(at, expected):
|
||||||
|
assert resolve_order_venue("AUTO", at) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"at",
|
||||||
|
[
|
||||||
|
datetime(2026, 7, 29, 7, 59, 59, tzinfo=timezone(timedelta(hours=9))),
|
||||||
|
datetime(2026, 7, 29, 8, 50, tzinfo=timezone(timedelta(hours=9))),
|
||||||
|
datetime(2026, 7, 29, 9, 0, 29, tzinfo=timezone(timedelta(hours=9))),
|
||||||
|
datetime(2026, 7, 29, 20, 0, 1, tzinfo=timezone(timedelta(hours=9))),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_auto_venue_fails_closed_outside_supported_sessions(at):
|
||||||
|
with pytest.raises(ValueError, match="auto_venue_session_closed"):
|
||||||
|
resolve_order_venue("AUTO", at)
|
||||||
|
|
||||||
|
|
||||||
|
def test_submit_auto_uses_nxt_and_reports_actual_venue(monkeypatch):
|
||||||
|
captured = {}
|
||||||
|
at = datetime(2026, 7, 29, 8, 1, tzinfo=timezone(timedelta(hours=9)))
|
||||||
|
|
||||||
|
def fake_post(url, headers=None, json=None, timeout=None):
|
||||||
|
captured["body"] = json
|
||||||
|
return _FakeResp(
|
||||||
|
{"rt_cd": "0", "output": {"ODNO": "0002", "KRX_FWDG_ORD_ORGNO": "06010"}}
|
||||||
|
)
|
||||||
|
|
||||||
|
broker = _mock_broker(clock=lambda: at)
|
||||||
|
monkeypatch.setattr(kis_mod.httpx, "post", fake_post)
|
||||||
|
ack = broker.submit_order(
|
||||||
|
OrderRequest(
|
||||||
|
slot_id="s-auto",
|
||||||
|
side="buy",
|
||||||
|
symbol="005930",
|
||||||
|
qty=1,
|
||||||
|
venue="AUTO",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert ack.accepted is True
|
||||||
|
assert ack.venue == "NXT"
|
||||||
|
assert captured["body"]["EXCG_ID_DVSN_CD"] == "NXT"
|
||||||
|
|
||||||
|
|
||||||
|
def test_submit_auto_outside_session_rejects_without_http(monkeypatch):
|
||||||
|
at = datetime(2026, 7, 29, 8, 55, tzinfo=timezone(timedelta(hours=9)))
|
||||||
|
broker = _mock_broker(clock=lambda: at)
|
||||||
|
|
||||||
|
def unexpected_post(*args, **kwargs):
|
||||||
|
raise AssertionError("AUTO session rejection must happen before HTTP")
|
||||||
|
|
||||||
|
monkeypatch.setattr(kis_mod.httpx, "post", unexpected_post)
|
||||||
|
ack = broker.submit_order(
|
||||||
|
OrderRequest(
|
||||||
|
slot_id="s-auto-closed",
|
||||||
|
side="buy",
|
||||||
|
symbol="005930",
|
||||||
|
qty=1,
|
||||||
|
venue="AUTO",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert ack.accepted is False
|
||||||
|
assert ack.reason == "auto_venue_session_closed"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# REQ-117 AC-4: 기존 위치 인수 순서 불변
|
# REQ-117 AC-4: 기존 위치 인수 순서 불변
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ def _trade(execution_id="paper:1:10") -> LedgerTrade:
|
|||||||
return LedgerTrade(
|
return LedgerTrade(
|
||||||
asset_id="PF-1", order_id="ORD-1", execution_id=execution_id,
|
asset_id="PF-1", order_id="ORD-1", execution_id=execution_id,
|
||||||
side="buy", qty=10, price=1000.0, fee=1.5, tax=0.0,
|
side="buy", qty=10, price=1000.0, fee=1.5, tax=0.0,
|
||||||
executed_at=datetime(2026, 6, 4, tzinfo=timezone.utc),
|
executed_at=datetime(2026, 6, 4, tzinfo=timezone.utc), venue="NXT",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -32,6 +32,7 @@ def test_enqueue_persists_and_is_idempotent(pg_clean):
|
|||||||
pending = outbox.list_pending()
|
pending = outbox.list_pending()
|
||||||
assert len(pending) == 1
|
assert len(pending) == 1
|
||||||
assert pending[0].status is WorkItemStatus.PENDING
|
assert pending[0].status is WorkItemStatus.PENDING
|
||||||
|
assert pending[0].trade.venue == "NXT"
|
||||||
|
|
||||||
|
|
||||||
def test_writer_completes_persisted_item(pg_clean):
|
def test_writer_completes_persisted_item(pg_clean):
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ def test_buy_fill_enqueues_one_ledger_item_and_writer_records_it():
|
|||||||
assert (t.asset_id, t.side, t.qty, t.price) == ("PF-1", "buy", 10, 1000.0)
|
assert (t.asset_id, t.side, t.qty, t.price) == ("PF-1", "buy", 10, 1000.0)
|
||||||
assert t.execution_id.startswith("paper:PAPER-")
|
assert t.execution_id.startswith("paper:PAPER-")
|
||||||
assert t.fee > 0.0 and t.tax == 0.0
|
assert t.fee > 0.0 and t.tax == 0.0
|
||||||
|
assert t.venue == "AUTO"
|
||||||
|
|
||||||
|
|
||||||
def test_sell_fill_enqueues_sell_ledger_item():
|
def test_sell_fill_enqueues_sell_ledger_item():
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ class _RecordingBroker:
|
|||||||
def poll_fill(self, broker_order_id, *, side, symbol):
|
def poll_fill(self, broker_order_id, *, side, symbol):
|
||||||
return self.responder(side)
|
return self.responder(side)
|
||||||
|
|
||||||
def cancel_order(self, broker_order_id, *, side, symbol, qty):
|
def cancel_order(self, broker_order_id, *, side, symbol, qty, venue="KRX"):
|
||||||
self.cancel_calls.append((broker_order_id, side, symbol, qty))
|
self.cancel_calls.append((broker_order_id, side, symbol, qty, venue))
|
||||||
if self.cancel_raises:
|
if self.cancel_raises:
|
||||||
raise RuntimeError("broker error")
|
raise RuntimeError("broker error")
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"""Venue propagation from the strategy wire contract to broker execution."""
|
"""Venue propagation from the strategy wire contract to broker execution."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
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 BalanceItem, FillStatus, OrderAck, OrderRequest
|
from trex_core.application.broker_port import BalanceItem, FillStatus, OrderAck, OrderRequest
|
||||||
from trex_core.application.execution import ExecutionService
|
from trex_core.application.execution import ExecutionService
|
||||||
from trex_core.application.intents import IntentService
|
from trex_core.application.intents import BuyIntent, IntentService
|
||||||
from trex_core.application.messaging import RpcRequest
|
from trex_core.application.messaging import RpcRequest
|
||||||
from trex_core.application.messaging_handlers import make_intent_rpc_handler
|
from trex_core.application.messaging_handlers import make_intent_rpc_handler
|
||||||
from trex_core.domain.slot import SlotService, SlotState
|
from trex_core.domain.slot import SlotService, SlotState
|
||||||
@@ -12,13 +14,18 @@ from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
|||||||
|
|
||||||
|
|
||||||
class RecordingBroker:
|
class RecordingBroker:
|
||||||
def __init__(self) -> None:
|
def __init__(self, *, actual_venue: str | None = None) -> None:
|
||||||
self.orders: list[OrderRequest] = []
|
self.orders: list[OrderRequest] = []
|
||||||
self.cancel_venues: list[str] = []
|
self.cancel_venues: list[str] = []
|
||||||
|
self.actual_venue = actual_venue
|
||||||
|
|
||||||
def submit_order(self, req: OrderRequest) -> OrderAck:
|
def submit_order(self, req: OrderRequest) -> OrderAck:
|
||||||
self.orders.append(req)
|
self.orders.append(req)
|
||||||
return OrderAck(accepted=True, broker_order_id=f"order-{len(self.orders)}")
|
return OrderAck(
|
||||||
|
accepted=True,
|
||||||
|
broker_order_id=f"order-{len(self.orders)}",
|
||||||
|
venue=self.actual_venue or req.venue,
|
||||||
|
)
|
||||||
|
|
||||||
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:
|
||||||
return FillStatus(state="accepted")
|
return FillStatus(state="accepted")
|
||||||
@@ -104,6 +111,41 @@ def test_invalid_venue_is_rejected_before_slot_transition() -> None:
|
|||||||
assert repo.list_slots("default")[0].status is SlotState.INACTIVE
|
assert repo.list_slots("default")[0].status is SlotState.INACTIVE
|
||||||
|
|
||||||
|
|
||||||
|
def test_omitted_wire_venue_is_auto_then_actual_venue_is_persisted() -> None:
|
||||||
|
repo = InMemorySlotRepository()
|
||||||
|
service = SlotService(repo)
|
||||||
|
intents = IntentService(
|
||||||
|
service,
|
||||||
|
repo,
|
||||||
|
InMemoryIntentStore(),
|
||||||
|
resolve_symbol=lambda portfolio_id: "005930",
|
||||||
|
)
|
||||||
|
repo.reconcile_universe("default", {"PF-1": "005930"})
|
||||||
|
broker = RecordingBroker(actual_venue="NXT")
|
||||||
|
execution = ExecutionService(service, repo, broker)
|
||||||
|
handler = make_intent_rpc_handler(intents)
|
||||||
|
|
||||||
|
response = handler(
|
||||||
|
RpcRequest(
|
||||||
|
payload={
|
||||||
|
"intent_type": "buy",
|
||||||
|
"intent_id": "auto-buy",
|
||||||
|
"portfolio_id": "PF-1",
|
||||||
|
"qty": 1,
|
||||||
|
},
|
||||||
|
correlation_id="auto-buy",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
slot_id = UUID(response.payload["slot_id"])
|
||||||
|
pending = repo.fetch_slot(slot_id)
|
||||||
|
assert pending is not None and pending.venue == "AUTO"
|
||||||
|
execution.run_once()
|
||||||
|
dispatched = repo.fetch_slot(slot_id)
|
||||||
|
assert broker.orders[0].venue == "AUTO"
|
||||||
|
assert dispatched is not None and dispatched.venue == "NXT"
|
||||||
|
|
||||||
|
|
||||||
def test_inactive_transition_resets_venue_to_krx() -> None:
|
def test_inactive_transition_resets_venue_to_krx() -> None:
|
||||||
repo, intents, _, _ = _wire()
|
repo, intents, _, _ = _wire()
|
||||||
handler = make_intent_rpc_handler(intents)
|
handler = make_intent_rpc_handler(intents)
|
||||||
@@ -131,6 +173,30 @@ def test_inactive_transition_resets_venue_to_krx() -> None:
|
|||||||
assert reset.venue == "KRX"
|
assert reset.venue == "KRX"
|
||||||
|
|
||||||
|
|
||||||
|
def test_holding_transition_resets_next_order_venue_to_auto() -> None:
|
||||||
|
repo, intents, _, _ = _wire()
|
||||||
|
decision = intents.submit_buy(
|
||||||
|
BuyIntent(
|
||||||
|
intent_id="nxt-buy-holding",
|
||||||
|
portfolio_id="PF-1",
|
||||||
|
qty=1,
|
||||||
|
venue="NXT",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
slot = repo.fetch_slot(decision.slot_id)
|
||||||
|
service = SlotService(repo)
|
||||||
|
holding = service.transit_slot(
|
||||||
|
slot.slot_id,
|
||||||
|
SlotState.PENDING_BUY,
|
||||||
|
SlotState.HOLDING,
|
||||||
|
reason="fill",
|
||||||
|
actor="test",
|
||||||
|
qty=1,
|
||||||
|
avg_price=1000,
|
||||||
|
)
|
||||||
|
assert holding.venue == "AUTO"
|
||||||
|
|
||||||
|
|
||||||
def test_operator_cancel_uses_original_nxt_venue() -> None:
|
def test_operator_cancel_uses_original_nxt_venue() -> None:
|
||||||
repo, intents, execution, broker = _wire()
|
repo, intents, execution, broker = _wire()
|
||||||
handler = make_intent_rpc_handler(intents)
|
handler = make_intent_rpc_handler(intents)
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ from __future__ import annotations
|
|||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, time, timedelta, timezone
|
||||||
from decimal import Decimal, InvalidOperation
|
from decimal import Decimal, InvalidOperation
|
||||||
from time import monotonic
|
from time import monotonic
|
||||||
from typing import Optional
|
from typing import Callable, Optional
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
@@ -37,6 +37,30 @@ _MOCK_HINTS = ("openapivts", "virtual", "mock")
|
|||||||
_FILL_POLL_MIN_INTERVAL_SECONDS = 5.0
|
_FILL_POLL_MIN_INTERVAL_SECONDS = 5.0
|
||||||
_FILL_POLL_TRANSPORT_BACKOFF_SECONDS = 30.0
|
_FILL_POLL_TRANSPORT_BACKOFF_SECONDS = 30.0
|
||||||
_SUBMISSION_LOOKUP_GRACE_SECONDS = 90.0
|
_SUBMISSION_LOOKUP_GRACE_SECONDS = 90.0
|
||||||
|
_SUPPORTED_ORDER_VENUES = frozenset({"AUTO", "KRX", "NXT", "SOR"})
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_order_venue(venue: str, at: datetime) -> str:
|
||||||
|
"""Resolve AUTO to the venue that accepts domestic-stock orders at `at`.
|
||||||
|
|
||||||
|
The gaps are intentional fail-closed intervals. They prevent an AUTO order
|
||||||
|
from silently falling back to KRX while the opening venue is indeterminate.
|
||||||
|
Explicit KRX/NXT/SOR values are preserved.
|
||||||
|
"""
|
||||||
|
normalized = str(venue).strip().upper()
|
||||||
|
if normalized not in _SUPPORTED_ORDER_VENUES:
|
||||||
|
raise ValueError("unsupported_order_venue")
|
||||||
|
if normalized != "AUTO":
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
current = at.astimezone(_KST).time().replace(tzinfo=None)
|
||||||
|
if time(8, 0) <= current < time(8, 50):
|
||||||
|
return "NXT"
|
||||||
|
if time(9, 0, 30) <= current < time(15, 30):
|
||||||
|
return "KRX"
|
||||||
|
if time(15, 30) <= current <= time(20, 0):
|
||||||
|
return "NXT"
|
||||||
|
raise ValueError("auto_venue_session_closed")
|
||||||
|
|
||||||
|
|
||||||
def _safe_int(v: object) -> Optional[int]:
|
def _safe_int(v: object) -> Optional[int]:
|
||||||
@@ -108,6 +132,7 @@ class KISBroker:
|
|||||||
timeout: float = 5.0,
|
timeout: float = 5.0,
|
||||||
allow_live: bool = False,
|
allow_live: bool = False,
|
||||||
token_cache=None,
|
token_cache=None,
|
||||||
|
clock: Optional[Callable[[], datetime]] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._account_ref = account_ref
|
self._account_ref = account_ref
|
||||||
self._app_key = app_key
|
self._app_key = app_key
|
||||||
@@ -117,6 +142,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._clock = clock or (lambda: datetime.now(tz=_KST))
|
||||||
self._fill_poll_not_before: dict[str, float] = {}
|
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:
|
||||||
@@ -226,23 +252,28 @@ 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)
|
submitted_at = self._clock().astimezone(_KST)
|
||||||
|
try:
|
||||||
|
actual_venue = resolve_order_venue(req.venue, submitted_at)
|
||||||
|
except ValueError as exc:
|
||||||
|
return OrderAck(accepted=False, reason=str(exc))
|
||||||
|
resolved_req = replace(req, venue=actual_venue)
|
||||||
body = {
|
body = {
|
||||||
"CANO": cano,
|
"CANO": cano,
|
||||||
"ACNT_PRDT_CD": product,
|
"ACNT_PRDT_CD": product,
|
||||||
"PDNO": req.symbol,
|
"PDNO": resolved_req.symbol,
|
||||||
"ORD_DVSN": req.order_type,
|
"ORD_DVSN": resolved_req.order_type,
|
||||||
"ORD_QTY": str(req.qty),
|
"ORD_QTY": str(resolved_req.qty),
|
||||||
"ORD_UNPR": str(req.limit_price or 0),
|
"ORD_UNPR": str(resolved_req.limit_price or 0),
|
||||||
# REQ-118: always send EXCG_ID_DVSN_CD from req.venue (default "KRX").
|
# REQ-118: always send EXCG_ID_DVSN_CD from req.venue (default "KRX").
|
||||||
# Previously omitted (server defaulted to KRX); explicit is more robust.
|
# Previously omitted (server defaulted to KRX); explicit is more robust.
|
||||||
# Real-account NXT order confirmed rt_cd=0 on 2026-07-06.
|
# Real-account NXT order confirmed rt_cd=0 on 2026-07-06.
|
||||||
"EXCG_ID_DVSN_CD": req.venue,
|
"EXCG_ID_DVSN_CD": actual_venue,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
resp = httpx.post(
|
resp = httpx.post(
|
||||||
f"{self._base_url}/uapi/domestic-stock/v1/trading/order-cash",
|
f"{self._base_url}/uapi/domestic-stock/v1/trading/order-cash",
|
||||||
headers=self._headers(self._tr_submit(req.side)),
|
headers=self._headers(self._tr_submit(resolved_req.side)),
|
||||||
json=body,
|
json=body,
|
||||||
timeout=self._timeout,
|
timeout=self._timeout,
|
||||||
)
|
)
|
||||||
@@ -253,15 +284,17 @@ class KISBroker:
|
|||||||
# the execution loop cannot submit the same order again.
|
# the execution loop cannot submit the same order again.
|
||||||
return OrderAck(
|
return OrderAck(
|
||||||
accepted=True,
|
accepted=True,
|
||||||
broker_order_id=_encode_submission_unknown(req, submitted_at),
|
broker_order_id=_encode_submission_unknown(resolved_req, submitted_at),
|
||||||
reason=f"submit_outcome_unknown:{type(exc).__name__}",
|
reason=f"submit_outcome_unknown:{type(exc).__name__}",
|
||||||
|
venue=actual_venue,
|
||||||
raw={"error_class": type(exc).__name__},
|
raw={"error_class": type(exc).__name__},
|
||||||
)
|
)
|
||||||
if getattr(resp, "status_code", 200) >= 500:
|
if getattr(resp, "status_code", 200) >= 500:
|
||||||
return OrderAck(
|
return OrderAck(
|
||||||
accepted=True,
|
accepted=True,
|
||||||
broker_order_id=_encode_submission_unknown(req, submitted_at),
|
broker_order_id=_encode_submission_unknown(resolved_req, submitted_at),
|
||||||
reason=f"submit_outcome_unknown:http_{resp.status_code}",
|
reason=f"submit_outcome_unknown:http_{resp.status_code}",
|
||||||
|
venue=actual_venue,
|
||||||
raw={"http_status": resp.status_code},
|
raw={"http_status": resp.status_code},
|
||||||
)
|
)
|
||||||
if str(payload.get("rt_cd")) == "0":
|
if str(payload.get("rt_cd")) == "0":
|
||||||
@@ -269,9 +302,17 @@ class KISBroker:
|
|||||||
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,
|
||||||
|
venue=actual_venue,
|
||||||
|
raw=payload,
|
||||||
|
)
|
||||||
return OrderAck(
|
return OrderAck(
|
||||||
accepted=False, reason=payload.get("msg1") or payload.get("msg_cd"), raw=payload
|
accepted=False,
|
||||||
|
reason=payload.get("msg1") or payload.get("msg_cd"),
|
||||||
|
venue=actual_venue,
|
||||||
|
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:
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ class PaperBroker:
|
|||||||
seq = self._seq
|
seq = self._seq
|
||||||
oid = f"PAPER-{seq:06d}"
|
oid = f"PAPER-{seq:06d}"
|
||||||
self._orders[oid] = _Order(oid, req)
|
self._orders[oid] = _Order(oid, req)
|
||||||
return OrderAck(accepted=True, broker_order_id=oid)
|
return OrderAck(accepted=True, broker_order_id=oid, venue=req.venue)
|
||||||
|
|
||||||
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:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
|||||||
@@ -285,13 +285,25 @@ class PostgreSQLSlotRepository:
|
|||||||
)
|
)
|
||||||
return [self._row_to_slot(r) for r in cur.fetchall()]
|
return [self._row_to_slot(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
def mark_dispatched(self, slot_id: UUID, broker_order_id: str) -> bool:
|
def mark_dispatched(
|
||||||
|
self,
|
||||||
|
slot_id: UUID,
|
||||||
|
broker_order_id: str,
|
||||||
|
*,
|
||||||
|
venue: Optional[str] = None,
|
||||||
|
) -> bool:
|
||||||
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(
|
||||||
"UPDATE daily_slot SET broker_order_id=%s, updated_at=%s "
|
"UPDATE daily_slot SET broker_order_id=%s, "
|
||||||
|
"venue=COALESCE(%s, venue), updated_at=%s "
|
||||||
"WHERE slot_id=%s AND broker_order_id IS NULL",
|
"WHERE slot_id=%s AND broker_order_id IS NULL",
|
||||||
(broker_order_id, datetime.now(tz=timezone.utc), slot_id),
|
(
|
||||||
|
broker_order_id,
|
||||||
|
venue,
|
||||||
|
datetime.now(tz=timezone.utc),
|
||||||
|
slot_id,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
ok = cur.rowcount == 1
|
ok = cur.rowcount == 1
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -456,7 +468,7 @@ class _PgIntentRecord:
|
|||||||
_OUTBOX_COLUMNS = (
|
_OUTBOX_COLUMNS = (
|
||||||
"correlation_id, asset_id, order_id, execution_id, side, qty, price, "
|
"correlation_id, asset_id, order_id, execution_id, side, qty, price, "
|
||||||
"fee, tax, executed_at, status, attempts, last_outcome, last_error, "
|
"fee, tax, executed_at, status, attempts, last_outcome, last_error, "
|
||||||
"created_at, updated_at, next_retry_at"
|
"created_at, updated_at, next_retry_at, venue"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -473,8 +485,8 @@ class PostgreSQLLedgerOutbox:
|
|||||||
cur.execute(
|
cur.execute(
|
||||||
"INSERT INTO ledger_outbox "
|
"INSERT INTO ledger_outbox "
|
||||||
"(correlation_id, asset_id, order_id, execution_id, side, "
|
"(correlation_id, asset_id, order_id, execution_id, side, "
|
||||||
" qty, price, fee, tax, executed_at) "
|
" qty, price, fee, tax, executed_at, venue) "
|
||||||
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) "
|
"VALUES (%s, %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.correlation_id,
|
||||||
@@ -487,6 +499,7 @@ class PostgreSQLLedgerOutbox:
|
|||||||
trade.fee,
|
trade.fee,
|
||||||
trade.tax,
|
trade.tax,
|
||||||
trade.executed_at,
|
trade.executed_at,
|
||||||
|
trade.venue,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -590,6 +603,7 @@ class PostgreSQLLedgerOutbox:
|
|||||||
fee=f(row[7]),
|
fee=f(row[7]),
|
||||||
tax=f(row[8]),
|
tax=f(row[8]),
|
||||||
executed_at=row[9],
|
executed_at=row[9],
|
||||||
|
venue=row[17],
|
||||||
)
|
)
|
||||||
return LedgerWorkItem(
|
return LedgerWorkItem(
|
||||||
correlation_id=row[0],
|
correlation_id=row[0],
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- dcy-041 / GitHub #44: AUTO may exist while an intent is pending, but the
|
||||||
|
-- actual submitted KRX/NXT value replaces it atomically with broker_order_id.
|
||||||
|
ALTER TABLE daily_slot
|
||||||
|
DROP CONSTRAINT IF EXISTS daily_slot_venue_check;
|
||||||
|
|
||||||
|
ALTER TABLE daily_slot
|
||||||
|
ADD CONSTRAINT daily_slot_venue_check
|
||||||
|
CHECK (venue IN ('AUTO', 'KRX', 'NXT', 'SOR'));
|
||||||
|
|
||||||
|
-- Core keeps the actual venue in its durable ledger outbox. Portfolio Hub's
|
||||||
|
-- HTTP schema is intentionally unchanged in this migration.
|
||||||
|
ALTER TABLE ledger_outbox
|
||||||
|
ADD COLUMN IF NOT EXISTS venue TEXT NOT NULL DEFAULT 'KRX';
|
||||||
|
|
||||||
|
ALTER TABLE ledger_outbox
|
||||||
|
DROP CONSTRAINT IF EXISTS ledger_outbox_venue_check;
|
||||||
|
|
||||||
|
ALTER TABLE ledger_outbox
|
||||||
|
ADD CONSTRAINT ledger_outbox_venue_check
|
||||||
|
CHECK (venue IN ('AUTO', 'KRX', 'NXT', 'SOR'));
|
||||||
@@ -45,6 +45,10 @@ class OrderAck:
|
|||||||
accepted: bool
|
accepted: bool
|
||||||
broker_order_id: Optional[str] = None
|
broker_order_id: Optional[str] = None
|
||||||
reason: Optional[str] = None
|
reason: Optional[str] = None
|
||||||
|
# Actual venue used for submission. KIS resolves AUTO before the HTTP call;
|
||||||
|
# execution persists this value with the broker order id so restart/cancel
|
||||||
|
# never has to infer the original venue.
|
||||||
|
venue: Optional[str] = None
|
||||||
raw: dict[str, Any] = field(default_factory=dict)
|
raw: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -201,7 +201,11 @@ 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,
|
||||||
|
venue=ack.venue,
|
||||||
|
)
|
||||||
r.dispatched += 1
|
r.dispatched += 1
|
||||||
if ack.reason and ack.reason.startswith("submit_outcome_unknown:"):
|
if ack.reason and ack.reason.startswith("submit_outcome_unknown:"):
|
||||||
_log.warning(
|
_log.warning(
|
||||||
@@ -643,6 +647,7 @@ class ExecutionService:
|
|||||||
fee=float(fs.fee),
|
fee=float(fs.fee),
|
||||||
tax=float(fs.tax),
|
tax=float(fs.tax),
|
||||||
executed_at=datetime.now(tz=timezone.utc),
|
executed_at=datetime.now(tz=timezone.utc),
|
||||||
|
venue=slot.venue,
|
||||||
)
|
)
|
||||||
self._ledger_outbox.enqueue(trade)
|
self._ledger_outbox.enqueue(trade)
|
||||||
r.ledger_enqueued += 1
|
r.ledger_enqueued += 1
|
||||||
|
|||||||
@@ -75,7 +75,9 @@ class BuyIntent:
|
|||||||
# slippage_pct: effective limit = entry_price*(1+pct/100) buy / *(1-pct/100) sell.
|
# slippage_pct: effective limit = entry_price*(1+pct/100) buy / *(1-pct/100) sell.
|
||||||
timeout_sec: Optional[int] = None
|
timeout_sec: Optional[int] = None
|
||||||
slippage_pct: Optional[float] = None
|
slippage_pct: Optional[float] = None
|
||||||
venue: str = "KRX"
|
# Strategy wire default. AUTO is resolved at the broker boundary so
|
||||||
|
# strategies remain session-agnostic while explicit KRX/NXT/SOR is kept.
|
||||||
|
venue: str = "AUTO"
|
||||||
extra: dict[str, Any] = field(default_factory=dict)
|
extra: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
@@ -94,7 +96,7 @@ class SellIntent:
|
|||||||
# dcy-022 / REQ-068: execution policy (optional, backward-compat).
|
# dcy-022 / REQ-068: execution policy (optional, backward-compat).
|
||||||
timeout_sec: Optional[int] = None
|
timeout_sec: Optional[int] = None
|
||||||
slippage_pct: Optional[float] = None
|
slippage_pct: Optional[float] = None
|
||||||
venue: str = "KRX"
|
venue: str = "AUTO"
|
||||||
extra: dict[str, Any] = field(default_factory=dict)
|
extra: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
@@ -211,7 +213,7 @@ class IntentService:
|
|||||||
dcy-036 / REQ-114: entry gate — if entry_allowed_fn returns False,
|
dcy-036 / REQ-114: entry gate — if entry_allowed_fn returns False,
|
||||||
BuyIntent is rejected before any slot lookup."""
|
BuyIntent is rejected before any slot lookup."""
|
||||||
|
|
||||||
if intent.venue not in {"KRX", "NXT", "SOR"}:
|
if intent.venue not in {"AUTO", "KRX", "NXT", "SOR"}:
|
||||||
return IntentDecision(intent.intent_id, "rejected", None, "invalid_venue")
|
return IntentDecision(intent.intent_id, "rejected", None, "invalid_venue")
|
||||||
|
|
||||||
# dcy-036 / REQ-114: day-start entry gate (fail-closed).
|
# dcy-036 / REQ-114: day-start entry gate (fail-closed).
|
||||||
@@ -308,7 +310,7 @@ class IntentService:
|
|||||||
return IntentDecision(intent.intent_id, "rejected", None, "already_active")
|
return IntentDecision(intent.intent_id, "rejected", None, "already_active")
|
||||||
|
|
||||||
def _admit_sell(self, intent: SellIntent) -> IntentDecision:
|
def _admit_sell(self, intent: SellIntent) -> IntentDecision:
|
||||||
if intent.venue not in {"KRX", "NXT", "SOR"}:
|
if intent.venue not in {"AUTO", "KRX", "NXT", "SOR"}:
|
||||||
return IntentDecision(intent.intent_id, "rejected", None, "invalid_venue")
|
return IntentDecision(intent.intent_id, "rejected", None, "invalid_venue")
|
||||||
|
|
||||||
slot = self._repo.fetch_slot(intent.slot_id)
|
slot = self._repo.fetch_slot(intent.slot_id)
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ class LedgerTrade:
|
|||||||
fee: float = 0.0
|
fee: float = 0.0
|
||||||
tax: float = 0.0
|
tax: float = 0.0
|
||||||
executed_at: Optional[datetime] = None
|
executed_at: Optional[datetime] = None
|
||||||
|
# Actual order venue persisted in the Core outbox. Portfolio Hub remains
|
||||||
|
# unchanged until its API contract explicitly accepts this additive field.
|
||||||
|
venue: str = "KRX"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def correlation_id(self) -> str:
|
def correlation_id(self) -> str:
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ def make_intent_rpc_handler(
|
|||||||
# dcy-022 / REQ-068: execution policy (optional, backward-compat).
|
# dcy-022 / REQ-068: execution policy (optional, backward-compat).
|
||||||
timeout_sec=_opt_int(body.get("timeout_sec")),
|
timeout_sec=_opt_int(body.get("timeout_sec")),
|
||||||
slippage_pct=_opt_float(body.get("slippage_pct")),
|
slippage_pct=_opt_float(body.get("slippage_pct")),
|
||||||
venue=_opt_str(body.get("venue")) or "KRX",
|
venue=_opt_str(body.get("venue")) or "AUTO",
|
||||||
)
|
)
|
||||||
d = intent_service.submit_buy(buy)
|
d = intent_service.submit_buy(buy)
|
||||||
elif intent_type == "sell":
|
elif intent_type == "sell":
|
||||||
@@ -133,7 +133,7 @@ def make_intent_rpc_handler(
|
|||||||
# dcy-022 / REQ-068: execution policy (optional, backward-compat).
|
# dcy-022 / REQ-068: execution policy (optional, backward-compat).
|
||||||
timeout_sec=_opt_int(body.get("timeout_sec")),
|
timeout_sec=_opt_int(body.get("timeout_sec")),
|
||||||
slippage_pct=_opt_float(body.get("slippage_pct")),
|
slippage_pct=_opt_float(body.get("slippage_pct")),
|
||||||
venue=_opt_str(body.get("venue")) or "KRX",
|
venue=_opt_str(body.get("venue")) or "AUTO",
|
||||||
)
|
)
|
||||||
d = intent_service.submit_sell(sell)
|
d = intent_service.submit_sell(sell)
|
||||||
else:
|
else:
|
||||||
@@ -627,4 +627,3 @@ def _parse_date(v) -> date_cls:
|
|||||||
|
|
||||||
def _today() -> date_cls:
|
def _today() -> date_cls:
|
||||||
return datetime.now(tz=timezone.utc).date()
|
return datetime.now(tz=timezone.utc).date()
|
||||||
|
|
||||||
|
|||||||
@@ -185,13 +185,22 @@ class InMemorySlotRepository:
|
|||||||
]
|
]
|
||||||
return out[:limit]
|
return out[:limit]
|
||||||
|
|
||||||
def mark_dispatched(self, slot_id: UUID, broker_order_id: str) -> bool:
|
def mark_dispatched(
|
||||||
"""Atomically set broker_order_id iff still undispatched (idempotency guard)."""
|
self,
|
||||||
|
slot_id: UUID,
|
||||||
|
broker_order_id: str,
|
||||||
|
*,
|
||||||
|
venue: Optional[str] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Atomically persist dispatch identity and the actual broker venue."""
|
||||||
s = self._slots.get(slot_id)
|
s = self._slots.get(slot_id)
|
||||||
if s is None or s.broker_order_id is not None:
|
if s is None or s.broker_order_id is not None:
|
||||||
return False
|
return False
|
||||||
self._slots[slot_id] = replace(
|
self._slots[slot_id] = replace(
|
||||||
s, broker_order_id=broker_order_id, updated_at=datetime.now(tz=timezone.utc)
|
s,
|
||||||
|
broker_order_id=broker_order_id,
|
||||||
|
venue=venue if venue is not None else s.venue,
|
||||||
|
updated_at=datetime.now(tz=timezone.utc),
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
@@ -215,10 +215,15 @@ class SlotService:
|
|||||||
new_limit_price = limit_price if limit_price is not None else slot.limit_price
|
new_limit_price = limit_price if limit_price is not None else slot.limit_price
|
||||||
new_timeout_sec = timeout_sec if timeout_sec is not None else slot.timeout_sec
|
new_timeout_sec = timeout_sec if timeout_sec is not None else slot.timeout_sec
|
||||||
new_slippage_pct = slippage_pct if slippage_pct is not None else slot.slippage_pct
|
new_slippage_pct = slippage_pct if slippage_pct is not None else slot.slippage_pct
|
||||||
|
new_venue = venue if venue is not None else slot.venue
|
||||||
else: # HOLDING / ERROR — pending order policy no longer applies.
|
else: # HOLDING / ERROR — pending order policy no longer applies.
|
||||||
new_limit_price = None
|
new_limit_price = None
|
||||||
new_timeout_sec = None
|
new_timeout_sec = None
|
||||||
new_slippage_pct = None
|
new_slippage_pct = None
|
||||||
|
# A completed order's venue must not leak into a later autonomous
|
||||||
|
# sell. The next order starts session-aware unless its intent
|
||||||
|
# explicitly chooses KRX/NXT/SOR.
|
||||||
|
new_venue = "AUTO" if to_state is SlotState.HOLDING else slot.venue
|
||||||
|
|
||||||
# occupancy_seq (dcy-028/REQ-081): bump when a slot begins a new
|
# occupancy_seq (dcy-028/REQ-081): bump when a slot begins a new
|
||||||
# occupancy (inactive→pending_buy). Stable across the rest of the
|
# occupancy (inactive→pending_buy). Stable across the rest of the
|
||||||
@@ -271,7 +276,7 @@ class SlotService:
|
|||||||
timeout_sec=new_timeout_sec,
|
timeout_sec=new_timeout_sec,
|
||||||
slippage_pct=new_slippage_pct,
|
slippage_pct=new_slippage_pct,
|
||||||
occupancy_seq=new_occupancy_seq,
|
occupancy_seq=new_occupancy_seq,
|
||||||
venue=venue if venue is not None else slot.venue,
|
venue=new_venue,
|
||||||
updated_at=datetime.now(tz=timezone.utc),
|
updated_at=datetime.now(tz=timezone.utc),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user