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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user