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)
);