fix: converge broker failures without retry

This commit is contained in:
Hermes Agent
2026-08-19 11:29:26 +09:00
parent be98965f2c
commit 15aeae6930
15 changed files with 181 additions and 211 deletions
+6 -17
View File
@@ -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'));
+2 -3
View File
@@ -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
+5 -6
View File
@@ -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)
+2 -34
View File
@@ -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
+2 -3
View File
@@ -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