fix: close broker uncertainty audit gaps

This commit is contained in:
jordan
2026-07-29 17:53:39 +09:00
parent de1e3eb44c
commit 611805dcec
19 changed files with 236 additions and 42 deletions
+14 -13
View File
@@ -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):
+12 -2
View File
@@ -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,
+5 -1
View File
@@ -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")
+22 -12
View File
@@ -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
+4 -1
View File
@@ -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",
+8
View File
@@ -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)
+7 -1
View File
@@ -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,