feat(strategy-state): persist instance-scoped slot maps

This commit is contained in:
Hermes Agent
2026-08-29 15:11:43 +09:00
parent 3f94c19162
commit 4699de61cc
18 changed files with 301 additions and 646 deletions
+1 -1
View File
@@ -107,7 +107,7 @@ def test_apply_cash_estimate_overlays_pending_delta():
repo = InMemorySlotRepository()
repo.reconcile_universe("default", {"PF-1": "SYM"})
handler = make_slots_rpc_handler(repo, snapshot_store=store, ledger_outbox=outbox)
resp = handler(RpcRequest(payload={"filter": "all"}, correlation_id="test"))
resp = handler(RpcRequest(payload={"filter": "all", "strategy_instance_id": "test-instance"}, correlation_id="test"))
slots = resp.payload["slots"]
pf1 = next(s for s in slots if s["portfolio_id"] == "PF-1")
assert pf1["cash_estimated"] is True
@@ -693,7 +693,7 @@ def _wire_with_warn(warned_slot_ids=None):
def _req(filter=None):
return RpcRequest(payload={"filter": filter or "all"}, correlation_id="t")
return RpcRequest(payload={"filter": filter or "all", "spy": True}, correlation_id="t")
# REQ-104 AC-5: reconcile_warned=True for warned slot
@@ -756,7 +756,7 @@ def test_slots_rpc_existing_warned_field_unchanged():
reason="filled", actor="test", avg_price=100.0, qty=10.0)
warn_store = InMemoryReconcileWarningStore()
handler = make_slots_rpc_handler(repo, snapshot_store=store, warn_store=warn_store)
resp = handler(RpcRequest(payload={"filter": "holding"}, correlation_id="t"))
resp = handler(RpcRequest(payload={"filter": "holding", "spy": True}, correlation_id="t"))
row = resp.payload["slots"][0]
# slot.qty=10 != hub asset_quantity=5 => warned=True (existing field)
assert row["warned"] is True
+2
View File
@@ -43,6 +43,8 @@ def _req(filter=None, spy=False):
payload["filter"] = filter
if spy:
payload["spy"] = True
else:
payload["strategy_instance_id"] = "test-instance"
return RpcRequest(payload=payload, correlation_id="test-corr")
@@ -74,6 +74,7 @@ def _make_handler(repo, warn_store, snapshot_items=None):
def _req(filter=None):
payload = {}
payload["spy"] = True
if filter:
payload["filter"] = filter
return RpcRequest(payload=payload, correlation_id="test")
@@ -67,6 +67,8 @@ def _req(filter=None, spy=False, strategy=None):
payload["filter"] = filter
if spy:
payload["spy"] = True
else:
payload["strategy_instance_id"] = "test-instance"
if strategy is not None:
payload["strategy"] = strategy
return RpcRequest(payload=payload, correlation_id="test-corr")
@@ -1,93 +1,60 @@
"""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.
"""
"""Strategy and operator strategy-state visibility contracts for slots.req."""
from __future__ import annotations
import pytest
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.application.watchdog import WatchdogStore
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
repo, store, watchdog = InMemorySlotRepository(), InMemoryStrategyStateStore(), WatchdogStore()
service = SlotService(repo)
repo.reconcile_universe("default", {"pf": "SYM"})
slot = repo.list_slots("default")[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="fill", actor="t", qty=1, avg_price=1)
store.set(slot.slot_id, "instance-a", expected_version=0, data="a", slot_active=True)
store.set(slot.slot_id, "instance-b", expected_version=0, data="b", slot_active=True)
return slot, store, watchdog, make_slots_rpc_handler(repo, strategy_state_store=store, watchdog_store=watchdog)
def _rows(handler):
return handler(RpcRequest(payload={}, correlation_id="c")).payload["slots"]
def _call(handler, payload):
return handler(RpcRequest(payload=payload, correlation_id="c")).payload
def test_row_carries_occupancy_seq():
_, _, _, slot, handler = _wire()
row = _rows(handler)[0]
assert row["occupancy_seq"] == 1
def test_strategy_receives_only_exact_requested_entry():
_, _, _, handler = _wire()
payload = _call(handler, {"strategy_instance_id": "instance-a"})
state = payload["slots"][0]["strategy_state"]
assert state["data"] == "a" and set(state) == {"version", "data", "updated_at"}
assert "instance-b" not in repr(payload)
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]
def test_strategy_omits_absent_entry():
_, _, _, handler = _wire()
row = _call(handler, {"strategy_instance_id": "absent"})["slots"][0]
assert "strategy_state" not in row
def test_no_strategy_reasoning_fields():
"""REQ-074 AC-5 (REQ-116 additive 재도입 후에도 유지): slot-origin strategy fields absent.
strategy_params / strategy_name / markets are now present (REQ-116 PortfolioItem-origin);
slot-origin strategy_id / sell_strategy_id / active_strategy_id remain absent.
"""
_, 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]
# slot-origin fields must be absent (REQ-074 AC-5 unchanged)
for k in ("strategy_id", "sell_strategy_id", "active_strategy_id"):
assert k not in row
# PortfolioItem-origin fields now present (REQ-116 additive)
assert "strategy_params" in row # {} (safe default, no snapshot_store)
assert "strategy_name" in row # None
assert "markets" in row # []
@pytest.mark.parametrize("identity", [None, "", False, 1, []])
def test_malformed_strategy_request_has_exact_safe_envelope(identity):
_, _, watchdog, handler = _wire()
assert _call(handler, {"strategy_instance_id": identity}) == {
"slots": [], "available_slots": None, "degraded": False,
"reason": "bad_request:strategy_instance_id",
"message": "strategy_instance_id must be a non-empty string",
}
assert watchdog.last_seen("unused") is None
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
def test_operator_without_or_with_id_receives_full_map_without_watchdog_update():
slot, _, watchdog, handler = _wire()
for payload in ({"spy": True}, {"spy": True, "strategy_instance_id": "instance-a"}):
state = _call(handler, payload)["slots"][0]["strategy_state"]
assert set(state) == {"instance-a", "instance-b"}
assert watchdog.last_seen(str(slot.slot_id)) is None
@@ -1,10 +1,8 @@
"""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.
"""
"""strategy_state.set strategy-instance wire contract tests."""
from __future__ import annotations
import pytest
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
@@ -12,109 +10,41 @@ 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
repo, store = InMemorySlotRepository(), InMemoryStrategyStateStore()
service = SlotService(repo)
repo.reconcile_universe("default", {"pf": "SYM"})
slot = repo.list_slots("default")[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="fill", actor="t", qty=1, avg_price=1)
return repo, store, service, slot, make_strategy_state_set_handler(repo, store)
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 _req(slot_id, instance="instance-a", version=0, data="state"):
return RpcRequest(payload={"slot_id": str(slot_id), "strategy_instance_id": instance, "expected_version": version, "data": data}, correlation_id="same-correlation-id")
def test_first_write_ok():
def test_write_uses_instance_identity_and_entry_cas():
_, _, _, slot, handler = _wire()
resp = handler(_req(slot.slot_id, ev=0))
assert resp.payload["ok"] is True and resp.payload["version"] == 1
assert handler(_req(slot.slot_id)).payload == {"ok": True, "slot_id": str(slot.slot_id), "strategy_instance_id": "instance-a", "version": 1}
conflict = handler(_req(slot.slot_id, version=0, data="stale")).payload
assert conflict["reason"] == ss.REASON_VERSION_CONFLICT and conflict["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
@pytest.mark.parametrize("identity", [None, "", 1, True, []])
def test_malformed_instance_id_is_rejected_without_write(identity):
_, store, _, slot, handler = _wire()
response = handler(_req(slot.slot_id, identity)).payload
assert response["ok"] is False
assert response["reason"].startswith("bad_request:")
assert store.get_for_slots([slot.slot_id]) == {}
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():
def test_inactive_and_oversize_writes_do_not_change_existing_entry():
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
handler(_req(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")
assert handler(_req(slot.slot_id, version=1)).payload["reason"] == ss.REASON_SLOT_INACTIVE
assert store.get_for_slots([slot.slot_id])[slot.slot_id]["instance-a"].version == 1
+35 -100
View File
@@ -1,118 +1,53 @@
"""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.
"""
"""Slot-scoped strategy-instance state store contract tests."""
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor
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"):
def _set(store, slot_id, instance="instance-a", *, version=0, data="x", active=True):
return store.set(
slot_id, strat,
expected_version=ev, expected_occupancy_seq=eo, data=data,
current_occupancy_seq=cur_occ, slot_active=active,
slot_id, instance, expected_version=version, data=data, 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
def test_entry_versions_are_isolated():
store, slot_id = InMemoryStrategyStateStore(), uuid4()
assert _set(store, slot_id, "a", data="a1").version == 1
assert _set(store, slot_id, "b", data="b1").version == 1
assert _set(store, slot_id, "a", version=1, data="a2").version == 2
entries = store.get_for_slots([slot_id])[slot_id]
assert entries["a"].version == 2 and entries["a"].data == "a2"
assert entries["b"].version == 1 and entries["b"].data == "b1"
assert not hasattr(entries["a"], "strategy_id")
assert not hasattr(entries["a"], "occupancy_seq")
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}) == {}
def test_conflict_inactive_and_size_limit_preserve_entries(monkeypatch):
store, slot_id = InMemoryStrategyStateStore(), uuid4()
_set(store, slot_id, "a", data="saved")
assert _set(store, slot_id, "a", data="stale").reason == ss.REASON_VERSION_CONFLICT
assert _set(store, slot_id, "b", active=False).reason == ss.REASON_SLOT_INACTIVE
assert _set(store, slot_id, "b", data="x" * (ss._STATE_CAP_BYTES + 1)).reason == ss.REASON_STATE_TOO_LARGE
entries = store.get_for_slots([slot_id])[slot_id]
assert set(entries) == {"a"} and entries["a"].data == "saved"
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([], {}) == {}
def test_concurrent_different_entries_both_survive():
store, slot_id = InMemoryStrategyStateStore(), uuid4()
with ThreadPoolExecutor(max_workers=2) as executor:
results = list(executor.map(lambda key: _set(store, slot_id, key, data=key), ("a", "b")))
assert all(result.ok for result in results)
assert set(store.get_for_slots([slot_id])[slot_id]) == {"a", "b"}
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}) == {}
def test_same_entry_competing_cas_has_one_winner():
store, slot_id = InMemoryStrategyStateStore(), uuid4()
_set(store, slot_id, "a", data="first")
with ThreadPoolExecutor(max_workers=2) as executor:
results = list(executor.map(lambda data: _set(store, slot_id, "a", version=1, data=data), ("x", "y")))
assert sum(result.ok for result in results) == 1
assert store.get_for_slots([slot_id])[slot_id]["a"].version == 2