feat(slot,intent): slot strategy-state 저장소 (dcy-028) + sell retryable 플래그 (issue #27)

BAT-042 (dcy-028 / issue #26 C-1/C-2/C-2b/C-3, REQ-079~082 + REQ-073/074 CHG):
- slot_strategy_state 별도 테이블 + StrategyStateStore(in-memory+PG) + occupancy_seq·version CAS + opaque UTF-8 data + 2단 size cap (REQ-079)
- trex.slots.strategy_state.set write RPC — reject reason 집합, 멱등 (REQ-080)
- Slot.occupancy_seq 필드 + inactive→pending_buy bump + →INACTIVE close 삭제 훅 → 점유 전환 ABA 구조적 차단 (REQ-081)
- slots.req 응답 행에 strategy_state 맵(batched 조립)·occupancy_seq additive (REQ-073 AC-7/REQ-074 AC-6)
- 마이그레이션 014(occupancy_seq)/015(slot_strategy_state), baseline 문서 정합 (REQ-082)

BAT-043 (issue #27 / REQ-007 CHG):
- rejected SellIntent 응답에 additive retryable: bool (core 소유 reason→retryable 매핑, 미인식 기본 true, bad_request* false) — 손절매 재시도 안전 경로 decoupling

검증: 487 passed, 47 skipped (신규 41: store CAS 6+ABA, occupancy bump/close 4경로, set RPC 9, slots read 5, retryable 8, PG 게이트), ruff clean.
REQ-079·080·081·082·073·074·007 → Implemented. issue #27 close. PG 경로 실 DB 검증은 release 위임.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 19:27:57 +09:00
co-authored by Claude Opus 4.8
parent bfadd0d5c8
commit e0d4e71304
40 changed files with 1941 additions and 14 deletions
+6 -1
View File
@@ -41,6 +41,7 @@ from trex_core.adapters.messaging.topology import (
direct_routing_key,
intent_queue,
slots_queue,
strategy_state_set_queue,
)
from trex_core.application.messaging import (
EventHandler,
@@ -140,7 +141,11 @@ class RabbitMQTransport:
iq = intent_queue(instance_key)
sq = slots_queue(instance_key)
cq = control_queue(instance_key)
for q, channel_type in ((iq, "intent"), (sq, "slots"), (cq, "control")):
ssq = strategy_state_set_queue(instance_key) # dcy-028 / REQ-080
for q, channel_type in (
(iq, "intent"), (sq, "slots"), (cq, "control"),
(ssq, "strategy_state_set"),
):
ch.queue_declare(q, durable=True, arguments=dlx)
# Directed binding: trex.direct → {instance_key}.{channel_type}
ch.queue_bind(q, DIRECT_EXCHANGE,
+5
View File
@@ -14,6 +14,7 @@ from __future__ import annotations
# --- RPC queues (durable for intent; transient-ish for slots but kept
# durable for simplicity since the queue itself is shared) ---
SLOTS_REQ_QUEUE = "trex.slots.req" # REQ-073 / dcy-025: replaces snapshot.req
STRATEGY_STATE_SET_QUEUE = "trex.slots.strategy_state.set" # dcy-028 / REQ-080
INTENT_REQ_QUEUE = "trex.intent.req"
PAUSE_REQ_QUEUE = "trex.pause.req" # BAT-008 / REQ-032 (legacy alias of control)
CONTROL_REQ_QUEUE = "trex.control.req" # BAT-012 / REQ-043 operator control channel
@@ -44,6 +45,10 @@ def slots_queue(instance_key: str | None = DEFAULT_INSTANCE_KEY) -> str:
return _instance_queue(SLOTS_REQ_QUEUE, instance_key)
def strategy_state_set_queue(instance_key: str | None = DEFAULT_INSTANCE_KEY) -> str:
return _instance_queue(STRATEGY_STATE_SET_QUEUE, instance_key)
def control_queue(instance_key: str | None = DEFAULT_INSTANCE_KEY) -> str:
return _instance_queue(CONTROL_REQ_QUEUE, instance_key)
+121 -3
View File
@@ -24,6 +24,11 @@ from trex_core.application.ledger_outbox import (
)
from trex_core.application.ledger_port import LedgerTrade
from trex_core.application.ports import IntentRecord
from trex_core.application.strategy_state import (
SetResult,
StrategyStateEntry,
_cas,
)
from trex_core.domain.slot import Slot, SlotNotFoundError, SlotState, SlotTransitionRecord
_SLOT_COLUMNS = (
@@ -31,7 +36,7 @@ _SLOT_COLUMNS = (
"order_id, strategy_id, broker_order_id, portfolio_id, "
"sell_strategy_id, forced_close_at, stop_loss_price, take_profit_price, "
"created_at, updated_at, instance_key, retired_at, "
"timeout_sec, slippage_pct"
"timeout_sec, slippage_pct, occupancy_seq"
)
@@ -197,7 +202,7 @@ class PostgreSQLSlotRepository:
"broker_order_id=%s, portfolio_id=%s, sell_strategy_id=%s, "
"forced_close_at=%s, stop_loss_price=%s, take_profit_price=%s, "
"instance_key=%s, retired_at=%s, timeout_sec=%s, slippage_pct=%s, "
"updated_at=%s WHERE slot_id=%s",
"occupancy_seq=%s, updated_at=%s WHERE slot_id=%s",
(
slot.status.value, slot.symbol, slot.qty, slot.avg_price,
slot.limit_price, slot.order_id,
@@ -205,7 +210,7 @@ class PostgreSQLSlotRepository:
slot.sell_strategy_id, slot.forced_close_at,
slot.stop_loss_price, slot.take_profit_price,
slot.instance_key, slot.retired_at,
slot.timeout_sec, slot.slippage_pct,
slot.timeout_sec, slot.slippage_pct, slot.occupancy_seq,
slot.updated_at or datetime.now(tz=timezone.utc), slot.slot_id,
),
)
@@ -299,6 +304,7 @@ class PostgreSQLSlotRepository:
# forced_close_at(11) stop_loss_price(12) take_profit_price(13)
# created_at(14) updated_at(15) instance_key(16) retired_at(17)
# timeout_sec(18) slippage_pct(19) [dcy-022/REQ-068]
# occupancy_seq(20) [dcy-028/REQ-081]
return Slot(
slot_id=row[0] if isinstance(row[0], UUID) else UUID(str(row[0])),
status=SlotState(row[1]),
@@ -320,6 +326,7 @@ class PostgreSQLSlotRepository:
retired_at=row[17],
timeout_sec=int(row[18]) if row[18] is not None else None,
slippage_pct=f(row[19]),
occupancy_seq=int(row[20]) if len(row) > 20 and row[20] is not None else 0,
)
@@ -515,3 +522,114 @@ class PostgreSQLLedgerOutbox:
updated_at=row[15],
next_retry_at=row[16],
)
class PgStrategyStateStore:
"""slot_strategy_state-backed StrategyStateStore (dcy-028 / REQ-079).
Stores opaque per-strategy state keyed by (slot_id, strategy_id). The CAS
decision is the same `_cas` used by the in-memory double; occupancy_seq and
slot_active are supplied by the caller (fetched from the slot) so the store
only owns the slot_strategy_state table.
"""
def __init__(self, dsn: str, *, psycopg_module: Any = None) -> None:
self._psycopg = _require_psycopg(psycopg_module)
self._dsn = dsn
def set(
self,
slot_id: UUID,
strategy_id: str,
*,
expected_occupancy_seq: int,
expected_version: int,
data: str,
current_occupancy_seq: int,
slot_active: bool,
) -> SetResult:
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
try:
with conn.cursor() as cur:
cur.execute(
"SELECT occupancy_seq, version, data, updated_at "
"FROM slot_strategy_state "
"WHERE slot_id = %s AND strategy_id = %s FOR UPDATE",
(slot_id, strategy_id),
)
row = cur.fetchone()
existing = (
StrategyStateEntry(
strategy_id=strategy_id,
occupancy_seq=int(row[0]),
version=int(row[1]),
data=row[2],
updated_at=row[3],
)
if row is not None
else None
)
result = _cas(
existing,
expected_occupancy_seq=expected_occupancy_seq,
expected_version=expected_version,
data=data,
current_occupancy_seq=current_occupancy_seq,
slot_active=slot_active,
)
if not result.ok:
conn.rollback()
return result
cur.execute(
"INSERT INTO slot_strategy_state "
"(slot_id, strategy_id, occupancy_seq, version, data, updated_at) "
"VALUES (%s, %s, %s, %s, %s, %s) "
"ON CONFLICT (slot_id, strategy_id) DO UPDATE SET "
"occupancy_seq = EXCLUDED.occupancy_seq, "
"version = EXCLUDED.version, data = EXCLUDED.data, "
"updated_at = EXCLUDED.updated_at",
(
slot_id, strategy_id, current_occupancy_seq,
result.version, data, datetime.now(tz=timezone.utc),
),
)
conn.commit()
return result
except BaseException:
conn.rollback()
raise
def get_for_slots(
self, slot_ids: list[UUID], current_occupancy_seq: dict[UUID, int]
) -> dict[UUID, dict[str, StrategyStateEntry]]:
if not slot_ids:
return {}
out: dict[UUID, dict[str, StrategyStateEntry]] = {}
with self._psycopg.connect(self._dsn) as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT slot_id, strategy_id, occupancy_seq, version, data, updated_at "
"FROM slot_strategy_state WHERE slot_id = ANY(%s)",
(list(slot_ids),),
)
for row in cur.fetchall():
sid = row[0] if isinstance(row[0], UUID) else UUID(str(row[0]))
# stale-occupancy entries are invisible (dcy-028 FR-3 defense)
if int(row[2]) != current_occupancy_seq.get(sid):
continue
out.setdefault(sid, {})[row[1]] = StrategyStateEntry(
strategy_id=row[1],
occupancy_seq=int(row[2]),
version=int(row[3]),
data=row[4],
updated_at=row[5],
)
return out
def delete_all(self, slot_id: UUID) -> None:
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM slot_strategy_state WHERE slot_id = %s", (slot_id,)
)
conn.commit()
@@ -0,0 +1,9 @@
-- BAT-042 / dcy-028 / REQ-081: per-occupancy guard counter on the slot.
--
-- The permanent asset slot is reused across occupancies; both a closed and a
-- freshly re-entered occupancy of the same slot start strategy_state at version
-- 0, so a version-only CAS cannot distinguish them at the first write. A
-- monotonic occupancy_seq (bumped on inactive→pending_buy) lets the
-- strategy-state write CAS reject a write computed against a prior occupancy
-- (occupancy_changed). Additive, idempotent. BIGINT so it never wraps.
ALTER TABLE daily_slot ADD COLUMN IF NOT EXISTS occupancy_seq BIGINT NOT NULL DEFAULT 0;
@@ -0,0 +1,18 @@
-- BAT-042 / dcy-028 / REQ-079: opaque per-strategy state store on slots.
--
-- A strategy daemon accumulates opaque state across a slot's occupancy (e.g. a
-- TSL peak). trex-core stores it server-side keyed by (slot_id, strategy_id),
-- never interpreting `data` (opaque UTF-8, byte-capped at the app layer). The
-- state's lifecycle matches the occupancy: trex-core deletes it on close
-- (→INACTIVE) so a reused slot starts empty (dcy-028 C-3). occupancy_seq mirrors
-- the slot's value at write time for the CAS occupancy guard (dcy-028 R-1).
-- version is the compare-and-set counter (idempotent / retry-safe writes).
CREATE TABLE IF NOT EXISTS slot_strategy_state (
slot_id UUID NOT NULL,
strategy_id TEXT NOT NULL,
occupancy_seq BIGINT NOT NULL,
version BIGINT NOT NULL,
data TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT (now() AT TIME ZONE 'UTC'),
PRIMARY KEY (slot_id, strategy_id)
);
+128 -4
View File
@@ -29,6 +29,33 @@ 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 -----------------------------------------------------
def make_intent_rpc_handler(
@@ -112,16 +139,24 @@ def make_intent_rpc_handler(
"slot_id": None, "reason": f"unknown_intent_type:{intent_type}",
})
except (KeyError, ValueError) as exc:
return RpcResponse(payload={
payload = {
"intent_id": intent_id, "status": "rejected",
"slot_id": None, "reason": f"bad_request:{exc}",
})
return RpcResponse(payload={
}
# 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,
"status": d.status,
"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
@@ -189,6 +224,7 @@ def make_slots_rpc_handler(
watchdog_timeout_sec: int = 120,
ledger_outbox=None,
max_active_slots: Optional[int] = None,
strategy_state_store=None,
):
"""Build the RPC handler for `trex.slots.req` (REQ-073/074).
@@ -236,6 +272,25 @@ def make_slots_rpc_handler(
for s in slots
]
# dcy-028 / REQ-073/074·079 (C-1): attach opaque strategy_state map per
# row via one batched store read (read piggyback — no extra round trip).
# Only entries matching the slot's current occupancy_seq are surfaced.
if strategy_state_store is not None and slots:
slot_ids = [s.slot_id for s in slots]
occ = {s.slot_id: getattr(s, "occupancy_seq", 0) for s in slots}
state_map = strategy_state_store.get_for_slots(slot_ids, occ)
for row, s in zip(slot_rows, slots):
entries = state_map.get(s.slot_id)
if entries:
row["strategy_state"] = {
strat: {
"version": e.version,
"data": e.data,
"updated_at": e.updated_at.isoformat(),
}
for strat, e in entries.items()
}
# dcy-018 / REQ-029: cash-estimated overlay (best-effort)
if ledger_outbox is not None:
try:
@@ -277,6 +332,75 @@ def make_slots_rpc_handler(
return handler
# ---- strategy-state write RPC handler (dcy-028 / REQ-080) -------------------
def make_strategy_state_set_handler(
slot_repo: SlotRepository,
strategy_state_store,
):
"""Build the RPC handler for `trex.slots.strategy_state.set` (REQ-080).
Request: {slot_id, strategy_id, expected_version:int,
expected_occupancy_seq:int, data:str}
Response (ok): {ok:true, slot_id, strategy_id, version:int}
Response (fail): {ok:false, slot_id, strategy_id, reason, version?|occupancy_seq?}
reason ∈ {occupancy_changed, version_conflict, slot_inactive,
state_too_large, bad_request:<detail>}. The store owns the CAS
(REQ-079); the handler supplies the slot's current occupancy_seq and
active-ness and never interprets `data` (opaque).
"""
_ACTIVE_STATES = {"pending_buy", "holding", "pending_sell"}
def handler(req: RpcRequest) -> RpcResponse:
body = req.payload or {}
try:
slot_id = UUID(str(body["slot_id"]))
strategy_id = str(body["strategy_id"])
expected_version = int(body["expected_version"])
expected_occupancy_seq = int(body["expected_occupancy_seq"])
data = body["data"]
if not isinstance(data, str):
raise ValueError("data must be a string")
except (KeyError, ValueError, TypeError) as exc:
return RpcResponse(payload={
"ok": False,
"slot_id": body.get("slot_id"),
"strategy_id": body.get("strategy_id"),
"reason": f"bad_request:{exc}",
})
slot = slot_repo.fetch_slot(slot_id)
slot_active = slot is not None and slot.status.value in _ACTIVE_STATES
current_occupancy_seq = getattr(slot, "occupancy_seq", 0) if slot else 0
result = strategy_state_store.set(
slot_id,
strategy_id,
expected_occupancy_seq=expected_occupancy_seq,
expected_version=expected_version,
data=data,
current_occupancy_seq=current_occupancy_seq,
slot_active=slot_active,
)
payload = {
"ok": result.ok,
"slot_id": str(slot_id),
"strategy_id": strategy_id,
}
if result.ok:
payload["version"] = result.version
else:
payload["reason"] = result.reason
if result.version is not None:
payload["version"] = result.version
if result.occupancy_seq is not None:
payload["occupancy_seq"] = result.occupancy_seq
return RpcResponse(payload=payload)
return handler
# ---- pause/resume RPC handler (REQ-032 AC-4) --------------------------------
def make_pause_rpc_handler(pause_store: PauseStore):
+3
View File
@@ -272,6 +272,9 @@ def slot_to_dict(
"portfolio_id": s.portfolio_id,
"forced_close_at": s.forced_close_at.isoformat() if s.forced_close_at else None,
"universe_detached": getattr(s, "retired_at", None) is not None,
# dcy-028/REQ-081 (BAT-042): per-occupancy guard counter — the strategy
# daemon echoes this as expected_occupancy_seq on strategy_state writes.
"occupancy_seq": getattr(s, "occupancy_seq", 0),
}
# hub authority: asset_quantity (hub primary) + cash_balance
+183
View File
@@ -0,0 +1,183 @@
"""Slot strategy-state store (dcy-028 / REQ-079, BAT-042).
A strategy daemon (e.g. trex-tsl) accumulates opaque state across a slot's
occupancy — for a Trailing Stop Loss the peak price since entry. `evaluate()`
must stay pure, so the state cannot live in the daemon (lost on restart) nor in
an external store (orphan-cleanup problem). Since the state's lifecycle matches
the slot occupancy exactly, trex-core stores it server-side keyed by
`(slot_id, strategy_id)`, round-trips it on the slots snapshot (read), accepts
version-CAS writes via a dedicated RPC, and deletes it when the slot closes.
trex-core never interprets `data` (opaque UTF-8 string). It validates only
(1) valid UTF-8 and (2) a byte size cap — encoding (JSON/base64) is the
strategy's business (dcy-028 OQ-1).
Occupancy guard (dcy-028 R-1 / REQ-081): a permanent slot is reused, so two
occupancies of the same slot both start at version 0. A write computed against
occupancy X must not land on a later occupancy Y. The slot carries a monotonic
`occupancy_seq`; the CAS rejects a write whose `expected_occupancy_seq` differs
from the slot's current value (`occupancy_changed`). The version CAS alone
cannot catch this at the first write (0 vs 0).
Single-writer assumption: one daemon operates a given slot·strategy. The CAS is
for retry / duplicate / occupancy-turnover safety, not multi-writer concurrency.
"""
from __future__ import annotations
import os
import threading
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Optional, Protocol
from uuid import UUID
# dcy-028 OQ-1: hard ceiling for the opaque `data` blob, measured in UTF-8
# bytes. Operators target 1~2KB for the known TSL use case; 4KB is the cap.
_STATE_CAP_BYTES = int(os.environ.get("TREX_STRATEGY_STATE_CAP_BYTES", "4096"))
# Reject reason tokens (dcy-028 / REQ-080). The full enumerated set; consumers
# fail-closed on anything unrecognized.
REASON_OCCUPANCY_CHANGED = "occupancy_changed"
REASON_VERSION_CONFLICT = "version_conflict"
REASON_SLOT_INACTIVE = "slot_inactive"
REASON_STATE_TOO_LARGE = "state_too_large"
@dataclass(frozen=True)
class StrategyStateEntry:
"""One stored opaque state value for a (slot_id, strategy_id)."""
strategy_id: str
occupancy_seq: int
version: int
data: str
updated_at: datetime
@dataclass(frozen=True)
class SetResult:
"""Outcome of a CAS set. ok=True carries the new version; else `reason`."""
ok: bool
version: Optional[int] = None
reason: Optional[str] = None
occupancy_seq: Optional[int] = None # current slot occupancy_seq on conflict
def _byte_len(data: str) -> int:
return len(data.encode("utf-8"))
class StrategyStateStore(Protocol):
def set(
self,
slot_id: UUID,
strategy_id: str,
*,
expected_occupancy_seq: int,
expected_version: int,
data: str,
current_occupancy_seq: int,
slot_active: bool,
) -> SetResult:
"""occupancy_seq + version CAS upsert of opaque `data` (dcy-028 FR-2)."""
...
def get_for_slots(
self, slot_ids: list[UUID], current_occupancy_seq: dict[UUID, int]
) -> dict[UUID, dict[str, StrategyStateEntry]]:
"""Batched read for the snapshot (C-1). Returns slot_id -> {strategy_id: entry}.
Only entries whose stored occupancy_seq matches the slot's current
occupancy_seq are returned (stale entries from a prior occupancy are
invisible — defense in depth alongside close-delete, dcy-028 FR-3)."""
...
def delete_all(self, slot_id: UUID) -> None:
"""Delete all strategy_state for a slot (close hook, dcy-028 C-3)."""
...
def _cas(
existing: Optional[StrategyStateEntry],
*,
expected_occupancy_seq: int,
expected_version: int,
data: str,
current_occupancy_seq: int,
slot_active: bool,
) -> SetResult:
"""Pure CAS decision shared by all store implementations (dcy-028 FR-2)."""
if not slot_active:
return SetResult(ok=False, reason=REASON_SLOT_INACTIVE)
if expected_occupancy_seq != current_occupancy_seq:
return SetResult(
ok=False, reason=REASON_OCCUPANCY_CHANGED, occupancy_seq=current_occupancy_seq
)
if _byte_len(data) > _STATE_CAP_BYTES:
return SetResult(ok=False, reason=REASON_STATE_TOO_LARGE)
stored_version = existing.version if existing is not None else 0
if expected_version != stored_version:
return SetResult(ok=False, reason=REASON_VERSION_CONFLICT, version=stored_version)
return SetResult(ok=True, version=stored_version + 1)
class InMemoryStrategyStateStore:
"""Process-local strategy-state store (paper / tests / explicit double)."""
def __init__(self) -> None:
# (slot_id, strategy_id) -> StrategyStateEntry
self._store: dict[tuple[UUID, str], StrategyStateEntry] = {}
self._lock = threading.Lock()
def set(
self,
slot_id: UUID,
strategy_id: str,
*,
expected_occupancy_seq: int,
expected_version: int,
data: str,
current_occupancy_seq: int,
slot_active: bool,
) -> SetResult:
with self._lock:
existing = self._store.get((slot_id, strategy_id))
result = _cas(
existing,
expected_occupancy_seq=expected_occupancy_seq,
expected_version=expected_version,
data=data,
current_occupancy_seq=current_occupancy_seq,
slot_active=slot_active,
)
if not result.ok:
return result
self._store[(slot_id, strategy_id)] = StrategyStateEntry(
strategy_id=strategy_id,
occupancy_seq=current_occupancy_seq,
version=result.version, # type: ignore[arg-type]
data=data,
updated_at=datetime.now(tz=timezone.utc),
)
return result
def get_for_slots(
self, slot_ids: list[UUID], current_occupancy_seq: dict[UUID, int]
) -> dict[UUID, dict[str, StrategyStateEntry]]:
wanted = set(slot_ids)
out: dict[UUID, dict[str, StrategyStateEntry]] = {}
with self._lock:
for (sid, strat), entry in self._store.items():
if sid not in wanted:
continue
# stale-occupancy entries are invisible (dcy-028 FR-3 defense)
if entry.occupancy_seq != current_occupancy_seq.get(sid):
continue
out.setdefault(sid, {})[strat] = entry
return out
def delete_all(self, slot_id: UUID) -> None:
with self._lock:
for key in [k for k in self._store if k[0] == slot_id]:
del self._store[key]
+6
View File
@@ -51,6 +51,11 @@ class Slot:
# slippage_pct: effective limit = entry_price * (1 ± pct/100); None = no band.
timeout_sec: Optional[int] = None
slippage_pct: Optional[float] = None
# dcy-028 / REQ-081 (BAT-042): monotonic occupancy counter. Incremented when
# the slot begins a new occupancy (inactive→pending_buy), stable across one
# holding episode. Used by the strategy-state CAS to reject writes computed
# against a prior occupancy of a reused slot (point-ABA guard, dcy-028 R-1).
occupancy_seq: int = 0
# Canonical column contract for `Slot` (dcy-001 OQ-2 / FR-5).
@@ -78,6 +83,7 @@ EXPECTED_SLOT_FIELDS: frozenset[str] = frozenset({
"retired_at", # dcy-020 / REQ-056 (BAT-027)
"timeout_sec", # dcy-022 / REQ-068 (BAT-032)
"slippage_pct", # dcy-022 / REQ-068 (BAT-032)
"occupancy_seq", # dcy-028 / REQ-081 (BAT-042)
})
+24 -1
View File
@@ -39,12 +39,21 @@ _UNSET: Any = object()
class SlotService:
"""Slot lifecycle service. Race-free transitions via advisory lock + transaction."""
def __init__(self, repository: SlotRepository, instance_key: str = "default") -> None:
def __init__(
self,
repository: SlotRepository,
instance_key: str = "default",
*,
strategy_state_store: Any = None,
) -> None:
self._repo = repository
# dcy-020/REQ-057: advisory lock is scoped per instance so a shared DB
# does not serialize transitions across instances (NFR-1).
self._instance_key = instance_key
self._lock_id = slot_advisory_lock_id(instance_key)
# dcy-028/REQ-081 (C-3): when injected, all strategy_state for a slot is
# deleted on close (→INACTIVE). None keeps legacy behaviour (no store).
self._strategy_state_store = strategy_state_store
@property
def instance_key(self) -> str:
@@ -138,6 +147,11 @@ class SlotService:
extra=extra or {},
)
)
# dcy-028/REQ-081 (C-3): close deletes the slot's strategy_state so a
# reused slot starts empty. Covers every close path (strategy sell,
# safety-sell, forced-close, operator) since all funnel to →INACTIVE.
if to_state is SlotState.INACTIVE and self._strategy_state_store is not None:
self._strategy_state_store.delete_all(slot_id)
return updated
@staticmethod
@@ -202,6 +216,14 @@ class SlotService:
new_timeout_sec = None
new_slippage_pct = None
# occupancy_seq (dcy-028/REQ-081): bump when a slot begins a new
# occupancy (inactive→pending_buy). Stable across the rest of the
# holding episode; the INACTIVE branch above preserves it (monotonic).
if slot.status is SlotState.INACTIVE and to_state is SlotState.PENDING_BUY:
new_occupancy_seq = slot.occupancy_seq + 1
else:
new_occupancy_seq = slot.occupancy_seq
# qty semantics during a pending state = the ORDER quantity (dcy-021 D1/D4).
# Entering PENDING_SELL takes the caller qty AS-IS so a full sell
# (qty=None) records a "sell all" sentinel rather than the stale held
@@ -244,6 +266,7 @@ class SlotService:
),
timeout_sec=new_timeout_sec,
slippage_pct=new_slippage_pct,
occupancy_seq=new_occupancy_seq,
updated_at=datetime.now(tz=timezone.utc),
)
+26 -1
View File
@@ -33,6 +33,7 @@ from trex_core.adapters.messaging import (
)
from trex_core.adapters.persistence.migrate import apply_migrations
from trex_core.adapters.persistence.postgresql import (
PgStrategyStateStore,
PostgreSQLIntentStore,
PostgreSQLLedgerOutbox,
PostgreSQLSlotRepository,
@@ -54,6 +55,7 @@ from trex_core.application.messaging_handlers import (
make_control_rpc_handler,
make_intent_rpc_handler,
make_slots_rpc_handler,
make_strategy_state_set_handler,
slot_transition_event_payload,
)
from trex_core.application.portfolio import (
@@ -147,7 +149,14 @@ class Daemon:
# Persistence + services
self.slot_repo = PostgreSQLSlotRepository(config.dsn)
self.intent_store = PostgreSQLIntentStore(config.dsn)
self.slot_service = SlotService(self.slot_repo, instance_key=config.instance_key)
# dcy-028 / REQ-079·081 (BAT-042): opaque strategy-state store. Injected
# into the slot service so close (→INACTIVE) deletes the slot's state.
self.strategy_state_store = PgStrategyStateStore(config.dsn)
self.slot_service = SlotService(
self.slot_repo,
instance_key=config.instance_key,
strategy_state_store=self.strategy_state_store,
)
self.intent_service = IntentService(
self.slot_service, self.slot_repo, self.intent_store,
resolve_symbol=self._resolve_symbol,
@@ -284,10 +293,25 @@ class Daemon:
watchdog_timeout_sec=self.config.safety_sell.watchdog_timeout_sec,
ledger_outbox=self.outbox,
max_active_slots=self.config.max_active_slots,
strategy_state_store=self.strategy_state_store, # dcy-028 C-1
)
transport.consume_rpc_forever(
topology.slots_queue(self.config.instance_key), handler, self.stop)
def _strategy_state_set_loop(self) -> None:
# dcy-028 / REQ-080: opaque strategy-state write RPC (occupancy_seq+version CAS).
transport = _build_transport(self.config)
self._transports.append(transport)
try:
transport.declare_topology(self.config.instance_key)
except Exception: # noqa: BLE001
pass
handler = make_strategy_state_set_handler(
self.slot_repo, self.strategy_state_store,
)
transport.consume_rpc_forever(
topology.strategy_state_set_queue(self.config.instance_key), handler, self.stop)
def _control_rpc_loop(self) -> None:
# REQ-043: operator control channel (pause/resume/list + kill_switch/resume_kill_switch).
transport = _build_transport(self.config)
@@ -338,6 +362,7 @@ class Daemon:
("trex-ledger", self._ledger_loop),
("trex-intent-rpc", self._intent_rpc_loop),
("trex-slots-rpc", self._slots_rpc_loop),
("trex-strategy-state-set", self._strategy_state_set_loop),
("trex-control-rpc", self._control_rpc_loop),
("trex-snapshot-scheduler", self._snapshot_scheduler_loop),
]
+1
View File
@@ -38,6 +38,7 @@ SLOT_CORE_TABLES: tuple[str, ...] = (
"ledger_outbox",
"snapshot_pause",
"killswitch_exclusion",
"slot_strategy_state", # dcy-028 / REQ-079 (BAT-042)
)