fix strategy state persistence corrections
This commit is contained in:
@@ -28,6 +28,7 @@ _EXPECTED_TABLES = {
|
||||
"broker_account_snapshot", # dcy-041 / GitHub #45
|
||||
"ledger_skip", # GitHub #68 skipped-ledger durable audit
|
||||
"reconcile_notification_state", # req-0007 / ac-0024
|
||||
"slot_strategy_state", # req-0009 / ac-0027
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +60,25 @@ def test_daily_slot_columns_match_expected_contract(pg_clean):
|
||||
assert actual == set(EXPECTED_SLOT_FIELDS)
|
||||
|
||||
|
||||
def test_strategy_state_schema_matches_instance_map_contract(pg_clean):
|
||||
with psycopg.connect(pg_clean) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT column_name, data_type, udt_name "
|
||||
"FROM information_schema.columns "
|
||||
"WHERE table_schema = 'public' AND table_name = 'slot_strategy_state'"
|
||||
)
|
||||
columns = {row[0]: (row[1], row[2]) for row in cur.fetchall()}
|
||||
cur.execute(
|
||||
"SELECT a.attname FROM pg_index i "
|
||||
"JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) "
|
||||
"WHERE i.indrelid = 'slot_strategy_state'::regclass AND i.indisprimary"
|
||||
)
|
||||
primary_key = {row[0] for row in cur.fetchall()}
|
||||
assert columns == {"slot_id": ("uuid", "uuid"), "data": ("jsonb", "jsonb")}
|
||||
assert primary_key == {"slot_id"}
|
||||
|
||||
|
||||
def test_drift_detected_when_contract_extended(pg_clean):
|
||||
"""Negative case: a contract field absent from the table is caught."""
|
||||
actual = _columns(pg_clean, "daily_slot")
|
||||
|
||||
@@ -164,7 +164,7 @@ def test_strategy_state_store_pg(pg_clean):
|
||||
|
||||
repo = PostgreSQLSlotRepository(pg_clean)
|
||||
store = PgStrategyStateStore(pg_clean)
|
||||
svc = SlotService(repo, instance_key=INST, strategy_state_store=store)
|
||||
svc = SlotService(repo, instance_key=INST)
|
||||
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,
|
||||
@@ -189,12 +189,58 @@ def test_strategy_state_store_pg(pg_clean):
|
||||
))
|
||||
assert all(write.ok for write in writes)
|
||||
assert {"instance-c", "instance-d"} <= set(store.get_for_slots([slot.slot_id])[slot.slot_id])
|
||||
assert store.get_for_slots([slot.slot_id])[slot.slot_id]["instance-a"].version == 1
|
||||
|
||||
# Separate connections race on the same entry/version: only one CAS wins.
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
competing = list(executor.map(
|
||||
lambda data: store.set(
|
||||
slot.slot_id, "instance-a", expected_version=1, data=data, slot_active=True,
|
||||
),
|
||||
("winner-one", "winner-two"),
|
||||
))
|
||||
assert sum(result.ok for result in competing) == 1, competing
|
||||
assert sum(result.reason == ss.REASON_VERSION_CONFLICT for result in competing) == 1
|
||||
winner = next(data for data, result in zip(("winner-one", "winner-two"), competing) if result.ok)
|
||||
assert store.get_for_slots([slot.slot_id])[slot.slot_id]["instance-a"].data == winner
|
||||
assert store.get_for_slots([slot.slot_id])[slot.slot_id]["instance-a"].version == 2
|
||||
|
||||
# A fresh adapter sees the byte-identical persisted entry and can continue CAS.
|
||||
before_restart = store.get_for_slots([slot.slot_id])[slot.slot_id]["instance-a"]
|
||||
after_restart = PgStrategyStateStore(pg_clean).get_for_slots([slot.slot_id])[slot.slot_id]["instance-a"]
|
||||
assert after_restart == before_restart
|
||||
|
||||
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]["instance-a"].version == 1
|
||||
assert store.get_for_slots([slot.slot_id])[slot.slot_id]["instance-a"].version == 2
|
||||
reused = svc.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="t")
|
||||
reason="buy", actor="t")
|
||||
assert reused.occupancy_seq == 2
|
||||
rehydrated = PgStrategyStateStore(pg_clean)
|
||||
persisted = rehydrated.get_for_slots([slot.slot_id])[slot.slot_id]["instance-a"]
|
||||
assert persisted == before_restart
|
||||
assert rehydrated.set(
|
||||
slot.slot_id, "instance-a", expected_version=2, data="continued", slot_active=True,
|
||||
).version == 3
|
||||
|
||||
|
||||
def test_strategy_state_write_cannot_follow_close(pg_clean):
|
||||
"""The store's locked lifecycle read rejects a write after close wins."""
|
||||
from trex_core.adapters.persistence.postgresql import PgStrategyStateStore
|
||||
|
||||
repo = PostgreSQLSlotRepository(pg_clean)
|
||||
svc = SlotService(repo, instance_key=INST)
|
||||
store = PgStrategyStateStore(pg_clean)
|
||||
repo.reconcile_universe(INST, {"pf-close": "005930"})
|
||||
slot = repo.find_slot_by_portfolio(INST, "pf-close")
|
||||
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="fill", actor="t", qty=1)
|
||||
assert store.set(slot.slot_id, "instance", expected_version=0, data="before", slot_active=True).ok
|
||||
|
||||
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")
|
||||
result = store.set(slot.slot_id, "instance", expected_version=1, data="after", slot_active=True)
|
||||
assert not result.ok and result.reason == "slot_inactive"
|
||||
assert store.get_for_slots([slot.slot_id])[slot.slot_id]["instance"].data == "before"
|
||||
|
||||
@@ -332,6 +332,33 @@ def test_ac7_warn_store_failure_fail_closed():
|
||||
assert "excluded_slots" in resp.payload
|
||||
|
||||
|
||||
def test_ac23_exclusion_precedes_broker_and_strategy_state_readers():
|
||||
repo = InMemorySlotRepository()
|
||||
svc = SlotService(repo)
|
||||
warn_store = InMemoryReconcileWarningStore()
|
||||
slot = _make_holding_slot(repo, svc, portfolio_id="pf-A", symbol=SYM_A, qty=10)
|
||||
warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="C")
|
||||
|
||||
class ExplodingReader:
|
||||
def holdings_for_slots_rpc(self):
|
||||
raise AssertionError("excluded slot reached broker reader")
|
||||
|
||||
class ExplodingStateStore:
|
||||
def get_for_slots(self, _slot_ids):
|
||||
raise AssertionError("excluded slot reached state reader")
|
||||
|
||||
handler = make_slots_rpc_handler(
|
||||
repo,
|
||||
instance_key=INSTANCE,
|
||||
warn_store=warn_store,
|
||||
broker_quantity_reader=ExplodingReader(),
|
||||
strategy_state_store=ExplodingStateStore(),
|
||||
)
|
||||
response = handler(_req(filter="holding")).payload
|
||||
assert response["slots"] == []
|
||||
assert response["excluded_slots"] == {"reconcile_quantity_mismatch": 1}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-8: intent rejected if C-case warning set after last slots RPC (race)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -58,3 +58,14 @@ def test_operator_without_or_with_id_receives_full_map_without_watchdog_update()
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.parametrize("spy", ["false", "true", 1, [], {}])
|
||||
def test_non_boolean_spy_is_rejected_without_visibility_or_watchdog_update(spy):
|
||||
slot, _, watchdog, handler = _wire()
|
||||
payload = _call(handler, {"spy": spy})
|
||||
assert payload == {
|
||||
"slots": [], "available_slots": None, "degraded": False,
|
||||
"reason": "bad_request:spy", "message": "spy must be a boolean",
|
||||
}
|
||||
assert watchdog.last_seen(str(slot.slot_id)) is None
|
||||
|
||||
@@ -27,6 +27,7 @@ _PG_TABLES = (
|
||||
"daily_symbol_entries",
|
||||
"daily_slot",
|
||||
"reconcile_notification_state",
|
||||
"slot_strategy_state",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
def test_close_preserves_state_and_reopen_continues_version():
|
||||
repo, store = InMemorySlotRepository(), InMemoryStrategyStateStore()
|
||||
service = SlotService(repo, strategy_state_store=store)
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user