feat: persist skipped ledger fills
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
This commit is contained in:
co-authored by
genichin
parent
df0258bc36
commit
76cde2edd0
@@ -14,11 +14,11 @@ from __future__ import annotations
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import UTC, datetime, timedelta, timezone
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from time import monotonic
|
||||
from typing import Callable, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -47,7 +47,7 @@ class KISEvidenceLookupError(RuntimeError):
|
||||
"""Read-only daily-fill evidence lookup failed."""
|
||||
|
||||
|
||||
def _balance_http_error_code(status: Optional[int]) -> str:
|
||||
def _balance_http_error_code(status: int | None) -> str:
|
||||
if status == 401:
|
||||
return "authentication_failed"
|
||||
if status == 403:
|
||||
@@ -69,7 +69,7 @@ def resolve_order_venue(venue: str, at: datetime) -> str:
|
||||
return normalized
|
||||
|
||||
|
||||
def _safe_int(v: object) -> Optional[int]:
|
||||
def _safe_int(v: object) -> int | None:
|
||||
try:
|
||||
return int(str(v).strip())
|
||||
except (TypeError, ValueError):
|
||||
@@ -92,7 +92,7 @@ def _encode_submission_unknown(req: OrderRequest, submitted_at: datetime) -> str
|
||||
return f"{_SUBMISSION_UNKNOWN_PREFIX}{encoded}"
|
||||
|
||||
|
||||
def _decode_submission_unknown(broker_order_id: str) -> Optional[dict]:
|
||||
def _decode_submission_unknown(broker_order_id: str) -> dict | None:
|
||||
if not broker_order_id.startswith(_SUBMISSION_UNKNOWN_PREFIX):
|
||||
return None
|
||||
encoded = broker_order_id[len(_SUBMISSION_UNKNOWN_PREFIX) :]
|
||||
@@ -138,15 +138,15 @@ class KISBroker:
|
||||
timeout: float = 5.0,
|
||||
allow_live: bool = False,
|
||||
token_cache=None,
|
||||
clock: Optional[Callable[[], datetime]] = None,
|
||||
clock: Callable[[], datetime] | None = None,
|
||||
) -> None:
|
||||
self._account_ref = account_ref
|
||||
self._app_key = app_key
|
||||
self._app_secret = app_secret
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._timeout = timeout
|
||||
self._token: Optional[str] = None
|
||||
self._token_exp: datetime = datetime.now(tz=timezone.utc)
|
||||
self._token: str | None = None
|
||||
self._token_exp: datetime = datetime.now(tz=UTC)
|
||||
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] = {}
|
||||
@@ -197,12 +197,12 @@ class KISBroker:
|
||||
self._app_secret = credential.app_secret
|
||||
# Invalidate L1 in-memory token so next call re-issues.
|
||||
self._token = None
|
||||
self._token_exp = datetime.min.replace(tzinfo=timezone.utc)
|
||||
self._token_exp = datetime.min.replace(tzinfo=UTC)
|
||||
|
||||
# --- auth ---
|
||||
def _ensure_token(self) -> str:
|
||||
# L1: in-memory
|
||||
if self._token and datetime.now(tz=timezone.utc) < self._token_exp:
|
||||
if self._token and datetime.now(tz=UTC) < self._token_exp:
|
||||
return self._token
|
||||
# L2: shared/persistent cache (Redis) — survives restart / multi-process (REQ-016)
|
||||
if self._token_cache is not None:
|
||||
@@ -224,7 +224,7 @@ class KISBroker:
|
||||
data = resp.json()
|
||||
self._token = data["access_token"]
|
||||
ttl = int(data.get("expires_in", 3600))
|
||||
self._token_exp = datetime.now(tz=timezone.utc) + timedelta(seconds=max(60, ttl - 300))
|
||||
self._token_exp = datetime.now(tz=UTC) + timedelta(seconds=max(60, ttl - 300))
|
||||
if self._token_cache is not None:
|
||||
self._token_cache.store(self._account_ref, self._app_key, self._token, self._token_exp)
|
||||
return self._token
|
||||
@@ -364,8 +364,8 @@ class KISBroker:
|
||||
*,
|
||||
side: str,
|
||||
symbol: str,
|
||||
order_date: Optional[str] = None,
|
||||
submitted_at: Optional[datetime] = None,
|
||||
order_date: str | None = None,
|
||||
submitted_at: datetime | None = None,
|
||||
) -> FillStatus:
|
||||
if broker_order_id.startswith(_SUBMISSION_UNKNOWN_PREFIX):
|
||||
return self._poll_unknown_submission(broker_order_id, side=side, symbol=symbol)
|
||||
@@ -424,7 +424,7 @@ class KISBroker:
|
||||
if row is None:
|
||||
visible_grace = (
|
||||
submitted_at is None
|
||||
or (self._clock().astimezone(timezone.utc) - submitted_at.astimezone(timezone.utc))
|
||||
or (self._clock().astimezone(UTC) - submitted_at.astimezone(UTC))
|
||||
.total_seconds()
|
||||
< _SUBMISSION_LOOKUP_GRACE_SECONDS
|
||||
)
|
||||
@@ -639,11 +639,21 @@ class KISBroker:
|
||||
def _executed_at_from_row(row: dict, fallback_date: str) -> datetime:
|
||||
order_date = str(row.get("ord_dt") or fallback_date).strip()
|
||||
order_time = str(row.get("ord_tmd") or "").strip()[:6]
|
||||
year = int(order_date[0:4])
|
||||
month = int(order_date[4:6])
|
||||
day = int(order_date[6:8])
|
||||
if len(order_time) == 6 and order_time.isdigit():
|
||||
executed_at = datetime.strptime(order_date + order_time, "%Y%m%d%H%M%S")
|
||||
return executed_at.replace(tzinfo=_KST).astimezone(timezone.utc)
|
||||
executed_at = datetime.strptime(order_date, "%Y%m%d")
|
||||
return executed_at.replace(tzinfo=_KST).astimezone(timezone.utc)
|
||||
executed_at = datetime(
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
int(order_time[0:2]),
|
||||
int(order_time[2:4]),
|
||||
int(order_time[4:6]),
|
||||
tzinfo=_KST,
|
||||
)
|
||||
return executed_at.astimezone(UTC)
|
||||
return datetime(year, month, day, tzinfo=_KST).astimezone(UTC)
|
||||
|
||||
def lookup_fill_evidence(self, *, trading_date: str, execution_id: str) -> dict:
|
||||
"""Read one exact filled/partial-cancel evidence row for a historical execution."""
|
||||
@@ -737,7 +747,7 @@ class KISBroker:
|
||||
if cancelled:
|
||||
return FillStatus(state="cancelled", raw=row) # full external cancel
|
||||
return FillStatus(state="accepted", raw=row)
|
||||
if ccld_qty >= ord_qty and ord_qty > 0:
|
||||
if ccld_qty >= ord_qty > 0:
|
||||
# Deterministic execution_id (dcy-004 OQ-3, orbit kis.py rule):
|
||||
# kis-daily-ccld:{ord_dt}:{ord_gno_brno}:{odno}:{tot_ccld_qty}
|
||||
ord_dt = str(row.get("ord_dt") or today)
|
||||
@@ -760,6 +770,7 @@ class KISBroker:
|
||||
fee=fee,
|
||||
tax=tax,
|
||||
execution_venue=execution_venue,
|
||||
executed_at=KISBroker._executed_at_from_row(row, ord_dt),
|
||||
raw=row,
|
||||
)
|
||||
# 0 < ccld_qty < ord_qty
|
||||
@@ -784,6 +795,7 @@ class KISBroker:
|
||||
fee=fee,
|
||||
tax=tax,
|
||||
execution_venue=execution_venue,
|
||||
executed_at=KISBroker._executed_at_from_row(row, ord_dt),
|
||||
cancelled=True,
|
||||
raw=row,
|
||||
)
|
||||
@@ -827,7 +839,7 @@ class KISBroker:
|
||||
|
||||
quantities: dict[str, int] = {}
|
||||
seen_continuations: set[tuple[str, str]] = set()
|
||||
continuation: Optional[tuple[str, str]] = None
|
||||
continuation: tuple[str, str] | None = None
|
||||
while True:
|
||||
request_headers = headers
|
||||
if continuation is not None:
|
||||
@@ -944,7 +956,7 @@ class KISBroker:
|
||||
raise BrokerBalanceError("malformed_response")
|
||||
return amount
|
||||
|
||||
def get_current_price(self, symbol: str, market: str = "J") -> Optional[Decimal]:
|
||||
def get_current_price(self, symbol: str, market: str = "J") -> Decimal | None:
|
||||
"""Current traded price `stck_prpr` via KIS inquire-price (REQ-038, REQ-118).
|
||||
|
||||
tr_id: `FHKST01010100` per REQ-038 AC-7 (live and mock share the same tr_id).
|
||||
|
||||
@@ -8,9 +8,8 @@ execution code path that production (KIS) takes.
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from trex_core.adapters.broker.seq_store import SeqStore
|
||||
from trex_core.application.broker_port import (
|
||||
@@ -52,9 +51,9 @@ class PaperBroker:
|
||||
self,
|
||||
*,
|
||||
fill_after_polls: int = 0,
|
||||
qty_per_poll: Optional[int] = None,
|
||||
reject_symbols: Optional[set[str]] = None,
|
||||
seq_store: Optional[SeqStore] = None,
|
||||
qty_per_poll: int | None = None,
|
||||
reject_symbols: set[str] | None = None,
|
||||
seq_store: SeqStore | None = None,
|
||||
) -> None:
|
||||
self._fill_after = fill_after_polls
|
||||
self._qty_per_poll = qty_per_poll
|
||||
@@ -90,8 +89,8 @@ class PaperBroker:
|
||||
*,
|
||||
side: str,
|
||||
symbol: str,
|
||||
order_date: Optional[str] = None,
|
||||
submitted_at: Optional[datetime] = None,
|
||||
order_date: str | None = None,
|
||||
submitted_at: datetime | None = None,
|
||||
) -> FillStatus:
|
||||
with self._lock:
|
||||
o = self._orders.get(broker_order_id)
|
||||
@@ -122,6 +121,7 @@ class PaperBroker:
|
||||
state="filled", filled_qty=o.filled, avg_price=o.price,
|
||||
execution_id=execution_id, fee=fee, tax=tax,
|
||||
execution_venue=o.execution_venue,
|
||||
executed_at=datetime.now(tz=UTC),
|
||||
)
|
||||
return FillStatus(
|
||||
state="partially_filled",
|
||||
@@ -172,7 +172,7 @@ class PaperBroker:
|
||||
with self._lock:
|
||||
self._prices[symbol] = Decimal(str(price))
|
||||
|
||||
def get_current_price(self, symbol: str, market: str = "J") -> Optional[Decimal]:
|
||||
def get_current_price(self, symbol: str, market: str = "J") -> Decimal | None:
|
||||
"""Return the programmed current price for symbol, or None if unset.
|
||||
|
||||
market accepted for BrokerPort protocol conformance (REQ-118) but
|
||||
|
||||
Reference in New Issue
Block a user