fix: close broker uncertainty audit gaps
This commit is contained in:
@@ -43,6 +43,16 @@ _SUBMISSION_LOOKUP_GRACE_SECONDS = 90.0
|
||||
_SUPPORTED_ORDER_VENUES = frozenset({"AUTO", "KRX", "NXT", "SOR"})
|
||||
|
||||
|
||||
def _balance_http_error_code(status: Optional[int]) -> str:
|
||||
if status == 401:
|
||||
return "authentication_failed"
|
||||
if status == 403:
|
||||
return "authorization_failed"
|
||||
if status in {408, 425, 429} or (status is not None and status >= 500):
|
||||
return "transport_failed"
|
||||
return "broker_rejected"
|
||||
|
||||
|
||||
def resolve_order_venue(venue: str, at: datetime) -> str:
|
||||
"""Resolve AUTO to the venue that accepts domestic-stock orders at `at`.
|
||||
|
||||
@@ -161,7 +171,8 @@ class KISBroker:
|
||||
def account_scope_hash(self) -> str:
|
||||
"""Stable non-secret identity for account-scoped persisted state."""
|
||||
cano, product = self._account()
|
||||
return hashlib.sha256(f"{cano}|{product}".encode()).hexdigest()
|
||||
material = f"{self._base_url}|{cano}|{product}|{self._app_key}"
|
||||
return hashlib.sha256(material.encode()).hexdigest()
|
||||
|
||||
# --- credential hot-swap (dcy-036 / REQ-113 AC-2 / REQ-115 AC-1) ----------
|
||||
def reinitialize(self, credential) -> None:
|
||||
@@ -658,12 +669,7 @@ class KISBroker:
|
||||
payload = resp.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
status = exc.response.status_code if exc.response is not None else None
|
||||
code = (
|
||||
"authentication_failed"
|
||||
if status == 401
|
||||
else ("authorization_failed" if status == 403 else "transport_failed")
|
||||
)
|
||||
raise BrokerBalanceError(code) from None
|
||||
raise BrokerBalanceError(_balance_http_error_code(status)) from None
|
||||
except httpx.HTTPError:
|
||||
raise BrokerBalanceError("transport_failed") from None
|
||||
except (ValueError, TypeError):
|
||||
@@ -721,12 +727,7 @@ class KISBroker:
|
||||
raise
|
||||
except httpx.HTTPStatusError as exc:
|
||||
status = exc.response.status_code if exc.response is not None else None
|
||||
code = (
|
||||
"authentication_failed"
|
||||
if status == 401
|
||||
else ("authorization_failed" if status == 403 else "transport_failed")
|
||||
)
|
||||
raise BrokerBalanceError(code) from None
|
||||
raise BrokerBalanceError(_balance_http_error_code(status)) from None
|
||||
except httpx.HTTPError:
|
||||
raise BrokerBalanceError("transport_failed") from None
|
||||
except (KeyError, TypeError, ValueError):
|
||||
|
||||
@@ -341,6 +341,8 @@ class PostgreSQLSlotRepository:
|
||||
slot_id: UUID,
|
||||
broker_order_id: str,
|
||||
state: str,
|
||||
*,
|
||||
only_if_unset: bool = False,
|
||||
) -> bool:
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
|
||||
@@ -349,8 +351,16 @@ class PostgreSQLSlotRepository:
|
||||
"UPDATE daily_slot SET cancel_state=%s, "
|
||||
"cancel_requested_at=%s, updated_at=%s "
|
||||
"WHERE slot_id=%s AND broker_order_id=%s "
|
||||
"AND status IN ('pending_buy','pending_sell')",
|
||||
(state, now, now, slot_id, broker_order_id),
|
||||
"AND status IN ('pending_buy','pending_sell') "
|
||||
"AND (%s = FALSE OR cancel_state IS NULL)",
|
||||
(
|
||||
state,
|
||||
now,
|
||||
now,
|
||||
slot_id,
|
||||
broker_order_id,
|
||||
only_if_unset,
|
||||
),
|
||||
)
|
||||
ok = cur.rowcount == 1
|
||||
conn.commit()
|
||||
|
||||
@@ -8,4 +8,6 @@ ALTER TABLE daily_slot
|
||||
|
||||
ALTER TABLE daily_slot
|
||||
ADD CONSTRAINT daily_slot_venue_check
|
||||
CHECK (venue IN ('KRX', 'NXT', 'SOR'));
|
||||
-- apply_migrations() replays every file on each boot. Keep this historical
|
||||
-- migration compatible with AUTO rows introduced by migration 019.
|
||||
CHECK (venue IN ('AUTO', 'KRX', 'NXT', 'SOR'));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
-- dcy-041 / GitHub #45: account-scoped last healthy KIS snapshot.
|
||||
-- account_hash is SHA-256(CANO|ACNT_PRDT_CD); no plaintext account identity,
|
||||
-- account_hash is SHA-256(base_url|CANO|ACNT_PRDT_CD|app_key); no plaintext identity,
|
||||
-- token, key, order response, or broker payload is persisted.
|
||||
CREATE TABLE IF NOT EXISTS broker_account_snapshot (
|
||||
account_hash TEXT PRIMARY KEY,
|
||||
|
||||
@@ -579,6 +579,7 @@ class ExecutionService:
|
||||
slot.slot_id,
|
||||
str(slot.broker_order_id),
|
||||
CancelState.UNKNOWN.value,
|
||||
only_if_unset=True,
|
||||
):
|
||||
_log.error(
|
||||
"cancel attempt reservation conflict: slot=%s broker_order_id=%s",
|
||||
@@ -719,6 +720,8 @@ class ExecutionService:
|
||||
if not slot.broker_order_id:
|
||||
# Claimed but not yet dispatched — execution loop will pick up or rollback.
|
||||
return "not_dispatched"
|
||||
if slot.cancel_state is not None:
|
||||
return "cancel_already_recorded"
|
||||
side = "buy" if slot.status is SlotState.PENDING_BUY else "sell"
|
||||
qty = int(slot.qty or 0)
|
||||
try:
|
||||
@@ -735,8 +738,9 @@ class ExecutionService:
|
||||
slot.slot_id,
|
||||
str(slot.broker_order_id),
|
||||
CancelState.UNKNOWN.value,
|
||||
only_if_unset=True,
|
||||
):
|
||||
return "cancel_state_conflict"
|
||||
return "cancel_already_recorded"
|
||||
outcome = self._broker.cancel_order(slot.broker_order_id, **cancel_kwargs)
|
||||
except Exception: # noqa: BLE001 - adapter failure becomes durable unknown
|
||||
outcome = CancelOutcome(CancelState.UNKNOWN, "adapter_exception")
|
||||
|
||||
@@ -30,6 +30,7 @@ from uuid import UUID
|
||||
|
||||
from trex_core.application.ports import IntentStore
|
||||
from trex_core.domain.slot import (
|
||||
SlotAdmissionRejectedError,
|
||||
SlotRepository,
|
||||
SlotService,
|
||||
SlotState,
|
||||
@@ -275,20 +276,24 @@ class IntentService:
|
||||
symbol = _resolve(slot)
|
||||
if not symbol:
|
||||
return IntentDecision(intent.intent_id, "rejected", None, "unknown_portfolio")
|
||||
if self._buy_guard_fn is not None:
|
||||
def _admission_precondition(_locked_slot) -> Optional[str]:
|
||||
# Runs inside SlotService's instance-scoped advisory lock. This
|
||||
# makes capacity and cached-cash reservation checks atomic with
|
||||
# the PENDING_BUY transition across same-instance daemons.
|
||||
if (
|
||||
not is_increase
|
||||
and self._max_active_slots is not None
|
||||
and self._repo.count_active(self._instance_key)
|
||||
>= self._max_active_slots
|
||||
):
|
||||
return "capacity_full"
|
||||
if self._buy_guard_fn is None:
|
||||
return None
|
||||
try:
|
||||
guard_reason = self._buy_guard_fn(intent)
|
||||
return self._buy_guard_fn(intent)
|
||||
except Exception: # noqa: BLE001 - buy guard fails closed
|
||||
guard_reason = "account_snapshot_unavailable"
|
||||
if guard_reason is not None:
|
||||
return IntentDecision(
|
||||
intent.intent_id, "rejected", None, guard_reason
|
||||
)
|
||||
# M cap applies only to NEW activations (initial buy). An increase on
|
||||
# an already-active asset does not change the active asset count.
|
||||
if (not is_increase and self._max_active_slots is not None
|
||||
and self._repo.count_active(self._instance_key) >= self._max_active_slots):
|
||||
return IntentDecision(intent.intent_id, "rejected", None, "capacity_full")
|
||||
return "account_snapshot_unavailable"
|
||||
|
||||
# dcy-022 / REQ-068: resolve effective timeout (per-intent > config default).
|
||||
effective_timeout = (
|
||||
intent.timeout_sec
|
||||
@@ -317,6 +322,11 @@ class IntentService:
|
||||
slippage_pct=intent.slippage_pct,
|
||||
venue=intent.venue,
|
||||
extra={"intent_id": intent.intent_id, "symbol": symbol},
|
||||
precondition=_admission_precondition,
|
||||
)
|
||||
except SlotAdmissionRejectedError as exc:
|
||||
return IntentDecision(
|
||||
intent.intent_id, "rejected", None, exc.reason
|
||||
)
|
||||
except SlotStateMismatchError:
|
||||
continue # slot changed under us; re-fetch and re-evaluate
|
||||
|
||||
@@ -11,11 +11,13 @@ Public API:
|
||||
- EXPECTED_SLOT_FIELDS: canonical `Slot` column contract (guarded by test_contract)
|
||||
- SlotService: main domain service (transit, sell, thresholds, capacity)
|
||||
- SlotRepository: abstract storage protocol
|
||||
- SlotTransitionError / SlotStateMismatchError / SlotNotFoundError: domain errors
|
||||
- SlotTransitionError / SlotStateMismatchError / SlotAdmissionRejectedError /
|
||||
SlotNotFoundError: domain errors
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from trex_core.domain.slot.errors import (
|
||||
SlotAdmissionRejectedError,
|
||||
SlotDomainError,
|
||||
SlotNotFoundError,
|
||||
SlotStateMismatchError,
|
||||
@@ -42,6 +44,7 @@ __all__ = [
|
||||
"EXPECTED_SLOT_FIELDS",
|
||||
"SLOT_ADVISORY_LOCK_ID",
|
||||
"Slot",
|
||||
"SlotAdmissionRejectedError",
|
||||
"SlotCapacity",
|
||||
"SlotDomainError",
|
||||
"SlotNotFoundError",
|
||||
|
||||
@@ -21,3 +21,11 @@ class SlotStateMismatchError(SlotDomainError):
|
||||
different state between read and write (race detection inside the
|
||||
transition function).
|
||||
"""
|
||||
|
||||
|
||||
class SlotAdmissionRejectedError(SlotDomainError):
|
||||
"""Raised when an advisory-locked admission precondition rejects."""
|
||||
|
||||
def __init__(self, reason: str) -> None:
|
||||
self.reason = reason
|
||||
super().__init__(reason)
|
||||
|
||||
@@ -224,6 +224,8 @@ class InMemorySlotRepository:
|
||||
slot_id: UUID,
|
||||
broker_order_id: str,
|
||||
state: str,
|
||||
*,
|
||||
only_if_unset: bool = False,
|
||||
) -> bool:
|
||||
"""Persist one cancel attempt iff the same order is still pending."""
|
||||
s = self._slots.get(slot_id)
|
||||
@@ -231,6 +233,7 @@ class InMemorySlotRepository:
|
||||
s is None
|
||||
or s.broker_order_id != broker_order_id
|
||||
or s.status not in self._PENDING
|
||||
or (only_if_unset and s.cancel_state is not None)
|
||||
):
|
||||
return False
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
|
||||
@@ -15,10 +15,11 @@ from __future__ import annotations
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
from typing import Any, Optional
|
||||
from typing import Any, Callable, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from trex_core.domain.slot.errors import (
|
||||
SlotAdmissionRejectedError,
|
||||
SlotNotFoundError,
|
||||
SlotStateMismatchError,
|
||||
SlotTransitionError,
|
||||
@@ -87,6 +88,7 @@ class SlotService:
|
||||
slippage_pct: Optional[float] = None,
|
||||
venue: Optional[str] = None,
|
||||
extra: Optional[dict[str, Any]] = None,
|
||||
precondition: Optional[Callable[[Slot], Optional[str]]] = None,
|
||||
) -> Slot:
|
||||
"""Transition slot from one state to another atomically.
|
||||
|
||||
@@ -116,6 +118,10 @@ class SlotService:
|
||||
f"slot {slot_id} current state {slot.status.value} "
|
||||
f"!= requested from_state {from_state.value}"
|
||||
)
|
||||
if precondition is not None:
|
||||
rejection_reason = precondition(slot)
|
||||
if rejection_reason is not None:
|
||||
raise SlotAdmissionRejectedError(rejection_reason)
|
||||
|
||||
updated = self._apply_transition_fields(
|
||||
slot,
|
||||
|
||||
Reference in New Issue
Block a user