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:
@@ -130,3 +130,47 @@ def test_dispatch_queue_instance_scoped(pg_clean):
|
||||
repo_a.mark_dispatched(slot_a.slot_id, "ord-1")
|
||||
assert [s.slot_id for s in repo_a.list_dispatched_pending("inst-a")] == [slot_a.slot_id]
|
||||
assert repo_a.list_dispatched_pending("inst-b") == []
|
||||
|
||||
|
||||
# dcy-028 / REQ-079·081 (BAT-042): strategy-state store + occupancy_seq column
|
||||
def test_strategy_state_store_pg(pg_clean):
|
||||
from trex_core.adapters.persistence.postgresql import PgStrategyStateStore
|
||||
from trex_core.application import strategy_state as ss
|
||||
|
||||
repo = PostgreSQLSlotRepository(pg_clean)
|
||||
store = PgStrategyStateStore(pg_clean)
|
||||
svc = SlotService(repo, instance_key=INST, strategy_state_store=store)
|
||||
repo.reconcile_universe(INST, {"pf-a": "005930"})
|
||||
slot = repo.find_slot_by_portfolio(INST, "pf-a")
|
||||
svc.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="t")
|
||||
svc.transit_slot(slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="filled", actor="t", qty=10, avg_price=100)
|
||||
# occupancy_seq persisted
|
||||
held = repo.fetch_slot(slot.slot_id)
|
||||
assert held.occupancy_seq == 1
|
||||
|
||||
# first-create
|
||||
r1 = store.set(slot.slot_id, "tsl_v1", expected_version=0,
|
||||
expected_occupancy_seq=1, data="peak", current_occupancy_seq=1,
|
||||
slot_active=True)
|
||||
assert r1.ok and r1.version == 1
|
||||
# version conflict
|
||||
r2 = store.set(slot.slot_id, "tsl_v1", expected_version=0,
|
||||
expected_occupancy_seq=1, data="stale", current_occupancy_seq=1,
|
||||
slot_active=True)
|
||||
assert not r2.ok and r2.reason == ss.REASON_VERSION_CONFLICT
|
||||
# batched read
|
||||
got = store.get_for_slots([slot.slot_id], {slot.slot_id: 1})
|
||||
assert got[slot.slot_id]["tsl_v1"].data == "peak"
|
||||
|
||||
# close deletes state (C-3)
|
||||
svc.transit_slot(slot.slot_id, SlotState.HOLDING, SlotState.PENDING_SELL,
|
||||
reason="sell", actor="t")
|
||||
svc.transit_slot(slot.slot_id, SlotState.PENDING_SELL, SlotState.INACTIVE,
|
||||
reason="sold", actor="t")
|
||||
assert store.get_for_slots([slot.slot_id], {slot.slot_id: 1}) == {}
|
||||
# reuse bumps occupancy_seq
|
||||
reused = svc.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="t")
|
||||
assert reused.occupancy_seq == 2
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""rejected SellIntent `retryable` flag (issue #27 / REQ-007 CHG-20260615-01).
|
||||
|
||||
Additive: only on intent_type="sell" + status="rejected". Maps reason→retryable
|
||||
via the core-owned table (unrecognized → True, safety default; bad_request → False).
|
||||
admitted / processing / buy responses must NOT carry the field.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
|
||||
from trex_core.application.intents import IntentService
|
||||
from trex_core.application.messaging import RpcRequest
|
||||
from trex_core.application.messaging_handlers import (
|
||||
_sell_reject_retryable,
|
||||
make_intent_rpc_handler,
|
||||
)
|
||||
from trex_core.domain.slot import SlotService, SlotState
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
INST = "default"
|
||||
|
||||
|
||||
def _wire():
|
||||
repo = InMemorySlotRepository()
|
||||
svc = SlotService(repo, instance_key=INST)
|
||||
intents = IntentService(svc, repo, InMemoryIntentStore(),
|
||||
resolve_symbol=lambda pid: "005930",
|
||||
instance_key=INST, max_active_slots=5)
|
||||
repo.reconcile_universe(INST, {"PF-1": "SYM"})
|
||||
handler = make_intent_rpc_handler(intents)
|
||||
slot = repo.list_slots(INST)[0]
|
||||
return repo, svc, intents, handler, slot
|
||||
|
||||
|
||||
def _sell(handler, slot_id, intent_id="s1"):
|
||||
return handler(RpcRequest(payload={
|
||||
"intent_type": "sell", "intent_id": intent_id, "slot_id": str(slot_id),
|
||||
}, correlation_id=intent_id))
|
||||
|
||||
|
||||
class TestRetryableMapping:
|
||||
def test_each_reason_maps(self):
|
||||
assert _sell_reject_retryable("slot_state_changed") is True
|
||||
assert _sell_reject_retryable("slot_not_holding:pending_buy") is True
|
||||
assert _sell_reject_retryable("duplicate_in_flight") is True
|
||||
assert _sell_reject_retryable("slot_not_found") is False
|
||||
assert _sell_reject_retryable("slot_not_holding:inactive") is False
|
||||
assert _sell_reject_retryable("slot_not_holding:pending_sell") is False
|
||||
assert _sell_reject_retryable("slot_not_holding:error") is False
|
||||
|
||||
def test_unrecognized_defaults_true(self):
|
||||
assert _sell_reject_retryable("some_future_reason") is True
|
||||
assert _sell_reject_retryable(None) is True
|
||||
|
||||
def test_bad_request_is_false(self):
|
||||
assert _sell_reject_retryable("bad_request:KeyError('slot_id')") is False
|
||||
|
||||
|
||||
class TestResponseField:
|
||||
def test_rejected_sell_inactive_slot_has_retryable_false(self):
|
||||
# slot is inactive (never bought) → slot_not_holding:inactive → terminal
|
||||
repo, svc, intents, handler, slot = _wire()
|
||||
resp = _sell(handler, slot.slot_id)
|
||||
assert resp.payload["status"] == "rejected"
|
||||
assert resp.payload["reason"] == "slot_not_holding:inactive"
|
||||
assert resp.payload["retryable"] is False
|
||||
|
||||
def test_rejected_sell_unknown_slot_false(self):
|
||||
from uuid import uuid4
|
||||
_, _, _, handler, _ = _wire()
|
||||
resp = _sell(handler, uuid4())
|
||||
assert resp.payload["status"] == "rejected"
|
||||
assert resp.payload["reason"] == "slot_not_found"
|
||||
assert resp.payload["retryable"] is False
|
||||
|
||||
def test_bad_request_sell_carries_retryable_false(self):
|
||||
_, _, _, handler, _ = _wire()
|
||||
resp = handler(RpcRequest(payload={
|
||||
"intent_type": "sell", "intent_id": "x", # missing slot_id
|
||||
}, correlation_id="x"))
|
||||
assert resp.payload["status"] == "rejected"
|
||||
assert resp.payload["reason"].startswith("bad_request")
|
||||
assert resp.payload["retryable"] is False
|
||||
|
||||
def test_admitted_sell_has_no_retryable(self):
|
||||
repo, svc, intents, handler, slot = _wire()
|
||||
# bring slot to holding so the sell is admitted
|
||||
svc.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="t")
|
||||
svc.transit_slot(slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="filled", actor="t", qty=10, avg_price=100)
|
||||
resp = _sell(handler, slot.slot_id)
|
||||
assert resp.payload["status"] == "admitted"
|
||||
assert "retryable" not in resp.payload
|
||||
|
||||
def test_buy_rejection_has_no_retryable(self):
|
||||
# buy on a non-existent slot → rejected, but buy responses omit retryable
|
||||
from uuid import uuid4
|
||||
_, _, _, handler, _ = _wire()
|
||||
resp = handler(RpcRequest(payload={
|
||||
"intent_type": "buy", "intent_id": "b1", "slot_id": str(uuid4()),
|
||||
}, correlation_id="b1"))
|
||||
assert resp.payload["status"] == "rejected"
|
||||
assert "retryable" not in resp.payload
|
||||
@@ -0,0 +1,83 @@
|
||||
"""slots.req read piggyback of strategy_state + occupancy_seq (dcy-028 C-1).
|
||||
|
||||
REQ-073 AC-7 / REQ-074 AC-6 / S-1: each slot row carries occupancy_seq and,
|
||||
when present, a strategy_state map assembled from the store in one batched read.
|
||||
strategy_* reasoning fields remain absent.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from trex_core.application.messaging import RpcRequest
|
||||
from trex_core.application.messaging_handlers import make_slots_rpc_handler
|
||||
from trex_core.application.strategy_state import InMemoryStrategyStateStore
|
||||
from trex_core.domain.slot import SlotService, SlotState
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
INST = "default"
|
||||
|
||||
|
||||
def _wire():
|
||||
repo = InMemorySlotRepository()
|
||||
store = InMemoryStrategyStateStore()
|
||||
service = SlotService(repo, instance_key=INST, strategy_state_store=store)
|
||||
repo.reconcile_universe(INST, {"pf-0": "SYM"})
|
||||
slot = repo.list_slots(INST)[0]
|
||||
service.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="t")
|
||||
service.transit_slot(slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="filled", actor="t", qty=10, avg_price=100)
|
||||
handler = make_slots_rpc_handler(repo, instance_key=INST,
|
||||
strategy_state_store=store)
|
||||
return repo, store, service, slot, handler
|
||||
|
||||
|
||||
def _rows(handler):
|
||||
return handler(RpcRequest(payload={}, correlation_id="c")).payload["slots"]
|
||||
|
||||
|
||||
def test_row_carries_occupancy_seq():
|
||||
_, _, _, slot, handler = _wire()
|
||||
row = _rows(handler)[0]
|
||||
assert row["occupancy_seq"] == 1
|
||||
|
||||
|
||||
def test_row_carries_strategy_state_when_present():
|
||||
_, store, _, slot, handler = _wire()
|
||||
store.set(slot.slot_id, "tsl_v1", expected_version=0, expected_occupancy_seq=1,
|
||||
data='{"peak":100}', current_occupancy_seq=1, slot_active=True)
|
||||
row = _rows(handler)[0]
|
||||
assert "strategy_state" in row
|
||||
assert row["strategy_state"]["tsl_v1"]["version"] == 1
|
||||
assert row["strategy_state"]["tsl_v1"]["data"] == '{"peak":100}'
|
||||
assert "updated_at" in row["strategy_state"]["tsl_v1"]
|
||||
|
||||
|
||||
def test_row_omits_strategy_state_when_absent():
|
||||
_, _, _, _, handler = _wire()
|
||||
row = _rows(handler)[0]
|
||||
assert "strategy_state" not in row
|
||||
|
||||
|
||||
def test_no_strategy_reasoning_fields():
|
||||
_, store, _, slot, handler = _wire()
|
||||
store.set(slot.slot_id, "tsl_v1", expected_version=0, expected_occupancy_seq=1,
|
||||
data="x", current_occupancy_seq=1, slot_active=True)
|
||||
row = _rows(handler)[0]
|
||||
for k in ("strategy_id", "sell_strategy_id", "strategy_params", "active_strategy_id"):
|
||||
assert k not in row
|
||||
|
||||
|
||||
def test_reused_slot_shows_no_state():
|
||||
"""After a full close + reuse cycle, no prior-occupancy state surfaces
|
||||
(close-delete + read-side occupancy filter). occupancy_seq advances to 2."""
|
||||
repo, store, service, slot, handler = _wire()
|
||||
store.set(slot.slot_id, "tsl_v1", expected_version=0, expected_occupancy_seq=1,
|
||||
data="old", current_occupancy_seq=1, slot_active=True)
|
||||
service.transit_slot(slot.slot_id, SlotState.HOLDING, SlotState.PENDING_SELL,
|
||||
reason="sell", actor="t")
|
||||
service.transit_slot(slot.slot_id, SlotState.PENDING_SELL, SlotState.INACTIVE,
|
||||
reason="sold", actor="t")
|
||||
service.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="t") # occupancy_seq -> 2
|
||||
row = _rows(handler)[0]
|
||||
assert row["occupancy_seq"] == 2
|
||||
assert "strategy_state" not in row
|
||||
@@ -0,0 +1,120 @@
|
||||
"""strategy_state.set write RPC handler tests (dcy-028 / REQ-080, BAT-042).
|
||||
|
||||
Covers the full reject reason set + success + bad_request, with the handler
|
||||
supplying the slot's current occupancy_seq / active-ness to the store CAS.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from trex_core.application import strategy_state as ss
|
||||
from trex_core.application.messaging import RpcRequest
|
||||
from trex_core.application.messaging_handlers import make_strategy_state_set_handler
|
||||
from trex_core.application.strategy_state import InMemoryStrategyStateStore
|
||||
from trex_core.domain.slot import SlotService, SlotState
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
INST = "default"
|
||||
|
||||
|
||||
def _wire():
|
||||
repo = InMemorySlotRepository()
|
||||
store = InMemoryStrategyStateStore()
|
||||
service = SlotService(repo, instance_key=INST, strategy_state_store=store)
|
||||
repo.reconcile_universe(INST, {"pf-0": "SYM"})
|
||||
slot = repo.list_slots(INST)[0]
|
||||
# activate to holding (occupancy_seq -> 1)
|
||||
service.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="t")
|
||||
service.transit_slot(slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="filled", actor="t", qty=10, avg_price=100)
|
||||
handler = make_strategy_state_set_handler(repo, store)
|
||||
return repo, store, service, slot, handler
|
||||
|
||||
|
||||
def _req(slot_id, *, ev=0, eo=1, data="peak", strat="tsl_v1"):
|
||||
return RpcRequest(payload={
|
||||
"slot_id": str(slot_id), "strategy_id": strat,
|
||||
"expected_version": ev, "expected_occupancy_seq": eo, "data": data,
|
||||
}, correlation_id=f"{slot_id}:{strat}:{ev}")
|
||||
|
||||
|
||||
def test_first_write_ok():
|
||||
_, _, _, slot, handler = _wire()
|
||||
resp = handler(_req(slot.slot_id, ev=0))
|
||||
assert resp.payload["ok"] is True and resp.payload["version"] == 1
|
||||
|
||||
|
||||
def test_version_conflict():
|
||||
_, _, _, slot, handler = _wire()
|
||||
handler(_req(slot.slot_id, ev=0)) # version -> 1
|
||||
resp = handler(_req(slot.slot_id, ev=0, data="stale"))
|
||||
assert resp.payload["ok"] is False
|
||||
assert resp.payload["reason"] == ss.REASON_VERSION_CONFLICT
|
||||
assert resp.payload["version"] == 1
|
||||
|
||||
|
||||
def test_occupancy_changed():
|
||||
_, _, _, slot, handler = _wire()
|
||||
# daemon read occupancy 0 (before activation) but slot is occupancy 1 now
|
||||
resp = handler(_req(slot.slot_id, ev=0, eo=0))
|
||||
assert resp.payload["ok"] is False
|
||||
assert resp.payload["reason"] == ss.REASON_OCCUPANCY_CHANGED
|
||||
assert resp.payload["occupancy_seq"] == 1
|
||||
|
||||
|
||||
def test_slot_inactive():
|
||||
repo, store, service, slot, handler = _wire()
|
||||
# close the slot
|
||||
service.transit_slot(slot.slot_id, SlotState.HOLDING, SlotState.PENDING_SELL,
|
||||
reason="sell", actor="t")
|
||||
service.transit_slot(slot.slot_id, SlotState.PENDING_SELL, SlotState.INACTIVE,
|
||||
reason="sold", actor="t")
|
||||
resp = handler(_req(slot.slot_id, ev=0, eo=1))
|
||||
assert resp.payload["ok"] is False
|
||||
assert resp.payload["reason"] == ss.REASON_SLOT_INACTIVE
|
||||
|
||||
|
||||
def test_unknown_slot_is_slot_inactive():
|
||||
from uuid import uuid4
|
||||
_, _, _, _, handler = _wire()
|
||||
resp = handler(_req(uuid4(), ev=0, eo=1))
|
||||
assert resp.payload["ok"] is False
|
||||
assert resp.payload["reason"] == ss.REASON_SLOT_INACTIVE
|
||||
|
||||
|
||||
def test_state_too_large():
|
||||
_, _, _, slot, handler = _wire()
|
||||
big = "a" * (ss._STATE_CAP_BYTES + 1)
|
||||
resp = handler(_req(slot.slot_id, ev=0, data=big))
|
||||
assert resp.payload["ok"] is False
|
||||
assert resp.payload["reason"] == ss.REASON_STATE_TOO_LARGE
|
||||
|
||||
|
||||
def test_bad_request_missing_field():
|
||||
_, _, _, slot, handler = _wire()
|
||||
resp = handler(RpcRequest(payload={"slot_id": str(slot.slot_id)},
|
||||
correlation_id="c"))
|
||||
assert resp.payload["ok"] is False
|
||||
assert resp.payload["reason"].startswith("bad_request:")
|
||||
|
||||
|
||||
def test_bad_request_non_string_data():
|
||||
_, _, _, slot, handler = _wire()
|
||||
resp = handler(RpcRequest(payload={
|
||||
"slot_id": str(slot.slot_id), "strategy_id": "tsl_v1",
|
||||
"expected_version": 0, "expected_occupancy_seq": 1, "data": 123,
|
||||
}, correlation_id="c"))
|
||||
assert resp.payload["ok"] is False
|
||||
assert resp.payload["reason"].startswith("bad_request:")
|
||||
|
||||
|
||||
def test_reason_set_is_enumerated():
|
||||
"""All non-ok reasons must be within the documented set (REQ-080 AC-6)."""
|
||||
allowed_prefixes = {
|
||||
ss.REASON_OCCUPANCY_CHANGED, ss.REASON_VERSION_CONFLICT,
|
||||
ss.REASON_SLOT_INACTIVE, ss.REASON_STATE_TOO_LARGE, "bad_request",
|
||||
}
|
||||
_, _, _, slot, handler = _wire()
|
||||
handler(_req(slot.slot_id, ev=0))
|
||||
bad = handler(_req(slot.slot_id, ev=0, data="x")) # version_conflict
|
||||
reason = bad.payload["reason"].split(":")[0]
|
||||
assert reason in allowed_prefixes
|
||||
@@ -0,0 +1,118 @@
|
||||
"""InMemoryStrategyStateStore CAS tests (dcy-028 / REQ-079, BAT-042).
|
||||
|
||||
Covers the 6 CAS outcomes (occupancy+version commit / version_conflict /
|
||||
occupancy_changed / slot_inactive / state_too_large / first-create), idempotent
|
||||
re-send, batched read with stale-occupancy filtering, and delete-on-close.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from trex_core.application import strategy_state as ss
|
||||
from trex_core.application.strategy_state import InMemoryStrategyStateStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store():
|
||||
return InMemoryStrategyStateStore()
|
||||
|
||||
|
||||
def _set(store, slot_id, *, ev=0, eo=1, data="x", cur_occ=1, active=True, strat="tsl_v1"):
|
||||
return store.set(
|
||||
slot_id, strat,
|
||||
expected_version=ev, expected_occupancy_seq=eo, data=data,
|
||||
current_occupancy_seq=cur_occ, slot_active=active,
|
||||
)
|
||||
|
||||
|
||||
class TestCas:
|
||||
def test_first_create(self, store):
|
||||
sid = uuid4()
|
||||
r = _set(store, sid, ev=0, eo=1, cur_occ=1, data='{"peak":100}')
|
||||
assert r.ok and r.version == 1
|
||||
|
||||
def test_commit_increments_version(self, store):
|
||||
sid = uuid4()
|
||||
_set(store, sid, ev=0)
|
||||
r = _set(store, sid, ev=1, data="next")
|
||||
assert r.ok and r.version == 2
|
||||
|
||||
def test_version_conflict_does_not_store(self, store):
|
||||
sid = uuid4()
|
||||
_set(store, sid, ev=0, data="first") # version=1
|
||||
r = _set(store, sid, ev=0, data="stale") # expects 0, stored 1
|
||||
assert not r.ok and r.reason == ss.REASON_VERSION_CONFLICT and r.version == 1
|
||||
# original preserved
|
||||
got = store.get_for_slots([sid], {sid: 1})[sid]["tsl_v1"]
|
||||
assert got.data == "first" and got.version == 1
|
||||
|
||||
def test_occupancy_changed_does_not_store(self, store):
|
||||
sid = uuid4()
|
||||
# daemon read occupancy_seq=1 but slot is now occupancy 2
|
||||
r = _set(store, sid, ev=0, eo=1, cur_occ=2)
|
||||
assert not r.ok and r.reason == ss.REASON_OCCUPANCY_CHANGED and r.occupancy_seq == 2
|
||||
assert store.get_for_slots([sid], {sid: 2}) == {}
|
||||
|
||||
def test_slot_inactive_does_not_store(self, store):
|
||||
sid = uuid4()
|
||||
r = _set(store, sid, ev=0, active=False)
|
||||
assert not r.ok and r.reason == ss.REASON_SLOT_INACTIVE
|
||||
assert store.get_for_slots([sid], {sid: 1}) == {}
|
||||
|
||||
def test_state_too_large(self, store, monkeypatch):
|
||||
sid = uuid4()
|
||||
big = "a" * (ss._STATE_CAP_BYTES + 1)
|
||||
r = _set(store, sid, ev=0, data=big)
|
||||
assert not r.ok and r.reason == ss.REASON_STATE_TOO_LARGE
|
||||
|
||||
def test_idempotent_resend_is_conflict_not_double_apply(self, store):
|
||||
sid = uuid4()
|
||||
r1 = _set(store, sid, ev=0, data="v1") # -> version 1
|
||||
assert r1.ok and r1.version == 1
|
||||
# re-send identical expected_version=0 -> rejected (already advanced)
|
||||
r2 = _set(store, sid, ev=0, data="v1")
|
||||
assert not r2.ok and r2.reason == ss.REASON_VERSION_CONFLICT
|
||||
|
||||
|
||||
class TestAbaGuard:
|
||||
def test_turnover_first_write_rejected(self, store):
|
||||
"""dcy-028 R-1 / S-4: read v0 of occupancy 1, slot reused as occupancy 2,
|
||||
stale write against occupancy 1 must not bind to occupancy 2."""
|
||||
sid = uuid4()
|
||||
# occupancy 2 is current; daemon computed against occupancy 1 (eo=1)
|
||||
r = _set(store, sid, ev=0, eo=1, cur_occ=2, data="stale-peak")
|
||||
assert not r.ok and r.reason == ss.REASON_OCCUPANCY_CHANGED
|
||||
# new occupancy starts clean
|
||||
assert store.get_for_slots([sid], {sid: 2}) == {}
|
||||
|
||||
|
||||
class TestBatchedRead:
|
||||
def test_get_for_slots_groups_by_slot_and_strategy(self, store):
|
||||
a, b = uuid4(), uuid4()
|
||||
_set(store, a, ev=0, cur_occ=1, strat="tsl_v1", data="A")
|
||||
_set(store, a, ev=0, cur_occ=1, strat="buy_v1", data="A2")
|
||||
_set(store, b, ev=0, cur_occ=1, strat="tsl_v1", data="B")
|
||||
out = store.get_for_slots([a, b], {a: 1, b: 1})
|
||||
assert set(out[a]) == {"tsl_v1", "buy_v1"}
|
||||
assert out[a]["tsl_v1"].data == "A"
|
||||
assert out[b]["tsl_v1"].data == "B"
|
||||
|
||||
def test_stale_occupancy_entries_invisible(self, store):
|
||||
sid = uuid4()
|
||||
_set(store, sid, ev=0, cur_occ=1, data="old") # stored at occupancy 1
|
||||
# slot is now occupancy 2 -> the occupancy-1 entry must not surface
|
||||
assert store.get_for_slots([sid], {sid: 2}) == {}
|
||||
|
||||
def test_empty_slot_ids(self, store):
|
||||
assert store.get_for_slots([], {}) == {}
|
||||
|
||||
|
||||
class TestDelete:
|
||||
def test_delete_all_removes_every_strategy(self, store):
|
||||
sid = uuid4()
|
||||
_set(store, sid, ev=0, strat="tsl_v1")
|
||||
_set(store, sid, ev=0, strat="buy_v1")
|
||||
store.delete_all(sid)
|
||||
assert store.get_for_slots([sid], {sid: 1}) == {}
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Slot occupancy_seq bump + close-delete hook (dcy-028 / REQ-081, BAT-042).
|
||||
|
||||
S-3: every close path (→INACTIVE) deletes the slot's strategy_state and a reused
|
||||
slot starts with a new occupancy_seq + empty state. occupancy_seq is stable
|
||||
across one holding episode and bumps on inactive→pending_buy.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from trex_core.application.strategy_state import InMemoryStrategyStateStore
|
||||
from trex_core.domain.slot import SlotService, SlotState
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
INST = "default"
|
||||
|
||||
|
||||
def _seed(repo):
|
||||
repo.reconcile_universe(INST, {"pf-0": "SYM"})
|
||||
return repo.list_slots(INST)[0]
|
||||
|
||||
|
||||
def _service(repo, store=None):
|
||||
return SlotService(repo, instance_key=INST, strategy_state_store=store)
|
||||
|
||||
|
||||
def _activate_to_holding(service, slot_id):
|
||||
service.transit_slot(slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="t")
|
||||
return service.transit_slot(slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="filled", actor="t", qty=10, avg_price=100)
|
||||
|
||||
|
||||
class TestOccupancySeq:
|
||||
def test_bumps_on_inactive_to_pending_buy(self):
|
||||
repo = InMemorySlotRepository()
|
||||
service = _service(repo)
|
||||
slot = _seed(repo)
|
||||
assert slot.occupancy_seq == 0
|
||||
updated = service.transit_slot(
|
||||
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="t")
|
||||
assert updated.occupancy_seq == 1
|
||||
|
||||
def test_stable_across_holding_episode(self):
|
||||
repo = InMemorySlotRepository()
|
||||
service = _service(repo)
|
||||
slot = _seed(repo)
|
||||
held = _activate_to_holding(service, slot.slot_id)
|
||||
assert held.occupancy_seq == 1
|
||||
sold = service.transit_slot(slot.slot_id, SlotState.HOLDING,
|
||||
SlotState.PENDING_SELL, reason="sell", actor="t")
|
||||
assert sold.occupancy_seq == 1 # unchanged within the episode
|
||||
|
||||
def test_increments_on_reuse(self):
|
||||
repo = InMemorySlotRepository()
|
||||
service = _service(repo)
|
||||
slot = _seed(repo)
|
||||
_activate_to_holding(service, slot.slot_id)
|
||||
service.transit_slot(slot.slot_id, SlotState.HOLDING, SlotState.PENDING_SELL,
|
||||
reason="sell", actor="t")
|
||||
service.transit_slot(slot.slot_id, SlotState.PENDING_SELL, SlotState.INACTIVE,
|
||||
reason="sold", actor="t")
|
||||
# reuse: occupancy_seq bumps from 1 to 2
|
||||
reused = service.transit_slot(slot.slot_id, SlotState.INACTIVE,
|
||||
SlotState.PENDING_BUY, reason="buy", actor="t")
|
||||
assert reused.occupancy_seq == 2
|
||||
|
||||
|
||||
class TestCloseDelete:
|
||||
def _setup(self):
|
||||
repo = InMemorySlotRepository()
|
||||
store = InMemoryStrategyStateStore()
|
||||
service = _service(repo, store)
|
||||
slot = _seed(repo)
|
||||
_activate_to_holding(service, slot.slot_id)
|
||||
# daemon writes state during holding (occupancy_seq=1)
|
||||
store.set(slot.slot_id, "tsl_v1", expected_version=0,
|
||||
expected_occupancy_seq=1, data="peak", current_occupancy_seq=1,
|
||||
slot_active=True)
|
||||
assert store.get_for_slots([slot.slot_id], {slot.slot_id: 1})
|
||||
return repo, store, service, slot
|
||||
|
||||
def test_sell_fill_close_deletes_state(self):
|
||||
repo, store, service, slot = self._setup()
|
||||
service.transit_slot(slot.slot_id, SlotState.HOLDING, SlotState.PENDING_SELL,
|
||||
reason="sell", actor="t")
|
||||
service.transit_slot(slot.slot_id, SlotState.PENDING_SELL, SlotState.INACTIVE,
|
||||
reason="sold", actor="t")
|
||||
assert store.get_for_slots([slot.slot_id], {slot.slot_id: 1}) == {}
|
||||
|
||||
def test_reused_slot_starts_empty_with_new_occupancy(self):
|
||||
repo, store, service, slot = self._setup()
|
||||
service.transit_slot(slot.slot_id, SlotState.HOLDING, SlotState.PENDING_SELL,
|
||||
reason="sell", actor="t")
|
||||
service.transit_slot(slot.slot_id, SlotState.PENDING_SELL, SlotState.INACTIVE,
|
||||
reason="sold", actor="t")
|
||||
reused = service.transit_slot(slot.slot_id, SlotState.INACTIVE,
|
||||
SlotState.PENDING_BUY, reason="buy", actor="t")
|
||||
assert reused.occupancy_seq == 2
|
||||
assert store.get_for_slots([slot.slot_id], {slot.slot_id: 2}) == {}
|
||||
|
||||
def test_error_close_path_also_deletes(self):
|
||||
repo, store, service, slot = self._setup()
|
||||
# holding → error → inactive (operator resolve) also clears state
|
||||
service.transit_slot(slot.slot_id, SlotState.HOLDING, SlotState.ERROR,
|
||||
reason="err", actor="t")
|
||||
service.transit_slot(slot.slot_id, SlotState.ERROR, SlotState.INACTIVE,
|
||||
reason="resolve", actor="t")
|
||||
assert store.get_for_slots([slot.slot_id], {slot.slot_id: 1}) == {}
|
||||
|
||||
def test_no_store_injected_is_noop(self):
|
||||
# backward-compat: service without store does not raise on close
|
||||
repo = InMemorySlotRepository()
|
||||
service = _service(repo, store=None)
|
||||
slot = _seed(repo)
|
||||
_activate_to_holding(service, slot.slot_id)
|
||||
service.transit_slot(slot.slot_id, SlotState.HOLDING, SlotState.PENDING_SELL,
|
||||
reason="sell", actor="t")
|
||||
out = service.transit_slot(slot.slot_id, SlotState.PENDING_SELL,
|
||||
SlotState.INACTIVE, reason="sold", actor="t")
|
||||
assert out.status is SlotState.INACTIVE
|
||||
Reference in New Issue
Block a user