fix: converge broker failures without retry
This commit is contained in:
@@ -15,7 +15,7 @@ import base64
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, time, timedelta, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from time import monotonic
|
||||
from typing import Callable, Optional
|
||||
@@ -40,7 +40,7 @@ _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
|
||||
_SUPPORTED_ORDER_VENUES = frozenset({"AUTO", "KRX", "NXT", "SOR"})
|
||||
_SUPPORTED_ORDER_VENUES = frozenset({"KRX", "NXT", "SOR"})
|
||||
|
||||
|
||||
def _balance_http_error_code(status: Optional[int]) -> str:
|
||||
@@ -54,26 +54,15 @@ def _balance_http_error_code(status: Optional[int]) -> str:
|
||||
|
||||
|
||||
def resolve_order_venue(venue: str, at: datetime) -> str:
|
||||
"""Resolve AUTO to the venue that accepts domestic-stock orders at `at`.
|
||||
"""Validate and preserve an explicit KRX/NXT/SOR order venue.
|
||||
|
||||
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.
|
||||
``at`` remains in the signature for compatibility with existing callers;
|
||||
venue selection is deliberately independent of wall-clock time.
|
||||
"""
|
||||
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")
|
||||
return normalized
|
||||
|
||||
|
||||
def _safe_int(v: object) -> Optional[int]:
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
-- GitHub #61: remove Core-owned wall-clock AUTO routing.
|
||||
-- Existing durable AUTO values are normalized before the stricter constraints.
|
||||
UPDATE daily_slot SET venue = 'SOR' WHERE venue = 'AUTO';
|
||||
UPDATE ledger_outbox SET venue = 'SOR' WHERE venue = 'AUTO';
|
||||
|
||||
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 ('KRX', 'NXT', 'SOR'));
|
||||
|
||||
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 ('KRX', 'NXT', 'SOR'));
|
||||
@@ -71,9 +71,8 @@ class OrderAck:
|
||||
accepted: bool
|
||||
broker_order_id: 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.
|
||||
# Actual venue used for submission. 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)
|
||||
submitted_at: Optional[datetime] = None
|
||||
|
||||
@@ -77,9 +77,8 @@ class BuyIntent:
|
||||
# slippage_pct: effective limit = entry_price*(1+pct/100) buy / *(1-pct/100) sell.
|
||||
timeout_sec: Optional[int] = None
|
||||
slippage_pct: Optional[float] = None
|
||||
# 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"
|
||||
# Missing strategy venue uses KIS SOR; explicit KRX/NXT/SOR is preserved.
|
||||
venue: str = "SOR"
|
||||
extra: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@@ -98,7 +97,7 @@ class SellIntent:
|
||||
# dcy-022 / REQ-068: execution policy (optional, backward-compat).
|
||||
timeout_sec: Optional[int] = None
|
||||
slippage_pct: Optional[float] = None
|
||||
venue: str = "AUTO"
|
||||
venue: str = "SOR"
|
||||
extra: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@@ -229,7 +228,7 @@ class IntentService:
|
||||
timeout_error = self._timeout_error(intent.timeout_sec)
|
||||
if timeout_error is not None:
|
||||
return IntentDecision(intent.intent_id, "rejected", None, timeout_error)
|
||||
if intent.venue not in {"AUTO", "KRX", "NXT", "SOR"}:
|
||||
if intent.venue not in {"KRX", "NXT", "SOR"}:
|
||||
return IntentDecision(intent.intent_id, "rejected", None, "invalid_venue")
|
||||
|
||||
# dcy-036 / REQ-114: day-start entry gate (fail-closed).
|
||||
@@ -347,7 +346,7 @@ class IntentService:
|
||||
timeout_error = self._timeout_error(intent.timeout_sec)
|
||||
if timeout_error is not None:
|
||||
return IntentDecision(intent.intent_id, "rejected", None, timeout_error)
|
||||
if intent.venue not in {"AUTO", "KRX", "NXT", "SOR"}:
|
||||
if intent.venue not in {"KRX", "NXT", "SOR"}:
|
||||
return IntentDecision(intent.intent_id, "rejected", None, "invalid_venue")
|
||||
|
||||
slot = self._repo.fetch_slot(intent.slot_id)
|
||||
|
||||
@@ -30,32 +30,6 @@ from trex_core.domain.slot import (
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---- sell-reject retryability (issue #27 / REQ-007 CHG-20260615-01) ---------
|
||||
# core-owned reason -> retryable mapping for the rejected SellIntent response.
|
||||
# Maintained with the reject vocabulary (intents.py _admit_sell/_dedup) so a new
|
||||
# reason gets a correct flag without the daemon touching a table. Unrecognized
|
||||
# reasons default to retryable=True: a stranded stop-loss (held losing position)
|
||||
# is worse than bounded, observable hammering on a terminal decision.
|
||||
_SELL_REJECT_RETRYABLE: dict[str, bool] = {
|
||||
"slot_state_changed": True,
|
||||
"slot_not_holding:pending_buy": True,
|
||||
"duplicate_in_flight": True,
|
||||
"slot_not_found": False,
|
||||
"slot_not_holding:inactive": False,
|
||||
"slot_not_holding:pending_sell": False,
|
||||
"slot_not_holding:error": False,
|
||||
}
|
||||
|
||||
|
||||
def _sell_reject_retryable(reason: Optional[str]) -> bool:
|
||||
"""Map a rejected-sell reason to retryable (issue #27). Default True (safety)."""
|
||||
if reason is None:
|
||||
return True
|
||||
# malformed payload: client defect — retrying the same bytes is pointless.
|
||||
if reason.startswith("bad_request"):
|
||||
return False
|
||||
return _SELL_REJECT_RETRYABLE.get(reason, True)
|
||||
|
||||
|
||||
# ---- intent RPC handler -----------------------------------------------------
|
||||
|
||||
@@ -117,7 +91,7 @@ def make_intent_rpc_handler(
|
||||
# dcy-022 / REQ-068: execution policy (optional, backward-compat).
|
||||
timeout_sec=_opt_int(body.get("timeout_sec")),
|
||||
slippage_pct=_opt_float(body.get("slippage_pct")),
|
||||
venue=_opt_str(body.get("venue")) or "AUTO",
|
||||
venue=_opt_str(body.get("venue")) or "SOR",
|
||||
)
|
||||
d = intent_service.submit_buy(buy)
|
||||
elif intent_type == "sell":
|
||||
@@ -133,7 +107,7 @@ def make_intent_rpc_handler(
|
||||
# dcy-022 / REQ-068: execution policy (optional, backward-compat).
|
||||
timeout_sec=_opt_int(body.get("timeout_sec")),
|
||||
slippage_pct=_opt_float(body.get("slippage_pct")),
|
||||
venue=_opt_str(body.get("venue")) or "AUTO",
|
||||
venue=_opt_str(body.get("venue")) or "SOR",
|
||||
)
|
||||
d = intent_service.submit_sell(sell)
|
||||
else:
|
||||
@@ -146,9 +120,6 @@ def make_intent_rpc_handler(
|
||||
"intent_id": intent_id, "status": "rejected",
|
||||
"slot_id": None, "reason": f"bad_request:{exc}",
|
||||
}
|
||||
# issue #27: sell-side malformed payload carries retryable=False.
|
||||
if intent_type == "sell":
|
||||
payload["retryable"] = _sell_reject_retryable(payload["reason"])
|
||||
return RpcResponse(payload=payload)
|
||||
payload = {
|
||||
"intent_id": d.intent_id,
|
||||
@@ -156,9 +127,6 @@ def make_intent_rpc_handler(
|
||||
"slot_id": (str(d.slot_id) if d.slot_id else None),
|
||||
"reason": d.reason,
|
||||
}
|
||||
# issue #27 / REQ-007 AC-7: additive retryable on rejected sell only.
|
||||
if intent_type == "sell" and d.status == "rejected":
|
||||
payload["retryable"] = _sell_reject_retryable(d.reason)
|
||||
return RpcResponse(payload=payload)
|
||||
|
||||
return handler
|
||||
|
||||
@@ -232,9 +232,8 @@ class SlotService:
|
||||
new_timeout_sec = 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
|
||||
# sell. Missing venue defaults to KIS SOR for the next order.
|
||||
new_venue = "SOR" if to_state is SlotState.HOLDING else slot.venue
|
||||
|
||||
# occupancy_seq (dcy-028/REQ-081): bump when a slot begins a new
|
||||
# occupancy (inactive→pending_buy). Stable across the rest of the
|
||||
|
||||
Reference in New Issue
Block a user