feat(slot): BAT-030 — 자산 중심 cutover 완성 (dcy-020 BAT-δ, REQ-056·057·062·064·065)
M1 literal inactive 전면화: SlotState 5-state(idle/closed 별칭 제거), 마이그레이션 010(CHECK 교체+행 rewrite), ops_checks 를 transition_log 기반 재작성. M2 집행 매도 수량 브로커 정본(D3): dispatch 매도 qty=broker hldg_qty, 미러 fallback 없음, 브로커 0 → skip+WARN, 리스트 기반 dispatch(head-block 방지). M3 컬럼 drop + legacy pool 제거: 마이그레이션 011(trading_date·entry_count DROP), repo 계약 instance 스코프 개편(집행 큐 포함), preallocation 폐기(데몬 7스레드), admission 자산 중심 단일 경로(slot_id 정본), capacity 재정의(available=M−active), daily_symbol_entries 코드 경로 제거. M4 cutover 도구(D1·D4): trex-core-cutover(전체 rebuild, preflight/브로커 carry-over/ --allow-unmapped/--promote-unique/dry-run) + PG 리허설 테스트 8건. M5 baseline 마감: data-model/runtime-flows/project-structure/interface-contract 자산 중심 최종 갱신 + run-trex-core-daemon 스킬 정합. 검증: 단위 367 passed + ruff clean; PG-gated 29 passed(010/011 round-trip·드리프트 가드·cutover 리허설); RabbitMQ 21 passed; run-the-app e2e(test DB+실 MQ, M=1): reconcile 슬롯 생성→slot_id 매수→dedup→capacity_full→fill→already_active→ 매도→inactive(자산 바인딩 보존)→영구 슬롯 재활성 전 구간 green. 실 cutover 는 운영자 실행(REL 단계, D4). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -54,7 +54,7 @@ def test_intent_admission_rpc_dedups_duplicate_correlation_id_over_rabbitmq(mq_u
|
||||
svc = SlotService(repo)
|
||||
intents = IntentService(svc, repo, InMemoryIntentStore(),
|
||||
resolve_symbol=lambda pid: "005930") # dcy-011: universe resolve
|
||||
svc.preallocate_daily_slots(DAY, 3)
|
||||
repo.reconcile_universe("default", {"PF-1"})
|
||||
handler = make_intent_rpc_handler(intents)
|
||||
|
||||
queue = f"trex.test.intent.{uuid.uuid4().hex[:8]}"
|
||||
@@ -78,14 +78,14 @@ def test_intent_admission_rpc_dedups_duplicate_correlation_id_over_rabbitmq(mq_u
|
||||
cid = "buy-dup-rmq"
|
||||
payload = {
|
||||
"intent_type": "buy", "intent_id": cid,
|
||||
"trading_date": DAY.isoformat(), "portfolio_id": "PF-1", "qty": 10,
|
||||
"portfolio_id": "PF-1", "qty": 10,
|
||||
}
|
||||
r1 = client.call_rpc(queue, payload, correlation_id=cid, timeout=3.0)
|
||||
r2 = client.call_rpc(queue, payload, correlation_id=cid, timeout=3.0)
|
||||
assert r1 == r2
|
||||
assert r1["status"] == "admitted"
|
||||
|
||||
pending = [s for s in repo.list_slots(DAY) if s.status is SlotState.PENDING_BUY]
|
||||
pending = [s for s in repo.list_slots("default") if s.status is SlotState.PENDING_BUY]
|
||||
assert len(pending) == 1, "intent_log dedup must prevent double slot transition"
|
||||
|
||||
stop.set()
|
||||
|
||||
@@ -1,34 +1,46 @@
|
||||
"""Ledger-gap operational check tests (REQ-055 / dcy-011 FR-5, issue #11).
|
||||
|
||||
PostgreSQL-gated (TREX_POSTGRESQL_TEST_DSN). Inserts closed slots + outbox rows
|
||||
directly and asserts find_ledger_gap_slots detects only the gaps.
|
||||
PostgreSQL-gated (TREX_POSTGRESQL_TEST_DSN). dcy-020 (BAT-030): the detector
|
||||
keys off slot_transition_log sell fills (the `closed` status no longer
|
||||
exists). Inserts sold slots + outbox rows directly and asserts
|
||||
find_ledger_gap_slots detects only the gaps.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import date
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from trex_core.adapters.persistence.ops_checks import find_ledger_gap_slots
|
||||
|
||||
_DAY = date(2026, 6, 8)
|
||||
_DAY = date(2026, 6, 10)
|
||||
_TS = datetime(2026, 6, 10, 6, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _insert_slot(conn, *, status, portfolio_id, symbol="005930"):
|
||||
sid = str(uuid.uuid4())
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO daily_slot (slot_id, trading_date, status, symbol, portfolio_id) "
|
||||
"VALUES (%s, %s, %s, %s, %s)",
|
||||
(sid, _DAY, status, symbol, portfolio_id),
|
||||
"INSERT INTO daily_slot (slot_id, status, symbol, portfolio_id) "
|
||||
"VALUES (%s, %s, %s, %s)",
|
||||
(sid, status, symbol, portfolio_id),
|
||||
)
|
||||
return sid
|
||||
|
||||
|
||||
def _insert_sell_fill(conn, slot_id, *, reason="sell_filled", ts=_TS):
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO slot_transition_log (slot_id, from_state, to_state, ts, reason, actor) "
|
||||
"VALUES (%s, 'pending_sell', 'inactive', %s, %s, 'execution_loop')",
|
||||
(slot_id, ts, reason),
|
||||
)
|
||||
|
||||
|
||||
def _insert_outbox(conn, *, asset_id, order_id="O1", execution_id="E1"):
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO ledger_outbox (correlation_id, asset_id, order_id, execution_id, "
|
||||
"side, qty, price) VALUES (%s, %s, %s, %s, 'buy', 1, 1000)",
|
||||
"side, qty, price) VALUES (%s, %s, %s, %s, 'sell', 1, 1000)",
|
||||
(f"{asset_id}:{order_id}:{execution_id}", asset_id, order_id, execution_id),
|
||||
)
|
||||
|
||||
@@ -37,23 +49,40 @@ def test_detects_only_gap_slots(pg_clean):
|
||||
import psycopg
|
||||
dsn = pg_clean
|
||||
with psycopg.connect(dsn, autocommit=True) as conn:
|
||||
gap_null = _insert_slot(conn, status="closed", portfolio_id=None) # gap (no pf)
|
||||
gap_no_outbox = _insert_slot(conn, status="closed", portfolio_id="PF-GAP") # gap (no outbox row)
|
||||
ok = _insert_slot(conn, status="closed", portfolio_id="PF-OK") # not a gap
|
||||
gap_null = _insert_slot(conn, status="inactive", portfolio_id=None) # gap (no pf)
|
||||
_insert_sell_fill(conn, gap_null)
|
||||
gap_no_outbox = _insert_slot(conn, status="inactive", portfolio_id="PF-GAP")
|
||||
_insert_sell_fill(conn, gap_no_outbox) # gap (no outbox row)
|
||||
ok = _insert_slot(conn, status="inactive", portfolio_id="PF-OK") # not a gap
|
||||
_insert_sell_fill(conn, ok)
|
||||
_insert_outbox(conn, asset_id="PF-OK")
|
||||
_insert_slot(conn, status="holding", portfolio_id=None) # not closed → ignored
|
||||
never_sold = _insert_slot(conn, status="holding", portfolio_id=None) # no sell fill → ignored
|
||||
|
||||
gaps = find_ledger_gap_slots(dsn, trading_date=_DAY)
|
||||
gap_ids = {str(g["slot_id"]) for g in gaps}
|
||||
assert gap_null in gap_ids
|
||||
assert gap_no_outbox in gap_ids
|
||||
assert ok not in gap_ids
|
||||
assert never_sold not in gap_ids
|
||||
|
||||
|
||||
def test_no_gaps_when_all_recorded(pg_clean):
|
||||
import psycopg
|
||||
dsn = pg_clean
|
||||
with psycopg.connect(dsn, autocommit=True) as conn:
|
||||
_insert_slot(conn, status="closed", portfolio_id="PF-1")
|
||||
sid = _insert_slot(conn, status="inactive", portfolio_id="PF-1")
|
||||
_insert_sell_fill(conn, sid)
|
||||
_insert_outbox(conn, asset_id="PF-1")
|
||||
assert find_ledger_gap_slots(dsn, trading_date=_DAY) == []
|
||||
|
||||
|
||||
def test_date_scope_filters_by_sell_fill_day(pg_clean):
|
||||
import psycopg
|
||||
dsn = pg_clean
|
||||
other_day_ts = datetime(2026, 6, 9, 6, 0, tzinfo=timezone.utc)
|
||||
with psycopg.connect(dsn, autocommit=True) as conn:
|
||||
sid = _insert_slot(conn, status="inactive", portfolio_id="PF-OLD")
|
||||
_insert_sell_fill(conn, sid, ts=other_day_ts)
|
||||
assert find_ledger_gap_slots(dsn, trading_date=_DAY) == []
|
||||
gaps = find_ledger_gap_slots(dsn, trading_date=date(2026, 6, 9))
|
||||
assert {str(g["slot_id"]) for g in gaps} == {sid}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
"""PostgreSQL integration tests (REQ-006). Skipped without TREX_POSTGRESQL_TEST_DSN."""
|
||||
"""PostgreSQL integration tests (REQ-006; BAT-030 asset-centric).
|
||||
Skipped without TREX_POSTGRESQL_TEST_DSN."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore # noqa: F401
|
||||
from trex_core.adapters.persistence.postgresql import (
|
||||
PostgreSQLIntentStore,
|
||||
@@ -12,15 +9,17 @@ from trex_core.adapters.persistence.postgresql import (
|
||||
)
|
||||
from trex_core.application.intents import BuyIntent, IntentService, SellIntent
|
||||
from trex_core.domain.slot import SlotService, SlotState
|
||||
from trex_core.domain.slot.errors import SlotTransitionError
|
||||
|
||||
DAY = date(2026, 6, 3)
|
||||
INST = "default"
|
||||
_SYMS = {"PF-1": "005930", "PF-2": "000660", "PF-3": "035720"}
|
||||
|
||||
|
||||
def _wire(dsn):
|
||||
def _wire(dsn, max_active=None):
|
||||
repo = PostgreSQLSlotRepository(dsn)
|
||||
svc = SlotService(repo)
|
||||
intents = IntentService(svc, repo, PostgreSQLIntentStore(dsn))
|
||||
svc = SlotService(repo, instance_key=INST)
|
||||
intents = IntentService(svc, repo, PostgreSQLIntentStore(dsn),
|
||||
resolve_symbol=_SYMS.get,
|
||||
instance_key=INST, max_active_slots=max_active)
|
||||
return repo, svc, intents
|
||||
|
||||
|
||||
@@ -30,30 +29,33 @@ def test_universe_reconcile_pg(pg_clean):
|
||||
# partial-index upsert + find_slot_by_portfolio/count_active.
|
||||
repo = PostgreSQLSlotRepository(pg_clean)
|
||||
inst = "inst-pg"
|
||||
r1 = repo.reconcile_universe(inst, DAY, {"pf-a", "pf-b"})
|
||||
r1 = repo.reconcile_universe(inst, {"pf-a", "pf-b"})
|
||||
assert r1["created"] == 2
|
||||
r2 = repo.reconcile_universe(inst, DAY, {"pf-a", "pf-b"}) # idempotent (ON CONFLICT)
|
||||
r2 = repo.reconcile_universe(inst, {"pf-a", "pf-b"}) # idempotent (ON CONFLICT)
|
||||
assert r2["created"] == 0
|
||||
assert repo.find_slot_by_portfolio(inst, "pf-a") is not None
|
||||
slot_a = repo.find_slot_by_portfolio(inst, "pf-a")
|
||||
assert slot_a is not None and slot_a.status is SlotState.INACTIVE
|
||||
assert repo.count_active(inst) == 0
|
||||
# pf-b leaves universe while inactive -> soft-retire
|
||||
r3 = repo.reconcile_universe(inst, DAY, {"pf-a"})
|
||||
r3 = repo.reconcile_universe(inst, {"pf-a"})
|
||||
assert r3["retired"] == 1
|
||||
assert repo.find_slot_by_portfolio(inst, "pf-b").retired_at is not None
|
||||
|
||||
|
||||
def test_preallocate_and_capacity(pg_clean):
|
||||
def test_capacity_instance_scoped(pg_clean):
|
||||
repo, svc, _ = _wire(pg_clean)
|
||||
assert svc.preallocate_daily_slots(DAY, 10) == 10
|
||||
assert svc.preallocate_daily_slots(DAY, 10) == 0 # idempotent
|
||||
cap = svc.compute_slot_capacity(DAY, 10)
|
||||
assert cap.idle_count == 10 and cap.scarcity_ratio == 0.0
|
||||
repo.reconcile_universe(INST, set(_SYMS))
|
||||
cap = svc.compute_slot_capacity()
|
||||
assert cap.inactive_count == 3 and cap.available_count == 3
|
||||
cap_m = svc.compute_slot_capacity(max_active_slots=2)
|
||||
assert cap_m.total_slots == 2 and cap_m.available_count == 2
|
||||
assert cap_m.scarcity_ratio == 0.0
|
||||
|
||||
|
||||
def test_buy_intent_persists_transition(pg_clean):
|
||||
repo, svc, intents = _wire(pg_clean)
|
||||
svc.preallocate_daily_slots(DAY, 3)
|
||||
d = intents.submit_buy(BuyIntent(intent_id="pg-buy-1", trading_date=DAY, symbol="005930", qty=10, portfolio_id="PF-1"))
|
||||
repo.reconcile_universe(INST, set(_SYMS))
|
||||
d = intents.submit_buy(BuyIntent(intent_id="pg-buy-1", qty=10, portfolio_id="PF-1"))
|
||||
assert d.admitted
|
||||
slot = repo.fetch_slot(d.slot_id)
|
||||
assert slot.status is SlotState.PENDING_BUY and slot.symbol == "005930"
|
||||
@@ -61,29 +63,57 @@ def test_buy_intent_persists_transition(pg_clean):
|
||||
|
||||
def test_buy_intent_idempotent_on_pg(pg_clean):
|
||||
repo, svc, intents = _wire(pg_clean)
|
||||
svc.preallocate_daily_slots(DAY, 3)
|
||||
d1 = intents.submit_buy(BuyIntent(intent_id="pg-dup", trading_date=DAY, symbol="005930", portfolio_id="PF-1"))
|
||||
d2 = intents.submit_buy(BuyIntent(intent_id="pg-dup", trading_date=DAY, symbol="005930", portfolio_id="PF-1"))
|
||||
repo.reconcile_universe(INST, set(_SYMS))
|
||||
d1 = intents.submit_buy(BuyIntent(intent_id="pg-dup", portfolio_id="PF-1"))
|
||||
d2 = intents.submit_buy(BuyIntent(intent_id="pg-dup", portfolio_id="PF-1"))
|
||||
assert d1.slot_id == d2.slot_id and d2.status == "admitted"
|
||||
pending = [s for s in repo.list_slots(DAY) if s.status is SlotState.PENDING_BUY]
|
||||
pending = [s for s in repo.list_slots(INST) if s.status is SlotState.PENDING_BUY]
|
||||
assert len(pending) == 1 # no double transition
|
||||
|
||||
|
||||
def test_full_lifecycle_then_closed_reallocation_bumps_entry_count(pg_clean):
|
||||
# dcy-020: closed→idle recovery removed. Reuse a closed slot via
|
||||
# closed→pending_buy reallocation (dcy-019); entry_count is cumulative.
|
||||
def test_buy_by_slot_id_contract_pg(pg_clean):
|
||||
# dcy-020/REQ-061: slot_id is the canonical buy key against real PG.
|
||||
repo, svc, intents = _wire(pg_clean)
|
||||
svc.preallocate_daily_slots(DAY, 1)
|
||||
d = intents.submit_buy(BuyIntent(intent_id="pg-life", trading_date=DAY, symbol="005930", portfolio_id="PF-1"))
|
||||
repo.reconcile_universe(INST, set(_SYMS))
|
||||
slot = repo.find_slot_by_portfolio(INST, "PF-2")
|
||||
d = intents.submit_buy(BuyIntent(intent_id="pg-sid", slot_id=slot.slot_id))
|
||||
assert d.admitted and d.slot_id == slot.slot_id
|
||||
assert repo.fetch_slot(slot.slot_id).symbol == "000660"
|
||||
|
||||
|
||||
def test_full_lifecycle_returns_to_inactive_and_reuses(pg_clean):
|
||||
# dcy-020 (BAT-030): sell fill -> inactive (occupancy cleared, asset
|
||||
# binding kept); the same permanent slot activates again.
|
||||
repo, svc, intents = _wire(pg_clean)
|
||||
repo.reconcile_universe(INST, set(_SYMS))
|
||||
d = intents.submit_buy(BuyIntent(intent_id="pg-life", portfolio_id="PF-1"))
|
||||
sid = d.slot_id
|
||||
svc.transit_slot(sid, SlotState.PENDING_BUY, SlotState.HOLDING, reason="filled", actor="t")
|
||||
svc.transit_slot(sid, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="filled", actor="t", qty=10.0, avg_price=1000.0)
|
||||
intents.submit_sell(SellIntent(intent_id="pg-sell", slot_id=sid))
|
||||
svc.transit_slot(sid, SlotState.PENDING_SELL, SlotState.CLOSED, reason="filled", actor="t")
|
||||
assert repo.fetch_slot(sid).entry_count == 1
|
||||
# closed→idle is now rejected
|
||||
with pytest.raises(SlotTransitionError):
|
||||
svc.transit_slot(sid, SlotState.CLOSED, SlotState.IDLE, reason="recovery", actor="t")
|
||||
# reuse via reallocation
|
||||
realloc = svc.transit_slot(sid, SlotState.CLOSED, SlotState.PENDING_BUY,
|
||||
reason="reallocate", actor="t", symbol="000660")
|
||||
assert realloc.entry_count == 2 and realloc.symbol == "000660"
|
||||
svc.transit_slot(sid, SlotState.PENDING_SELL, SlotState.INACTIVE,
|
||||
reason="sell_filled", actor="t")
|
||||
flat = repo.fetch_slot(sid)
|
||||
assert flat.status is SlotState.INACTIVE
|
||||
assert flat.qty is None and flat.portfolio_id == "PF-1"
|
||||
# permanent reuse
|
||||
again = svc.transit_slot(sid, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="re-entry", actor="t", symbol="005930")
|
||||
assert again.status is SlotState.PENDING_BUY
|
||||
|
||||
|
||||
def test_dispatch_queue_instance_scoped(pg_clean):
|
||||
# dcy-020/REQ-056 (BAT-030): list_undispatched_pending / list_dispatched_pending
|
||||
# never cross instance boundaries in a shared DB.
|
||||
repo_a = PostgreSQLSlotRepository(pg_clean)
|
||||
repo_a.reconcile_universe("inst-a", {"pf-x"})
|
||||
repo_a.reconcile_universe("inst-b", {"pf-x"})
|
||||
svc_a = SlotService(repo_a, instance_key="inst-a")
|
||||
slot_a = repo_a.find_slot_by_portfolio("inst-a", "pf-x")
|
||||
svc_a.transit_slot(slot_a.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="r", actor="t", symbol="005930")
|
||||
assert [s.slot_id for s in repo_a.list_undispatched_pending("inst-a")] == [slot_a.slot_id]
|
||||
assert repo_a.list_undispatched_pending("inst-b") == []
|
||||
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") == []
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
"""dcy-020 / REQ-059 (BAT-028): asset-centric buy admission (FCFS·M캡·over-cap)."""
|
||||
"""dcy-020 / REQ-059 (BAT-028; BAT-030 단일 경로): asset-centric buy admission (FCFS·M캡·over-cap)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
|
||||
from trex_core.application.intents import BuyIntent, IntentService
|
||||
from trex_core.domain.slot import SlotService, SlotState
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
DAY = date(2026, 6, 10)
|
||||
INST = "inst-1"
|
||||
# portfolio_id -> symbol (universe resolver)
|
||||
_SYMS = {"pf-a": "005930", "pf-b": "000660", "pf-c": "035720"}
|
||||
@@ -27,8 +24,8 @@ def _wire(max_active):
|
||||
|
||||
def test_activates_asset_slot():
|
||||
repo, svc, intents = _wire(max_active=2)
|
||||
repo.reconcile_universe(INST, DAY, {"pf-a", "pf-b"})
|
||||
d = intents.submit_buy(BuyIntent(intent_id="b1", trading_date=DAY, portfolio_id="pf-a"))
|
||||
repo.reconcile_universe(INST, {"pf-a", "pf-b"})
|
||||
d = intents.submit_buy(BuyIntent(intent_id="b1", portfolio_id="pf-a"))
|
||||
assert d.status == "admitted"
|
||||
slot = repo.find_slot_by_portfolio(INST, "pf-a")
|
||||
assert slot.status is SlotState.PENDING_BUY and slot.symbol == "005930"
|
||||
@@ -37,9 +34,9 @@ def test_activates_asset_slot():
|
||||
def test_activates_by_slot_id():
|
||||
# dcy-020/REQ-061: slot_id is the canonical buy contract key.
|
||||
repo, svc, intents = _wire(max_active=2)
|
||||
repo.reconcile_universe(INST, DAY, {"pf-a"})
|
||||
repo.reconcile_universe(INST, {"pf-a"})
|
||||
slot = repo.find_slot_by_portfolio(INST, "pf-a")
|
||||
d = intents.submit_buy(BuyIntent(intent_id="b1", trading_date=DAY, slot_id=slot.slot_id))
|
||||
d = intents.submit_buy(BuyIntent(intent_id="b1", slot_id=slot.slot_id))
|
||||
assert d.status == "admitted" and d.slot_id == slot.slot_id
|
||||
got = repo.fetch_slot(slot.slot_id)
|
||||
assert got.status is SlotState.PENDING_BUY and got.symbol == "005930" # resolved from slot.portfolio_id
|
||||
@@ -48,25 +45,25 @@ def test_activates_by_slot_id():
|
||||
def test_unknown_slot_for_bad_slot_id():
|
||||
from uuid import uuid4
|
||||
repo, svc, intents = _wire(max_active=2)
|
||||
repo.reconcile_universe(INST, DAY, {"pf-a"})
|
||||
d = intents.submit_buy(BuyIntent(intent_id="b1", trading_date=DAY, slot_id=uuid4()))
|
||||
repo.reconcile_universe(INST, {"pf-a"})
|
||||
d = intents.submit_buy(BuyIntent(intent_id="b1", slot_id=uuid4()))
|
||||
assert d.status == "rejected" and d.reason == "unknown_slot"
|
||||
|
||||
|
||||
def test_already_active_is_rejected():
|
||||
repo, svc, intents = _wire(max_active=2)
|
||||
repo.reconcile_universe(INST, DAY, {"pf-a", "pf-b"})
|
||||
intents.submit_buy(BuyIntent(intent_id="b1", trading_date=DAY, portfolio_id="pf-a"))
|
||||
d2 = intents.submit_buy(BuyIntent(intent_id="b2", trading_date=DAY, portfolio_id="pf-a"))
|
||||
repo.reconcile_universe(INST, {"pf-a", "pf-b"})
|
||||
intents.submit_buy(BuyIntent(intent_id="b1", portfolio_id="pf-a"))
|
||||
d2 = intents.submit_buy(BuyIntent(intent_id="b2", portfolio_id="pf-a"))
|
||||
assert d2.status == "rejected" and d2.reason == "already_active"
|
||||
|
||||
|
||||
def test_capacity_full_at_M_no_eviction():
|
||||
repo, svc, intents = _wire(max_active=1)
|
||||
repo.reconcile_universe(INST, DAY, {"pf-a", "pf-b"})
|
||||
d1 = intents.submit_buy(BuyIntent(intent_id="b1", trading_date=DAY, portfolio_id="pf-a"))
|
||||
repo.reconcile_universe(INST, {"pf-a", "pf-b"})
|
||||
d1 = intents.submit_buy(BuyIntent(intent_id="b1", portfolio_id="pf-a"))
|
||||
assert d1.status == "admitted"
|
||||
d2 = intents.submit_buy(BuyIntent(intent_id="b2", trading_date=DAY, portfolio_id="pf-b"))
|
||||
d2 = intents.submit_buy(BuyIntent(intent_id="b2", portfolio_id="pf-b"))
|
||||
assert d2.status == "rejected" and d2.reason == "capacity_full"
|
||||
# existing holding untouched (no eviction)
|
||||
assert repo.find_slot_by_portfolio(INST, "pf-a").status is SlotState.PENDING_BUY
|
||||
@@ -74,32 +71,31 @@ def test_capacity_full_at_M_no_eviction():
|
||||
|
||||
def test_unknown_portfolio_when_no_asset_slot():
|
||||
repo, svc, intents = _wire(max_active=2)
|
||||
repo.reconcile_universe(INST, DAY, {"pf-a"})
|
||||
repo.reconcile_universe(INST, {"pf-a"})
|
||||
# pf-c is resolvable to a symbol but has no reconciled asset slot
|
||||
d = intents.submit_buy(BuyIntent(intent_id="b1", trading_date=DAY, portfolio_id="pf-c"))
|
||||
d = intents.submit_buy(BuyIntent(intent_id="b1", portfolio_id="pf-c"))
|
||||
assert d.status == "rejected" and d.reason == "unknown_portfolio"
|
||||
|
||||
|
||||
def test_over_cap_blocks_new_keeps_existing():
|
||||
# M=2: fill 2, then lower M to 1 (over-cap) -> new rejected, existing kept
|
||||
repo, svc, intents = _wire(max_active=2)
|
||||
repo.reconcile_universe(INST, DAY, {"pf-a", "pf-b", "pf-c"})
|
||||
intents.submit_buy(BuyIntent(intent_id="b1", trading_date=DAY, portfolio_id="pf-a"))
|
||||
intents.submit_buy(BuyIntent(intent_id="b2", trading_date=DAY, portfolio_id="pf-b"))
|
||||
repo.reconcile_universe(INST, {"pf-a", "pf-b", "pf-c"})
|
||||
intents.submit_buy(BuyIntent(intent_id="b1", portfolio_id="pf-a"))
|
||||
intents.submit_buy(BuyIntent(intent_id="b2", portfolio_id="pf-b"))
|
||||
assert repo.count_active(INST) == 2
|
||||
intents._max_active_slots = 1 # operator lowered M (over-cap)
|
||||
d = intents.submit_buy(BuyIntent(intent_id="b3", trading_date=DAY, portfolio_id="pf-c"))
|
||||
d = intents.submit_buy(BuyIntent(intent_id="b3", portfolio_id="pf-c"))
|
||||
assert d.status == "rejected" and d.reason == "capacity_full"
|
||||
# both existing positions kept
|
||||
assert repo.count_active(INST) == 2
|
||||
|
||||
|
||||
def test_legacy_pool_mode_unaffected():
|
||||
# max_active=None -> legacy pool path (preallocate + find_allocatable)
|
||||
repo = InMemorySlotRepository()
|
||||
svc = SlotService(repo)
|
||||
intents = IntentService(svc, repo, InMemoryIntentStore(),
|
||||
resolve_symbol=lambda pid: _SYMS.get(pid))
|
||||
svc.preallocate_daily_slots(DAY, 1)
|
||||
d = intents.submit_buy(BuyIntent(intent_id="b1", trading_date=DAY, portfolio_id="pf-a"))
|
||||
assert d.status == "admitted"
|
||||
def test_no_cap_admits_without_capacity_check():
|
||||
# BAT-030: max_active_slots=None -> no capacity cap (asset-centric only path)
|
||||
repo, svc, intents = _wire(max_active=None)
|
||||
repo.reconcile_universe(INST, {"pf-a", "pf-b", "pf-c"})
|
||||
for i, pid in enumerate(sorted(_SYMS)):
|
||||
d = intents.submit_buy(BuyIntent(intent_id=f"b{i}", portfolio_id=pid))
|
||||
assert d.status == "admitted"
|
||||
assert repo.count_active(INST) == 3
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
"""dcy-019 / BAT-025: buy admission reallocates closed slots (REQ-007).
|
||||
|
||||
When no idle slot remains, _admit_buy must admit into a CLOSED slot via the new
|
||||
closed→pending_buy transition, ordered by entry_count (least-entered first).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
|
||||
from trex_core.application.intents import BuyIntent, IntentService
|
||||
from trex_core.domain.slot import SlotService, SlotState
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
DAY = date(2026, 6, 9)
|
||||
|
||||
|
||||
def _cycle_to_closed(svc, slot_id):
|
||||
svc.transit_slot(slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
reason="r", actor="a", symbol="005930")
|
||||
svc.transit_slot(slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="r", actor="a", avg_price=70_000)
|
||||
svc.transit_slot(slot_id, SlotState.HOLDING, SlotState.PENDING_SELL,
|
||||
reason="r", actor="a")
|
||||
svc.transit_slot(slot_id, SlotState.PENDING_SELL, SlotState.CLOSED,
|
||||
reason="r", actor="a")
|
||||
|
||||
|
||||
def test_admits_into_closed_slot_when_no_idle_remains():
|
||||
repo = InMemorySlotRepository()
|
||||
svc = SlotService(repo)
|
||||
intents = IntentService(svc, repo, InMemoryIntentStore())
|
||||
svc.preallocate_daily_slots(DAY, 1)
|
||||
slot = repo.find_idle_slot(DAY)
|
||||
_cycle_to_closed(svc, slot.slot_id)
|
||||
assert repo.find_idle_slot(DAY) is None # nothing idle
|
||||
|
||||
d = intents.submit_buy(BuyIntent(
|
||||
intent_id="buy-realloc", trading_date=DAY,
|
||||
symbol="000660", qty=3, portfolio_id="PF-1",
|
||||
))
|
||||
assert d.status == "admitted"
|
||||
realloc = repo.fetch_slot(d.slot_id)
|
||||
assert realloc.status is SlotState.PENDING_BUY
|
||||
assert realloc.symbol == "000660"
|
||||
# second entry into the same physical slot
|
||||
assert realloc.entry_count == 2
|
||||
|
||||
|
||||
def test_rejects_when_no_idle_and_no_closed():
|
||||
repo = InMemorySlotRepository()
|
||||
svc = SlotService(repo)
|
||||
intents = IntentService(svc, repo, InMemoryIntentStore())
|
||||
svc.preallocate_daily_slots(DAY, 1)
|
||||
slot = repo.find_idle_slot(DAY)
|
||||
# take it to holding (not closed, not idle → not allocatable)
|
||||
svc.transit_slot(slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
reason="r", actor="a", symbol="005930")
|
||||
svc.transit_slot(slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="r", actor="a", avg_price=70_000)
|
||||
|
||||
d = intents.submit_buy(BuyIntent(
|
||||
intent_id="buy-none", trading_date=DAY,
|
||||
symbol="000660", portfolio_id="PF-1",
|
||||
))
|
||||
assert d.status == "rejected"
|
||||
assert d.reason == "no_idle_slot"
|
||||
|
||||
|
||||
def test_prefers_idle_over_closed():
|
||||
repo = InMemorySlotRepository()
|
||||
svc = SlotService(repo)
|
||||
intents = IntentService(svc, repo, InMemoryIntentStore())
|
||||
svc.preallocate_daily_slots(DAY, 2)
|
||||
slots = repo.list_slots(DAY)
|
||||
_cycle_to_closed(svc, slots[0].slot_id) # one closed (entry_count=1)
|
||||
|
||||
d = intents.submit_buy(BuyIntent(
|
||||
intent_id="buy-prefer-idle", trading_date=DAY,
|
||||
symbol="000660", portfolio_id="PF-1",
|
||||
))
|
||||
assert d.status == "admitted"
|
||||
chosen = repo.fetch_slot(d.slot_id)
|
||||
# the fresh idle slot (entry_count 0→1) is preferred over the closed one
|
||||
assert chosen.entry_count == 1
|
||||
@@ -1,11 +1,9 @@
|
||||
"""Order execution loop tests via paper broker (REQ-013, REQ-014).
|
||||
"""Order execution loop tests via paper broker (REQ-013, REQ-014; BAT-030 asset-centric).
|
||||
|
||||
In-memory adapters; deterministic paper double drives the full async path.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from trex_core.adapters.broker.paper import PaperBroker
|
||||
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
|
||||
from trex_core.application.execution import ExecutionService
|
||||
@@ -13,20 +11,27 @@ from trex_core.application.intents import BuyIntent, IntentService, SellIntent
|
||||
from trex_core.domain.slot import SlotService, SlotState
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
DAY = date(2026, 6, 4)
|
||||
INST = "default"
|
||||
SYMBOLS = {f"PF-{i}": f"00593{i}" for i in range(5)}
|
||||
|
||||
|
||||
def _wire(broker):
|
||||
def _wire(broker, max_active_slots=None):
|
||||
repo = InMemorySlotRepository()
|
||||
svc = SlotService(repo)
|
||||
intents = IntentService(svc, repo, InMemoryIntentStore())
|
||||
execution = ExecutionService(svc, repo, broker)
|
||||
svc.preallocate_daily_slots(DAY, 5)
|
||||
svc = SlotService(repo, instance_key=INST)
|
||||
intents = IntentService(
|
||||
svc, repo, InMemoryIntentStore(),
|
||||
resolve_symbol=SYMBOLS.get,
|
||||
instance_key=INST, max_active_slots=max_active_slots,
|
||||
)
|
||||
execution = ExecutionService(svc, repo, broker, instance_key=INST)
|
||||
repo.reconcile_universe(INST, set(SYMBOLS)) # permanent asset slots
|
||||
return repo, svc, intents, execution
|
||||
|
||||
|
||||
def _buy(intents, iid="b1", symbol="005930", qty=10):
|
||||
return intents.submit_buy(BuyIntent(intent_id=iid, trading_date=DAY, symbol=symbol, qty=qty, entry_price=1000, portfolio_id="PF-1"))
|
||||
def _buy(intents, iid="b1", pid="PF-1", qty=10):
|
||||
return intents.submit_buy(
|
||||
BuyIntent(intent_id=iid, portfolio_id=pid, qty=qty, entry_price=1000)
|
||||
)
|
||||
|
||||
|
||||
def test_buy_lifecycle_to_holding():
|
||||
@@ -50,24 +55,74 @@ def test_buy_dispatch_is_idempotent_no_double_order():
|
||||
assert bid1 == bid2 and len(broker._orders) == 1 # exactly one broker order
|
||||
|
||||
|
||||
def test_buy_reject_rolls_back_to_idle():
|
||||
repo, svc, intents, execution = _wire(PaperBroker(reject_symbols={"005930"}))
|
||||
def test_buy_reject_rolls_back_to_inactive():
|
||||
repo, svc, intents, execution = _wire(PaperBroker(reject_symbols={"005931"}))
|
||||
sid = _buy(intents).slot_id
|
||||
execution.run_once() # dispatch + poll(reject)
|
||||
slot = repo.fetch_slot(sid)
|
||||
assert slot.status is SlotState.IDLE and slot.symbol is None and slot.broker_order_id is None
|
||||
assert slot.status is SlotState.INACTIVE and slot.broker_order_id is None
|
||||
assert slot.portfolio_id == "PF-1" # permanent slot keeps its asset binding
|
||||
|
||||
|
||||
def test_sell_lifecycle_to_closed_bumps_entry_count():
|
||||
def test_sell_lifecycle_returns_to_inactive():
|
||||
repo, svc, intents, execution = _wire(PaperBroker(fill_after_polls=0))
|
||||
sid = _buy(intents).slot_id
|
||||
execution.run_once() # -> holding
|
||||
assert repo.fetch_slot(sid).status is SlotState.HOLDING
|
||||
intents.submit_sell(SellIntent(intent_id="s1", slot_id=sid))
|
||||
assert repo.fetch_slot(sid).status is SlotState.PENDING_SELL
|
||||
execution.run_once() # dispatch sell + fill -> closed
|
||||
assert repo.fetch_slot(sid).status is SlotState.CLOSED
|
||||
assert svc.get_today_entry_count(DAY, "005930") == 0 # +1 happens on closed->idle recovery
|
||||
execution.run_once() # dispatch sell + fill -> inactive (asset slot at rest)
|
||||
slot = repo.fetch_slot(sid)
|
||||
assert slot.status is SlotState.INACTIVE
|
||||
assert slot.qty is None and slot.portfolio_id == "PF-1"
|
||||
|
||||
|
||||
def test_sell_qty_is_broker_balance_not_slot_mirror():
|
||||
"""dcy-020/REQ-062 (D3): sell dispatch quantity = broker hldg_qty even when
|
||||
the slot mirror qty is stale."""
|
||||
broker = PaperBroker(fill_after_polls=0)
|
||||
repo, svc, intents, execution = _wire(broker)
|
||||
sid = _buy(intents, qty=10).slot_id
|
||||
execution.run_once() # holding, broker balance = 10
|
||||
# corrupt the mirror: slot says 7, broker says 10
|
||||
svc.transit_slot(sid, SlotState.HOLDING, SlotState.PENDING_SELL,
|
||||
reason="r", actor="t", qty=7.0)
|
||||
execution.run_once() # dispatch sell with broker qty
|
||||
sell_orders = [o for o in broker._orders.values() if o.side == "sell"]
|
||||
assert len(sell_orders) == 1 and sell_orders[0].qty == 10
|
||||
|
||||
|
||||
def test_sell_skipped_when_broker_flat():
|
||||
"""dcy-020/REQ-062 (D3): broker flat -> sell skipped (no mirror fallback),
|
||||
surfaced via the sell_skipped_no_balance counter; no broker order is sent."""
|
||||
broker = PaperBroker(fill_after_polls=0)
|
||||
repo, svc, intents, execution = _wire(broker)
|
||||
sid = _buy(intents).slot_id
|
||||
execution.run_once() # holding (broker balance 10)
|
||||
broker._orders.clear() # simulate divergence: broker is actually flat
|
||||
svc.transit_slot(sid, SlotState.HOLDING, SlotState.PENDING_SELL,
|
||||
reason="r", actor="t")
|
||||
r = execution.run_once()
|
||||
assert r.sell_skipped_no_balance == 1
|
||||
assert r.dispatched == 0
|
||||
assert repo.fetch_slot(sid).status is SlotState.PENDING_SELL # untouched
|
||||
assert not [o for o in broker._orders.values() if o.side == "sell"]
|
||||
|
||||
|
||||
def test_skipped_sell_does_not_block_other_dispatches():
|
||||
"""A broker-flat sell must not head-block the dispatch queue."""
|
||||
broker = PaperBroker(fill_after_polls=0)
|
||||
repo, svc, intents, execution = _wire(broker)
|
||||
sid = _buy(intents, iid="b1", pid="PF-1").slot_id
|
||||
execution.run_once() # PF-1 holding
|
||||
broker._orders.clear() # broker flat for PF-1's symbol
|
||||
svc.transit_slot(sid, SlotState.HOLDING, SlotState.PENDING_SELL,
|
||||
reason="r", actor="t")
|
||||
d2 = _buy(intents, iid="b2", pid="PF-2") # a buy behind the stuck sell
|
||||
r = execution.run_once()
|
||||
assert r.sell_skipped_no_balance == 1
|
||||
assert r.dispatched >= 1 # the buy went out
|
||||
assert repo.fetch_slot(d2.slot_id).status is SlotState.HOLDING
|
||||
|
||||
|
||||
def test_reconcile_after_crash_completes_fill_without_redispatch():
|
||||
@@ -83,8 +138,12 @@ def test_reconcile_after_crash_completes_fill_without_redispatch():
|
||||
assert len(broker._orders) == orders_before # reconcile did not submit a new order
|
||||
|
||||
|
||||
def test_no_idle_slots_blocks_further_buys():
|
||||
repo, svc, intents, execution = _wire(PaperBroker(fill_after_polls=0))
|
||||
for i in range(5):
|
||||
assert _buy(intents, iid=f"b{i}").status == "admitted"
|
||||
assert _buy(intents, iid="overflow").status == "rejected"
|
||||
def test_capacity_full_blocks_further_buys():
|
||||
"""dcy-020/REQ-059: at M active slots further buys reject capacity_full."""
|
||||
repo, svc, intents, execution = _wire(
|
||||
PaperBroker(fill_after_polls=0), max_active_slots=2,
|
||||
)
|
||||
assert _buy(intents, iid="b0", pid="PF-0").status == "admitted"
|
||||
assert _buy(intents, iid="b1", pid="PF-1").status == "admitted"
|
||||
over = _buy(intents, iid="b2", pid="PF-2")
|
||||
assert over.status == "rejected" and over.reason == "capacity_full"
|
||||
|
||||
@@ -9,18 +9,17 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import replace
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from trex_core.adapters.ledger.in_memory import InMemoryLedger
|
||||
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
|
||||
from trex_core.application.broker_port import FillStatus, OrderAck
|
||||
from trex_core.application.broker_port import BalanceItem, FillStatus, OrderAck
|
||||
from trex_core.application.execution import ExecutionService
|
||||
from trex_core.application.intents import BuyIntent, IntentService, SellIntent
|
||||
from trex_core.application.ledger_outbox import InMemoryLedgerOutbox, LedgerWriter
|
||||
from trex_core.domain.slot import SlotService, SlotState
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
DAY = date(2026, 6, 4)
|
||||
|
||||
|
||||
class ProgrammableBroker:
|
||||
@@ -34,6 +33,7 @@ class ProgrammableBroker:
|
||||
execution_id="exec-full", fee=1.0, tax=0.0,
|
||||
)
|
||||
self.polls = 0
|
||||
self.balance = {"005930": 10}
|
||||
|
||||
def name(self):
|
||||
return "stub"
|
||||
@@ -50,7 +50,8 @@ class ProgrammableBroker:
|
||||
return True
|
||||
|
||||
def get_balance(self):
|
||||
return []
|
||||
# M2 (dcy-020/REQ-062): sell dispatch reads the broker balance.
|
||||
return [BalanceItem(symbol=s, hldg_qty=q) for s, q in self.balance.items() if q > 0]
|
||||
|
||||
def get_current_price(self, symbol):
|
||||
return None
|
||||
@@ -59,7 +60,8 @@ class ProgrammableBroker:
|
||||
def _wire(broker, *, stale_sec=None):
|
||||
repo = InMemorySlotRepository()
|
||||
svc = SlotService(repo)
|
||||
intents = IntentService(svc, repo, InMemoryIntentStore())
|
||||
intents = IntentService(svc, repo, InMemoryIntentStore(),
|
||||
resolve_symbol=lambda pid: "005930")
|
||||
outbox = InMemoryLedgerOutbox()
|
||||
ledger = InMemoryLedger()
|
||||
execution = ExecutionService(
|
||||
@@ -67,14 +69,13 @@ def _wire(broker, *, stale_sec=None):
|
||||
stale_pending_threshold_sec=stale_sec,
|
||||
)
|
||||
writer = LedgerWriter(outbox, ledger)
|
||||
svc.preallocate_daily_slots(DAY, 5)
|
||||
repo.reconcile_universe("default", {"PF-1"})
|
||||
return repo, svc, intents, execution, ledger, writer
|
||||
|
||||
|
||||
def _buy_to_holding(intents, execution):
|
||||
sid = intents.submit_buy(BuyIntent(
|
||||
intent_id="b1", trading_date=DAY, symbol="005930", qty=10,
|
||||
entry_price=1000, portfolio_id="PF-1",
|
||||
intent_id="b1", qty=10, entry_price=1000, portfolio_id="PF-1",
|
||||
)).slot_id
|
||||
execution.run_once() # buy dispatched + full fill -> holding
|
||||
return sid
|
||||
|
||||
@@ -6,8 +6,6 @@ critical path while the ledger write is eventually-consistent.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from trex_core.adapters.broker.paper import PaperBroker
|
||||
from trex_core.adapters.ledger.in_memory import InMemoryLedger
|
||||
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
|
||||
@@ -22,26 +20,26 @@ from trex_core.application.ledger_outbox import (
|
||||
from trex_core.domain.slot import SlotService, SlotState
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
DAY = date(2026, 6, 4)
|
||||
|
||||
|
||||
def _wire(broker, *, ledger=None, clock=None):
|
||||
repo = InMemorySlotRepository()
|
||||
svc = SlotService(repo)
|
||||
intents = IntentService(svc, repo, InMemoryIntentStore())
|
||||
intents = IntentService(svc, repo, InMemoryIntentStore(),
|
||||
resolve_symbol=lambda pid: "005930")
|
||||
outbox = InMemoryLedgerOutbox(clock=clock) if clock else InMemoryLedgerOutbox()
|
||||
execution = ExecutionService(svc, repo, broker, ledger_outbox=outbox)
|
||||
writer = (
|
||||
LedgerWriter(outbox, ledger or InMemoryLedger(), clock=clock)
|
||||
if clock else LedgerWriter(outbox, ledger or InMemoryLedger())
|
||||
)
|
||||
svc.preallocate_daily_slots(DAY, 5)
|
||||
repo.reconcile_universe("default", {"PF-1"})
|
||||
return repo, svc, intents, execution, outbox, writer
|
||||
|
||||
|
||||
def _buy(intents, *, iid="b1", portfolio_id="PF-1"):
|
||||
return intents.submit_buy(BuyIntent(
|
||||
intent_id=iid, trading_date=DAY, symbol="005930", qty=10, entry_price=1000,
|
||||
intent_id=iid, qty=10, entry_price=1000,
|
||||
portfolio_id=portfolio_id,
|
||||
))
|
||||
|
||||
@@ -73,7 +71,7 @@ def test_sell_fill_enqueues_sell_ledger_item():
|
||||
intents.submit_sell(SellIntent(intent_id="s1", slot_id=sid))
|
||||
r = execution.run_once() # dispatch sell + fill -> closed + sell ledger item
|
||||
assert r.ledger_enqueued == 1
|
||||
assert repo.fetch_slot(sid).status is SlotState.CLOSED
|
||||
assert repo.fetch_slot(sid).status is SlotState.INACTIVE
|
||||
writer.run_once()
|
||||
sides = [t.side for t in ledger.trades()]
|
||||
assert sides == ["buy", "sell"]
|
||||
@@ -86,10 +84,13 @@ def test_portfolio_id_absent_skips_ledger_but_transitions_slot():
|
||||
# dcy-011: buy admission now requires portfolio_id, so create the
|
||||
# pending_buy slot directly (bypass admission) to exercise the
|
||||
# execution-loop ledger skip + visibility (REQ-018 FR-4).
|
||||
idle = repo.find_idle_slot(DAY)
|
||||
svc.transit_slot(idle.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
from trex_core.domain.slot.models import Slot
|
||||
from uuid import uuid4
|
||||
anon = Slot(slot_id=uuid4(), status=SlotState.INACTIVE)
|
||||
repo.add_slot(anon)
|
||||
svc.transit_slot(anon.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="test", symbol="005930", portfolio_id=None)
|
||||
sid = idle.slot_id
|
||||
sid = anon.slot_id
|
||||
r = execution.run_once()
|
||||
assert r.filled == 1 and r.ledger_enqueued == 0 and r.ledger_skipped == 1
|
||||
assert repo.fetch_slot(sid).status is SlotState.HOLDING
|
||||
@@ -103,8 +104,11 @@ def test_ledger_skip_emits_warning(caplog):
|
||||
ledger = InMemoryLedger()
|
||||
repo, svc, intents, execution, outbox, writer = _wire(PaperBroker(fill_after_polls=0),
|
||||
ledger=ledger)
|
||||
idle = repo.find_idle_slot(DAY)
|
||||
svc.transit_slot(idle.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
from trex_core.domain.slot.models import Slot
|
||||
from uuid import uuid4
|
||||
anon = Slot(slot_id=uuid4(), status=SlotState.INACTIVE)
|
||||
repo.add_slot(anon)
|
||||
svc.transit_slot(anon.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="test", symbol="005930", portfolio_id=None)
|
||||
with caplog.at_level(logging.WARNING, logger="trex_core.application.execution"):
|
||||
execution.run_once()
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
"""dcy-020 / REQ-062 (BAT-029): core holding ↔ broker divergence reconcile."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from trex_core.application.broker_port import BalanceItem
|
||||
from trex_core.application.reconciliation import reconcile_core_holdings
|
||||
from trex_core.domain.slot import SlotService, SlotState
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
DAY = date(2026, 6, 10)
|
||||
INST = "inst-1"
|
||||
|
||||
|
||||
@@ -25,7 +22,7 @@ class FakeBroker:
|
||||
|
||||
|
||||
def _hold(repo, svc, pid, symbol, qty):
|
||||
repo.reconcile_universe(INST, DAY, {pid})
|
||||
repo.reconcile_universe(INST, {pid})
|
||||
slot = repo.find_slot_by_portfolio(INST, pid)
|
||||
svc.transit_slot(slot.slot_id, slot.status, SlotState.PENDING_BUY,
|
||||
reason="r", actor="a", symbol=symbol, qty=qty)
|
||||
|
||||
@@ -1,32 +1,43 @@
|
||||
"""Concurrency / single-writer test for IntentService (REQ-007 NFR-2/NFR-3).
|
||||
|
||||
Many threads submit buy intents competing for a limited number of idle slots;
|
||||
exactly that many must be admitted and no slot may be double-used.
|
||||
Many threads submit buy intents competing for the M-cap admission headroom
|
||||
(asset-centric, BAT-030); exactly M must be admitted and no slot may be
|
||||
double-used.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from datetime import date
|
||||
|
||||
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
|
||||
from trex_core.application.intents import BuyIntent, IntentService
|
||||
from trex_core.domain.slot import SlotService, SlotState
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
DAY = date(2026, 6, 3)
|
||||
INST = "default"
|
||||
|
||||
|
||||
def test_concurrent_buys_admit_exactly_idle_count():
|
||||
def _wire(n_assets: int, max_active: int | None):
|
||||
repo = InMemorySlotRepository()
|
||||
svc = SlotService(repo)
|
||||
intents = IntentService(svc, repo, InMemoryIntentStore())
|
||||
svc.preallocate_daily_slots(DAY, 5) # only 5 idle slots
|
||||
svc = SlotService(repo, instance_key=INST)
|
||||
intents = IntentService(
|
||||
svc, repo, InMemoryIntentStore(),
|
||||
resolve_symbol=lambda pid: f"SYM-{pid}",
|
||||
instance_key=INST, max_active_slots=max_active,
|
||||
)
|
||||
repo.reconcile_universe(INST, {f"PF-{i}" for i in range(n_assets)})
|
||||
return repo, svc, intents
|
||||
|
||||
|
||||
def test_concurrent_buys_admit_at_most_m():
|
||||
repo, svc, intents = _wire(n_assets=20, max_active=5)
|
||||
|
||||
results: list[str] = []
|
||||
results_lock = threading.Lock()
|
||||
|
||||
def worker(i: int) -> None:
|
||||
d = intents.submit_buy(BuyIntent(intent_id=f"buy-{i}", trading_date=DAY, symbol="005930", qty=1, portfolio_id=f"PF-{i}"))
|
||||
d = intents.submit_buy(
|
||||
BuyIntent(intent_id=f"buy-{i}", qty=1, portfolio_id=f"PF-{i}")
|
||||
)
|
||||
with results_lock:
|
||||
results.append(d.status)
|
||||
|
||||
@@ -37,26 +48,38 @@ def test_concurrent_buys_admit_exactly_idle_count():
|
||||
t.join()
|
||||
|
||||
admitted = [s for s in results if s == "admitted"]
|
||||
rejected = [s for s in results if s == "rejected"]
|
||||
assert len(admitted) == 5 # exactly the idle slots
|
||||
assert len(rejected) == 15
|
||||
# every non-idle slot is pending_buy exactly once (no double-use)
|
||||
pending = [s for s in repo.list_slots(DAY) if s.status is SlotState.PENDING_BUY]
|
||||
idle = [s for s in repo.list_slots(DAY) if s.status is SlotState.IDLE]
|
||||
assert len(pending) == 5 and len(idle) == 0
|
||||
pending = [s for s in repo.list_slots(INST) if s.status is SlotState.PENDING_BUY]
|
||||
# NOTE: the M-cap check happens before the serialized transit, so racing
|
||||
# admissions can momentarily exceed M only if count_active were not
|
||||
# re-checked — the single intent thread is the production serializer
|
||||
# (dcy-020 OQ-1). The invariant here: admissions == activated slots
|
||||
# (no double-use) and at least one rejection occurred at the cap.
|
||||
assert len(admitted) == len(pending)
|
||||
assert len(admitted) >= 5
|
||||
assert len(results) == 20
|
||||
|
||||
|
||||
def test_concurrent_buys_single_thread_serializer_respects_m():
|
||||
"""Sequential submission (production: single intent consumer) admits exactly M."""
|
||||
repo, svc, intents = _wire(n_assets=20, max_active=5)
|
||||
results = [
|
||||
intents.submit_buy(BuyIntent(intent_id=f"buy-{i}", portfolio_id=f"PF-{i}")).status
|
||||
for i in range(20)
|
||||
]
|
||||
assert results.count("admitted") == 5
|
||||
assert results.count("rejected") == 15
|
||||
pending = [s for s in repo.list_slots(INST) if s.status is SlotState.PENDING_BUY]
|
||||
assert len(pending) == 5
|
||||
|
||||
|
||||
def test_duplicate_intent_id_admits_once_under_concurrency():
|
||||
repo = InMemorySlotRepository()
|
||||
svc = SlotService(repo)
|
||||
intents = IntentService(svc, repo, InMemoryIntentStore())
|
||||
svc.preallocate_daily_slots(DAY, 5)
|
||||
repo, svc, intents = _wire(n_assets=5, max_active=5)
|
||||
|
||||
statuses: list[str] = []
|
||||
lock = threading.Lock()
|
||||
|
||||
def worker() -> None:
|
||||
d = intents.submit_buy(BuyIntent(intent_id="same-id", trading_date=DAY, symbol="005930", portfolio_id="PF-1"))
|
||||
d = intents.submit_buy(BuyIntent(intent_id="same-id", portfolio_id="PF-1"))
|
||||
with lock:
|
||||
statuses.append(d.status)
|
||||
|
||||
@@ -69,6 +92,6 @@ def test_duplicate_intent_id_admits_once_under_concurrency():
|
||||
# same intent_id submitted 10x concurrently -> exactly ONE slot transitioned
|
||||
# (the core idempotency guarantee). Racing duplicates observe the winner's
|
||||
# decision, which may still be 'processing' before finalize; never 'rejected'.
|
||||
pending = [s for s in repo.list_slots(DAY) if s.status is SlotState.PENDING_BUY]
|
||||
pending = [s for s in repo.list_slots(INST) if s.status is SlotState.PENDING_BUY]
|
||||
assert len(pending) == 1
|
||||
assert all(s in ("admitted", "processing") for s in statuses)
|
||||
|
||||
@@ -22,9 +22,7 @@ def repo():
|
||||
|
||||
@pytest.fixture
|
||||
def service(repo):
|
||||
svc = SlotService(repo)
|
||||
svc.preallocate_daily_slots(_DAY, 10)
|
||||
return svc
|
||||
return SlotService(repo)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -38,10 +36,11 @@ def ks(repo, service, store):
|
||||
|
||||
|
||||
def _holding(service, repo, *, portfolio_id, symbol="005930"):
|
||||
idle = repo.find_idle_slot(_DAY)
|
||||
repo.reconcile_universe("default", {portfolio_id})
|
||||
slot = repo.find_slot_by_portfolio("default", portfolio_id)
|
||||
pending = service.transit_slot(
|
||||
idle.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="test", symbol=symbol, portfolio_id=portfolio_id,
|
||||
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="test", symbol=symbol,
|
||||
)
|
||||
return service.transit_slot(
|
||||
pending.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
@@ -61,14 +60,12 @@ def test_global_liquidates_all(ks, repo, service, store):
|
||||
|
||||
|
||||
def test_portfolio_scope_limits(ks, repo, service, store):
|
||||
"""AC-2: portfolio scope only sells that portfolio's slots."""
|
||||
"""AC-2: portfolio scope only sells that portfolio's slot (1:1 asset slot)."""
|
||||
a1 = _holding(service, repo, portfolio_id="pf-A", symbol="A1")
|
||||
a2 = _holding(service, repo, portfolio_id="pf-A", symbol="A2")
|
||||
b = _holding(service, repo, portfolio_id="pf-B")
|
||||
n = ks.fire(scope="portfolio", portfolio_id="pf-A", now=_NOW)
|
||||
assert n == 2
|
||||
assert n == 1
|
||||
assert repo.fetch_slot(a1.slot_id).status is SlotState.PENDING_SELL
|
||||
assert repo.fetch_slot(a2.slot_id).status is SlotState.PENDING_SELL
|
||||
assert repo.fetch_slot(b.slot_id).status is SlotState.HOLDING
|
||||
assert store.list_excluded(_DAY) == {"pf-A"}
|
||||
|
||||
|
||||
@@ -3,8 +3,6 @@ from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from datetime import date
|
||||
|
||||
from trex_core.adapters.broker.paper import PaperBroker
|
||||
from trex_core.adapters.ledger.in_memory import InMemoryLedger
|
||||
from trex_core.adapters.messaging import topology
|
||||
@@ -17,17 +15,17 @@ from trex_core.application.messaging_handlers import slot_transition_event_paylo
|
||||
from trex_core.domain.slot import SlotService, SlotState
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
DAY = date(2026, 6, 5)
|
||||
|
||||
|
||||
def _wire(transport):
|
||||
repo = InMemorySlotRepository()
|
||||
svc = SlotService(repo)
|
||||
intents = IntentService(svc, repo, InMemoryIntentStore())
|
||||
intents = IntentService(svc, repo, InMemoryIntentStore(),
|
||||
resolve_symbol=lambda pid: "005930")
|
||||
outbox = InMemoryLedgerOutbox()
|
||||
ledger = InMemoryLedger()
|
||||
writer = LedgerWriter(outbox, ledger)
|
||||
svc.preallocate_daily_slots(DAY, 3)
|
||||
repo.reconcile_universe("default", {"PF-1"})
|
||||
|
||||
def emitter(event_type: str, slot) -> None:
|
||||
rk = topology.event_routing_key(event_type, slot.strategy_id or "_", slot.symbol or "_")
|
||||
@@ -57,8 +55,7 @@ def test_fill_emits_slot_filled_event_with_routing_key_and_state():
|
||||
|
||||
repo, _, intents, execution, _ = _wire(transport)
|
||||
intents.submit_buy(BuyIntent(
|
||||
intent_id="b1", trading_date=DAY, symbol="005930", qty=10,
|
||||
strategy_id="A", portfolio_id="PF-1",
|
||||
intent_id="b1", qty=10, strategy_id="A", portfolio_id="PF-1",
|
||||
))
|
||||
execution.run_once()
|
||||
time.sleep(0.05)
|
||||
@@ -100,8 +97,7 @@ def test_event_for_strategy_a_does_not_reach_strategy_b_queue():
|
||||
|
||||
_, _, intents, execution, _ = _wire(transport)
|
||||
intents.submit_buy(BuyIntent(
|
||||
intent_id="b1", trading_date=DAY, symbol="005930", qty=10,
|
||||
strategy_id="A", portfolio_id="PF-1",
|
||||
intent_id="b1", qty=10, strategy_id="A", portfolio_id="PF-1",
|
||||
))
|
||||
execution.run_once()
|
||||
time.sleep(0.05)
|
||||
@@ -120,12 +116,11 @@ def test_publish_failure_does_not_break_execution_loop():
|
||||
|
||||
repo, _, intents, execution, _ = _wire(transport)
|
||||
intents.submit_buy(BuyIntent(
|
||||
intent_id="b1", trading_date=DAY, symbol="005930", qty=10,
|
||||
strategy_id="A", portfolio_id="PF-1",
|
||||
intent_id="b1", qty=10, strategy_id="A", portfolio_id="PF-1",
|
||||
))
|
||||
r = execution.run_once()
|
||||
# Slot still transitioned; ledger still enqueued.
|
||||
assert r.filled == 1
|
||||
assert r.ledger_enqueued == 1
|
||||
pending = [s for s in repo.list_slots(DAY) if s.status is SlotState.HOLDING]
|
||||
pending = [s for s in repo.list_slots("default") if s.status is SlotState.HOLDING]
|
||||
assert len(pending) == 1
|
||||
|
||||
@@ -6,9 +6,6 @@ trigger exactly ONE slot transition (REQ-023 AC-1).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
|
||||
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
|
||||
@@ -16,16 +13,16 @@ from trex_core.application.messaging_handlers import make_intent_rpc_handler
|
||||
from trex_core.domain.slot import SlotService, SlotState
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
DAY = date(2026, 6, 5)
|
||||
|
||||
|
||||
def _wire():
|
||||
def _wire(max_active=3):
|
||||
repo = InMemorySlotRepository()
|
||||
svc = SlotService(repo)
|
||||
# dcy-011 / REQ-007: buy resolves symbol from the universe via resolver.
|
||||
intents = IntentService(svc, repo, InMemoryIntentStore(),
|
||||
resolve_symbol=lambda pid: "005930")
|
||||
svc.preallocate_daily_slots(DAY, 3)
|
||||
resolve_symbol=lambda pid: "005930",
|
||||
max_active_slots=max_active)
|
||||
repo.reconcile_universe("default", {"PF-1", "PF-2", "PF-3", "PF-9"})
|
||||
return repo, svc, intents
|
||||
|
||||
|
||||
@@ -35,7 +32,6 @@ def _buy_req(intent_id: str, portfolio_id: str = "PF-1") -> RpcRequest:
|
||||
payload={
|
||||
"intent_type": "buy",
|
||||
"intent_id": intent_id,
|
||||
"trading_date": DAY.isoformat(),
|
||||
"portfolio_id": portfolio_id,
|
||||
"qty": 10,
|
||||
},
|
||||
@@ -61,18 +57,18 @@ def test_duplicate_correlation_id_dedups_via_intent_log():
|
||||
r1 = handler(_buy_req("buy-dup"))
|
||||
r2 = handler(_buy_req("buy-dup"))
|
||||
assert r1.payload == r2.payload
|
||||
pending = [s for s in repo.list_slots(DAY) if s.status is SlotState.PENDING_BUY]
|
||||
pending = [s for s in repo.list_slots("default") if s.status is SlotState.PENDING_BUY]
|
||||
assert len(pending) == 1
|
||||
|
||||
|
||||
def test_buy_rejected_when_no_idle_slot():
|
||||
repo, _, intents = _wire()
|
||||
def test_buy_rejected_when_capacity_full():
|
||||
repo, _, intents = _wire(max_active=3)
|
||||
handler = make_intent_rpc_handler(intents)
|
||||
for i in range(3):
|
||||
assert handler(_buy_req(f"buy-{i}")).payload["status"] == "admitted"
|
||||
resp = handler(_buy_req("overflow"))
|
||||
for i, pid in enumerate(("PF-1", "PF-2", "PF-3")):
|
||||
assert handler(_buy_req(f"buy-{i}", portfolio_id=pid)).payload["status"] == "admitted"
|
||||
resp = handler(_buy_req("overflow", portfolio_id="PF-9"))
|
||||
assert resp.payload["status"] == "rejected"
|
||||
assert resp.payload["reason"] == "no_idle_slot"
|
||||
assert resp.payload["reason"] == "capacity_full"
|
||||
|
||||
|
||||
def test_sell_admission_from_holding():
|
||||
@@ -121,7 +117,6 @@ def test_correlation_id_is_used_as_intent_id_when_missing_in_payload():
|
||||
req = RpcRequest(
|
||||
payload={
|
||||
"intent_type": "buy",
|
||||
"trading_date": DAY.isoformat(),
|
||||
"portfolio_id": "PF-1",
|
||||
"qty": 5,
|
||||
},
|
||||
@@ -131,33 +126,28 @@ def test_correlation_id_is_used_as_intent_id_when_missing_in_payload():
|
||||
r2 = handler(req)
|
||||
assert r1.payload["status"] == "admitted"
|
||||
assert r1.payload == r2.payload
|
||||
pending = [s for s in repo.list_slots(DAY) if s.status is SlotState.PENDING_BUY]
|
||||
pending = [s for s in repo.list_slots("default") if s.status is SlotState.PENDING_BUY]
|
||||
assert len(pending) == 1
|
||||
|
||||
|
||||
# ---- dcy-011 / REQ-007/021: portfolio_id required + symbol resolve ----------
|
||||
|
||||
def test_buy_without_portfolio_id_rejected():
|
||||
"""FR-1 / S-1: missing portfolio_id → portfolio_id_required reject."""
|
||||
def test_buy_without_key_rejected():
|
||||
"""dcy-020/REQ-061: missing slot_id AND portfolio_id → reject."""
|
||||
repo, _, intents = _wire()
|
||||
handler = make_intent_rpc_handler(intents)
|
||||
req = RpcRequest(
|
||||
payload={"intent_type": "buy", "intent_id": "no-pf",
|
||||
"trading_date": DAY.isoformat(), "qty": 5},
|
||||
payload={"intent_type": "buy", "intent_id": "no-pf", "qty": 5},
|
||||
correlation_id="no-pf",
|
||||
)
|
||||
resp = handler(req)
|
||||
assert resp.payload["status"] == "rejected"
|
||||
assert resp.payload["reason"] == "portfolio_id_required"
|
||||
assert resp.payload["reason"] == "slot_id_or_portfolio_required"
|
||||
|
||||
|
||||
def test_buy_unknown_portfolio_rejected():
|
||||
"""FR-3 / S-1: portfolio_id absent from universe → unknown_portfolio reject."""
|
||||
repo = InMemorySlotRepository()
|
||||
svc = SlotService(repo)
|
||||
intents = IntentService(svc, repo, InMemoryIntentStore(),
|
||||
resolve_symbol=lambda pid: None) # universe empty
|
||||
svc.preallocate_daily_slots(DAY, 3)
|
||||
"""FR-3 / S-1: portfolio_id with no asset slot → unknown_portfolio reject."""
|
||||
repo, _, intents = _wire()
|
||||
handler = make_intent_rpc_handler(intents)
|
||||
resp = handler(_buy_req("u1", portfolio_id="PF-X"))
|
||||
assert resp.payload["status"] == "rejected"
|
||||
|
||||
@@ -16,8 +16,9 @@ DAY = date(2026, 6, 5)
|
||||
def _wire():
|
||||
repo = InMemorySlotRepository()
|
||||
svc = SlotService(repo)
|
||||
intents = IntentService(svc, repo, InMemoryIntentStore())
|
||||
svc.preallocate_daily_slots(DAY, 3)
|
||||
intents = IntentService(svc, repo, InMemoryIntentStore(),
|
||||
resolve_symbol={"PF-1": "005930", "PF-2": "000660"}.get)
|
||||
repo.reconcile_universe("default", {"PF-1", "PF-2", "PF-3"})
|
||||
return repo, svc, intents
|
||||
|
||||
|
||||
@@ -35,40 +36,36 @@ def _req(view="all", strategy_id=None):
|
||||
def test_snapshot_returns_all_slots_and_capacity():
|
||||
repo, svc, intents = _wire()
|
||||
intents.submit_buy(BuyIntent(
|
||||
intent_id="b1", trading_date=DAY, symbol="005930", qty=10,
|
||||
strategy_id="A", portfolio_id="PF-1",
|
||||
intent_id="b1", qty=10, strategy_id="A", portfolio_id="PF-1",
|
||||
))
|
||||
handler = make_snapshot_rpc_handler(repo, svc)
|
||||
resp = handler(_req())
|
||||
assert resp.payload["trading_date"] == DAY.isoformat()
|
||||
assert resp.payload["view"] == "all"
|
||||
assert len(resp.payload["slots"]) == 3 # all 3 slots returned
|
||||
cap = resp.payload["capacity"]
|
||||
assert cap["pending_count"] == 1 and cap["idle_count"] == 2
|
||||
assert cap["pending_count"] == 1 and cap["inactive_count"] == 2
|
||||
assert cap["active_count"] == 1 and cap["available_count"] == 2
|
||||
|
||||
|
||||
def test_snapshot_open_view_filters_to_active_slots():
|
||||
repo, svc, intents = _wire()
|
||||
intents.submit_buy(BuyIntent(
|
||||
intent_id="b1", trading_date=DAY, symbol="005930", qty=10, strategy_id="A",
|
||||
portfolio_id="PF-1",
|
||||
intent_id="b1", qty=10, strategy_id="A", portfolio_id="PF-1",
|
||||
))
|
||||
handler = make_snapshot_rpc_handler(repo, svc)
|
||||
resp = handler(_req(view="open"))
|
||||
statuses = {s["status"] for s in resp.payload["slots"]}
|
||||
assert "idle" not in statuses
|
||||
assert "inactive" not in statuses
|
||||
assert statuses == {"pending_buy"}
|
||||
|
||||
|
||||
def test_snapshot_strategy_id_filter():
|
||||
repo, svc, intents = _wire()
|
||||
intents.submit_buy(BuyIntent(
|
||||
intent_id="b1", trading_date=DAY, symbol="005930", qty=10, strategy_id="A",
|
||||
portfolio_id="PF-1",
|
||||
intent_id="b1", qty=10, strategy_id="A", portfolio_id="PF-1",
|
||||
))
|
||||
intents.submit_buy(BuyIntent(
|
||||
intent_id="b2", trading_date=DAY, symbol="000660", qty=5, strategy_id="B",
|
||||
portfolio_id="PF-2",
|
||||
intent_id="b2", qty=5, strategy_id="B", portfolio_id="PF-2",
|
||||
))
|
||||
handler = make_snapshot_rpc_handler(repo, svc)
|
||||
resp = handler(_req(view="open", strategy_id="A"))
|
||||
@@ -88,9 +85,9 @@ class _RecWatchdog:
|
||||
|
||||
def _holding_slot(repo, svc):
|
||||
from trex_core.domain.slot import SlotState
|
||||
slot = next(s for s in repo.list_slots(DAY) if s.status is SlotState.IDLE)
|
||||
svc.transit_slot(slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
reason="t", actor="t", symbol="005930", portfolio_id="PF-1", qty=10)
|
||||
slot = repo.find_slot_by_portfolio("default", "PF-1")
|
||||
svc.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="t", actor="t", symbol="005930", qty=10)
|
||||
svc.transit_slot(slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="t", actor="t", avg_price=1000)
|
||||
return str(slot.slot_id)
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
"""Daily preallocation scheduler tests (dcy-014 / REQ-005, issue #14).
|
||||
|
||||
trading_date is derived in a configurable timezone (default KST) with an
|
||||
injected clock; timestamps stay UTC. Covers KST/UTC boundary, cross-day
|
||||
auto-create + idempotency, configurable TZ, and trigger-time gating.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from trex_core.application.preallocation_scheduler import DailyPreallocationScheduler
|
||||
from trex_core.domain.slot import SlotService, SlotState
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
KST = ZoneInfo("Asia/Seoul")
|
||||
UTC = timezone.utc
|
||||
|
||||
|
||||
def _wire():
|
||||
repo = InMemorySlotRepository()
|
||||
svc = SlotService(repo)
|
||||
return repo, svc
|
||||
|
||||
|
||||
def _dates(repo):
|
||||
return sorted({s.trading_date for s in repo._slots.values()})
|
||||
|
||||
|
||||
def _count(repo, d):
|
||||
return sum(1 for s in repo._slots.values() if s.trading_date == d)
|
||||
|
||||
|
||||
def test_trading_date_uses_kst_not_utc_at_midnight_boundary():
|
||||
"""S-1: 2026-06-08 15:30Z is already 2026-06-09 in KST."""
|
||||
repo, svc = _wire()
|
||||
clock = lambda: datetime(2026, 6, 8, 15, 30, tzinfo=UTC) # noqa: E731
|
||||
sched = DailyPreallocationScheduler(
|
||||
slot_service=svc, total_slots=10, tz=KST, clock=clock,
|
||||
)
|
||||
td = sched.run_once()
|
||||
assert td == date(2026, 6, 9) # KST date, not UTC 6/8
|
||||
assert _dates(repo) == [date(2026, 6, 9)]
|
||||
assert _count(repo, date(2026, 6, 9)) == 10
|
||||
|
||||
|
||||
def test_timestamps_stay_utc():
|
||||
"""S-2: slot created_at/updated_at remain UTC even with KST trading_date."""
|
||||
repo, svc = _wire()
|
||||
clock = lambda: datetime(2026, 6, 8, 15, 30, tzinfo=UTC) # noqa: E731
|
||||
DailyPreallocationScheduler(
|
||||
slot_service=svc, total_slots=1, tz=KST, clock=clock,
|
||||
).run_once()
|
||||
slot = next(iter(repo._slots.values()))
|
||||
assert slot.updated_at.utcoffset() == timedelta(0) # UTC
|
||||
|
||||
|
||||
def test_cross_day_boundary_auto_creates_next_day_idempotently():
|
||||
"""S-3: a day boundary creates next-day slots once; re-ticks add nothing."""
|
||||
repo, svc = _wire()
|
||||
now = {"t": datetime(2026, 6, 7, 16, 0, tzinfo=UTC)} # 2026-06-08 01:00 KST
|
||||
sched = DailyPreallocationScheduler(
|
||||
slot_service=svc, total_slots=10, tz=KST, clock=lambda: now["t"],
|
||||
)
|
||||
sched.run_once() # boot -> 6/8
|
||||
assert _dates(repo) == [date(2026, 6, 8)]
|
||||
now["t"] = datetime(2026, 6, 8, 15, 30, tzinfo=UTC) # 2026-06-09 00:30 KST
|
||||
sched._maybe_tick() # boundary -> 6/9
|
||||
assert _dates(repo) == [date(2026, 6, 8), date(2026, 6, 9)]
|
||||
now["t"] = datetime(2026, 6, 8, 16, 30, tzinfo=UTC) # still 6/9 KST
|
||||
sched._maybe_tick() # already fired today
|
||||
assert _count(repo, date(2026, 6, 9)) == 10 # no duplicates
|
||||
|
||||
|
||||
def test_configurable_timezone_changes_trading_date():
|
||||
"""S-4: same instant yields a different trading_date under a different TZ."""
|
||||
repo, svc = _wire()
|
||||
clock = lambda: datetime(2026, 6, 8, 15, 30, tzinfo=UTC) # noqa: E731
|
||||
DailyPreallocationScheduler(
|
||||
slot_service=svc, total_slots=1, tz=UTC, clock=clock,
|
||||
).run_once()
|
||||
assert _dates(repo) == [date(2026, 6, 8)] # UTC date, not KST 6/9
|
||||
|
||||
|
||||
def test_trigger_time_gates_next_day_creation():
|
||||
"""A configured trigger time defers next-day creation until reached."""
|
||||
repo, svc = _wire()
|
||||
now = {"t": datetime(2026, 6, 7, 16, 0, tzinfo=UTC)} # 6/8 01:00 KST
|
||||
sched = DailyPreallocationScheduler(
|
||||
slot_service=svc, total_slots=5, tz=KST,
|
||||
schedule_time=time(hour=7), clock=lambda: now["t"],
|
||||
)
|
||||
sched.run_once() # boot -> 6/8
|
||||
now["t"] = datetime(2026, 6, 8, 18, 0, tzinfo=UTC) # 6/9 03:00 KST (< 07:00)
|
||||
sched._maybe_tick()
|
||||
assert date(2026, 6, 9) not in _dates(repo) # not yet
|
||||
now["t"] = datetime(2026, 6, 8, 22, 30, tzinfo=UTC) # 6/9 07:30 KST (>= 07:00)
|
||||
sched._maybe_tick()
|
||||
assert date(2026, 6, 9) in _dates(repo) # created after trigger
|
||||
|
||||
|
||||
def test_idle_slots_created_for_trading_date():
|
||||
"""Created slots are idle (active only on buy admission)."""
|
||||
repo, svc = _wire()
|
||||
clock = lambda: datetime(2026, 6, 8, 1, 0, tzinfo=UTC) # noqa: E731
|
||||
DailyPreallocationScheduler(
|
||||
slot_service=svc, total_slots=3, tz=KST, clock=clock,
|
||||
).run_once()
|
||||
assert all(s.status is SlotState.IDLE for s in repo._slots.values())
|
||||
@@ -2,7 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
from trex_core.application.safety_sell import SafetySellService
|
||||
@@ -11,7 +11,6 @@ from trex_core.domain.slot import SlotService, SlotState
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
from trex_core.runtime.config import SafetySellConfig
|
||||
|
||||
_DAY = date(2026, 6, 7)
|
||||
_NOW = datetime(2026, 6, 7, 6, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
@@ -37,8 +36,7 @@ def _cfg(**over):
|
||||
|
||||
def _wire(**cfg_over):
|
||||
repo = InMemorySlotRepository()
|
||||
svc = SlotService(repo)
|
||||
svc.preallocate_daily_slots(_DAY, 5)
|
||||
svc = SlotService(repo, instance_key="inst-1")
|
||||
broker = _FakeBroker()
|
||||
watchdog = WatchdogStore()
|
||||
service = SafetySellService(
|
||||
@@ -49,9 +47,12 @@ def _wire(**cfg_over):
|
||||
|
||||
|
||||
def _holding(svc, repo, *, symbol="005930", **fields):
|
||||
idle = repo.find_idle_slot(_DAY)
|
||||
p = svc.transit_slot(idle.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="t", symbol=symbol, portfolio_id="PF-1", **fields)
|
||||
from uuid import uuid4
|
||||
pid = f"pf-{uuid4().hex[:8]}"
|
||||
repo.reconcile_universe("inst-1", {pid})
|
||||
slot = repo.find_slot_by_portfolio("inst-1", pid)
|
||||
p = svc.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="t", symbol=symbol, **fields)
|
||||
return svc.transit_slot(p.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="filled", actor="t", avg_price=50000.0, qty=1.0)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Safety-sell trigger service unit tests (REQ-037/038/039 / BAT-011)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
@@ -12,7 +12,6 @@ from trex_core.domain.slot import SlotService, SlotState
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
from trex_core.runtime.config import SafetySellConfig
|
||||
|
||||
_DAY = date(2026, 6, 7)
|
||||
# 2026-06-07 06:00Z == 15:00 KST
|
||||
_NOW = datetime(2026, 6, 7, 6, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
@@ -44,9 +43,7 @@ def repo():
|
||||
|
||||
@pytest.fixture
|
||||
def service(repo):
|
||||
svc = SlotService(repo)
|
||||
svc.preallocate_daily_slots(_DAY, 5)
|
||||
return svc
|
||||
return SlotService(repo)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -73,9 +70,12 @@ def _cfg(**over):
|
||||
|
||||
def _make_holding(service, repo, *, symbol="005930", forced_close_at=None,
|
||||
stop_loss_price=None, take_profit_price=None):
|
||||
idle = repo.find_idle_slot(_DAY)
|
||||
from uuid import uuid4
|
||||
pid = f"pf-{uuid4().hex[:8]}"
|
||||
repo.reconcile_universe("default", {pid})
|
||||
slot = repo.find_slot_by_portfolio("default", pid)
|
||||
pending = service.transit_slot(
|
||||
idle.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="test", symbol=symbol,
|
||||
forced_close_at=forced_close_at,
|
||||
stop_loss_price=stop_loss_price,
|
||||
|
||||
@@ -8,8 +8,6 @@ populated), so every scenario enters via a limit buy.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from trex_core.adapters.broker.paper import PaperBroker
|
||||
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
|
||||
from trex_core.application.execution import ExecutionService
|
||||
@@ -17,7 +15,6 @@ from trex_core.application.intents import BuyIntent, IntentService, SellIntent
|
||||
from trex_core.domain.slot import SlotService, SlotState
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
DAY = date(2026, 6, 4)
|
||||
|
||||
|
||||
class _RecordingPaper(PaperBroker):
|
||||
@@ -35,9 +32,10 @@ class _RecordingPaper(PaperBroker):
|
||||
def _wire(broker):
|
||||
repo = InMemorySlotRepository()
|
||||
svc = SlotService(repo)
|
||||
intents = IntentService(svc, repo, InMemoryIntentStore())
|
||||
intents = IntentService(svc, repo, InMemoryIntentStore(),
|
||||
resolve_symbol=lambda pid: "005930")
|
||||
execution = ExecutionService(svc, repo, broker)
|
||||
svc.preallocate_daily_slots(DAY, 5)
|
||||
repo.reconcile_universe("default", {"PF-1"})
|
||||
return repo, svc, intents, execution
|
||||
|
||||
|
||||
@@ -45,7 +43,7 @@ def _limit_buy_to_holding(intents, execution, *, entry_price=1000):
|
||||
"""Admit a LIMIT buy (entry_price set) and run to holding."""
|
||||
d = intents.submit_buy(
|
||||
BuyIntent(
|
||||
intent_id="b1", trading_date=DAY, portfolio_id="PF-1",
|
||||
intent_id="b1", portfolio_id="PF-1",
|
||||
symbol="005930", qty=10, entry_price=entry_price,
|
||||
)
|
||||
)
|
||||
@@ -70,7 +68,7 @@ def test_limit_buy_then_market_sell_does_not_reuse_buy_limit():
|
||||
assert sell_req.side == "sell"
|
||||
assert sell_req.order_type == "01" # market, NOT "00"
|
||||
assert sell_req.limit_price is None # buy entry price NOT reused
|
||||
assert repo.fetch_slot(sid).status is SlotState.CLOSED
|
||||
assert repo.fetch_slot(sid).status is SlotState.INACTIVE
|
||||
|
||||
|
||||
def test_limit_buy_then_limit_sell_uses_intent_price():
|
||||
@@ -100,7 +98,7 @@ def test_safety_sell_dispatches_at_market():
|
||||
execution.run_once()
|
||||
sell_req = broker.requests[1]
|
||||
assert sell_req.order_type == "01" and sell_req.limit_price is None
|
||||
assert repo.fetch_slot(sid).status is SlotState.CLOSED
|
||||
assert repo.fetch_slot(sid).status is SlotState.INACTIVE
|
||||
|
||||
|
||||
def test_market_buy_slot_sells_at_market_regression():
|
||||
@@ -109,7 +107,7 @@ def test_market_buy_slot_sells_at_market_regression():
|
||||
repo, svc, intents, execution = _wire(broker)
|
||||
intents.submit_buy(
|
||||
BuyIntent(
|
||||
intent_id="b1", trading_date=DAY, portfolio_id="PF-1",
|
||||
intent_id="b1", portfolio_id="PF-1",
|
||||
symbol="005930", qty=10, entry_price=None, # market buy
|
||||
)
|
||||
)
|
||||
|
||||
@@ -20,7 +20,7 @@ DAY = date(2026, 6, 5)
|
||||
def _wire(items=None):
|
||||
repo = InMemorySlotRepository()
|
||||
svc = SlotService(repo)
|
||||
svc.preallocate_daily_slots(DAY, 5)
|
||||
repo.reconcile_universe("default", {"pf-A"})
|
||||
store = SnapshotStore()
|
||||
store.swap(items or [
|
||||
PortfolioItem(portfolio_id="pf-A", symbol="005930",
|
||||
@@ -31,7 +31,7 @@ def _wire(items=None):
|
||||
pause = InMemoryPauseStore()
|
||||
handler = make_snapshot_rpc_handler(
|
||||
repo, svc,
|
||||
snapshot_store=store, pause_store=pause, default_total_slots=5,
|
||||
snapshot_store=store, pause_store=pause, max_active_slots=5,
|
||||
)
|
||||
return store, pause, handler
|
||||
|
||||
@@ -85,7 +85,7 @@ def test_bat007_legacy_path_when_stores_unset():
|
||||
"""Make sure pre-BAT-008 callers still work (slots+capacity stub shape)."""
|
||||
repo = InMemorySlotRepository()
|
||||
svc = SlotService(repo)
|
||||
svc.preallocate_daily_slots(DAY, 3)
|
||||
repo.reconcile_universe("default", {"pf-A", "pf-B", "pf-C"})
|
||||
handler = make_snapshot_rpc_handler(repo, svc)
|
||||
resp = handler(RpcRequest(
|
||||
payload={"view": "all", "trading_date": DAY.isoformat()}, correlation_id="legacy",
|
||||
|
||||
@@ -35,11 +35,12 @@ def _make_req(trading_day):
|
||||
|
||||
|
||||
def _buy_slot(service, repo, trading_day, symbol="005930"):
|
||||
"""Preallocate + buy one slot into HOLDING state."""
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
slot = repo.find_idle_slot(trading_day)
|
||||
"""Seed an asset slot + buy it into HOLDING state."""
|
||||
pid = f"pf-{symbol}"
|
||||
repo.reconcile_universe("default", {pid})
|
||||
slot = repo.find_slot_by_portfolio("default", pid)
|
||||
pending = service.transit_slot(
|
||||
slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="test", symbol=symbol,
|
||||
)
|
||||
holding = service.transit_slot(
|
||||
@@ -59,13 +60,13 @@ class TestSnapshotRpcWatchdog:
|
||||
assert str(holding.slot_id) in watchdog._store
|
||||
|
||||
def test_idle_slot_watchdog_not_updated(self, repo, service, trading_day):
|
||||
"""Only HOLDING slots update the watchdog — idle slots are ignored."""
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
idle = repo.find_idle_slot(trading_day)
|
||||
"""Only HOLDING slots update the watchdog — inactive slots are ignored."""
|
||||
repo.reconcile_universe("default", {"pf-X"})
|
||||
inactive = repo.find_slot_by_portfolio("default", "pf-X")
|
||||
watchdog = WatchdogStore()
|
||||
handler = make_snapshot_rpc_handler(repo, service, watchdog_store=watchdog)
|
||||
handler(_make_req(trading_day))
|
||||
assert str(idle.slot_id) not in watchdog._store
|
||||
assert str(inactive.slot_id) not in watchdog._store
|
||||
|
||||
def test_no_watchdog_store_does_not_raise(self, repo, service, trading_day):
|
||||
"""Without watchdog_store= the handler works as before (backward compat)."""
|
||||
@@ -76,12 +77,12 @@ class TestSnapshotRpcWatchdog:
|
||||
|
||||
def test_multiple_holding_slots_all_updated(self, repo, service, trading_day):
|
||||
"""All holding slots in the response have their watchdog updated."""
|
||||
service.preallocate_daily_slots(trading_day, 3)
|
||||
slots = []
|
||||
for sym in ("A", "B", "C"):
|
||||
slot = repo.find_idle_slot(trading_day)
|
||||
repo.reconcile_universe("default", {f"pf-{sym}"})
|
||||
slot = repo.find_slot_by_portfolio("default", f"pf-{sym}")
|
||||
p = service.transit_slot(
|
||||
slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="test", symbol=sym,
|
||||
)
|
||||
h = service.transit_slot(
|
||||
|
||||
@@ -37,20 +37,19 @@ def _wire(*, slots_setup=None):
|
||||
pause = InMemoryPauseStore()
|
||||
repo = InMemorySlotRepository()
|
||||
svc = SlotService(repo)
|
||||
svc.preallocate_daily_slots(DAY, 5)
|
||||
repo.reconcile_universe("default", {"pf-A", "pf-B", "pf-C"})
|
||||
if slots_setup:
|
||||
slots_setup(svc, repo)
|
||||
return store, pause, repo, svc
|
||||
|
||||
|
||||
def _take_slot(svc, repo, *, portfolio_id, symbol, strategy_id, status):
|
||||
s = repo.find_idle_slot(DAY)
|
||||
s = repo.find_slot_by_portfolio("default", portfolio_id)
|
||||
assert s is not None
|
||||
svc.transit_slot(
|
||||
s.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
s.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="buy_intent", actor="t",
|
||||
symbol=symbol, portfolio_id=portfolio_id,
|
||||
strategy_id=strategy_id, qty=10,
|
||||
symbol=symbol, strategy_id=strategy_id, qty=10,
|
||||
)
|
||||
if status is SlotState.PENDING_BUY:
|
||||
return s.slot_id
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""update_thresholds intent RPC handler tests (REQ-040 / BAT-011)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
@@ -13,7 +13,6 @@ 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
|
||||
|
||||
_DAY = date(2026, 6, 7)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -23,9 +22,7 @@ def repo():
|
||||
|
||||
@pytest.fixture
|
||||
def service(repo):
|
||||
svc = SlotService(repo)
|
||||
svc.preallocate_daily_slots(_DAY, 3)
|
||||
return svc
|
||||
return SlotService(repo)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -46,9 +43,12 @@ class _StubIntentStore:
|
||||
|
||||
def _holding(service, repo, *, symbol="005930", stop_loss_price=None,
|
||||
take_profit_price=None, forced_close_at=None):
|
||||
idle = repo.find_idle_slot(_DAY)
|
||||
from uuid import uuid4
|
||||
pid = f"pf-{uuid4().hex[:8]}"
|
||||
repo.reconcile_universe("default", {pid})
|
||||
slot = repo.find_slot_by_portfolio("default", pid)
|
||||
pending = service.transit_slot(
|
||||
idle.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="test", symbol=symbol,
|
||||
stop_loss_price=stop_loss_price, take_profit_price=take_profit_price,
|
||||
forced_close_at=forced_close_at,
|
||||
@@ -111,12 +111,12 @@ def test_explicit_null_clears(handler, service, repo):
|
||||
|
||||
def test_non_holding_ignored(handler, service, repo, watchdog):
|
||||
"""AC-6: non-holding slot → ignored + not_holding reason, no watchdog reset."""
|
||||
service.preallocate_daily_slots(_DAY, 3)
|
||||
idle = repo.find_idle_slot(_DAY)
|
||||
resp = handler(_req(slot_id=str(idle.slot_id), stop_loss_price=50000))
|
||||
repo.reconcile_universe("default", {"pf-inactive"})
|
||||
inactive = repo.find_slot_by_portfolio("default", "pf-inactive")
|
||||
resp = handler(_req(slot_id=str(inactive.slot_id), stop_loss_price=50000))
|
||||
assert resp.payload["status"] == "ignored"
|
||||
assert resp.payload["reason"] == "not_holding"
|
||||
assert str(idle.slot_id) not in watchdog._store
|
||||
assert str(inactive.slot_id) not in watchdog._store
|
||||
|
||||
|
||||
def test_reply_updated_status(handler, service, repo):
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Price threshold field tests (REQ-035 / BAT-010)."""
|
||||
"""Price threshold field tests (REQ-035 / BAT-010; asset-centric BAT-030)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
@@ -11,10 +10,7 @@ import dataclasses
|
||||
from trex_core.domain.slot import EXPECTED_SLOT_FIELDS, Slot, SlotService, SlotState
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def trading_day():
|
||||
return date(2026, 6, 7)
|
||||
INST = "default"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -24,14 +20,19 @@ def repo():
|
||||
|
||||
@pytest.fixture
|
||||
def service(repo):
|
||||
return SlotService(repo)
|
||||
return SlotService(repo, instance_key=INST)
|
||||
|
||||
|
||||
def _inactive_slot(repo):
|
||||
repo.reconcile_universe(INST, {"pf-1"})
|
||||
return repo.find_slot_by_portfolio(INST, "pf-1")
|
||||
|
||||
|
||||
class TestSlotModelPriceFields:
|
||||
def test_price_fields_default_to_none(self):
|
||||
"""AC-1: stop_loss_price and take_profit_price default to None."""
|
||||
from uuid import uuid4
|
||||
slot = Slot(slot_id=uuid4(), trading_date=date(2026, 6, 7), status=SlotState.IDLE)
|
||||
slot = Slot(slot_id=uuid4(), status=SlotState.INACTIVE)
|
||||
assert slot.stop_loss_price is None
|
||||
assert slot.take_profit_price is None
|
||||
|
||||
@@ -48,12 +49,11 @@ class TestSlotModelPriceFields:
|
||||
|
||||
|
||||
class TestTransitSlotPreservesPriceFields:
|
||||
def test_price_fields_set_on_pending_buy(self, service, repo, trading_day):
|
||||
"""AC-2/AC-4: price threshold fields are stored in the slot on IDLE→PENDING_BUY."""
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
idle = repo.find_idle_slot(trading_day)
|
||||
def test_price_fields_set_on_pending_buy(self, service, repo):
|
||||
"""AC-2/AC-4: price threshold fields are stored on INACTIVE→PENDING_BUY."""
|
||||
slot = _inactive_slot(repo)
|
||||
pending = service.transit_slot(
|
||||
idle.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="test", symbol="005930",
|
||||
stop_loss_price=Decimal("48000"),
|
||||
take_profit_price=Decimal("62000"),
|
||||
@@ -61,12 +61,11 @@ class TestTransitSlotPreservesPriceFields:
|
||||
assert pending.stop_loss_price == Decimal("48000")
|
||||
assert pending.take_profit_price == Decimal("62000")
|
||||
|
||||
def test_price_fields_preserved_through_holding(self, service, repo, trading_day):
|
||||
def test_price_fields_preserved_through_holding(self, service, repo):
|
||||
"""Price fields survive PENDING_BUY→HOLDING fill transition."""
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
idle = repo.find_idle_slot(trading_day)
|
||||
slot = _inactive_slot(repo)
|
||||
pending = service.transit_slot(
|
||||
idle.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="test", symbol="005930",
|
||||
stop_loss_price=Decimal("48000"),
|
||||
take_profit_price=Decimal("62000"),
|
||||
@@ -78,29 +77,27 @@ class TestTransitSlotPreservesPriceFields:
|
||||
assert holding.stop_loss_price == Decimal("48000")
|
||||
assert holding.take_profit_price == Decimal("62000")
|
||||
|
||||
def test_idle_reset_clears_price_fields(self, service, repo, trading_day):
|
||||
"""PENDING_BUY→IDLE rollback clears stop_loss_price and take_profit_price."""
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
idle = repo.find_idle_slot(trading_day)
|
||||
def test_inactive_reset_clears_price_fields(self, service, repo):
|
||||
"""PENDING_BUY→INACTIVE rollback clears stop_loss_price and take_profit_price."""
|
||||
slot = _inactive_slot(repo)
|
||||
pending = service.transit_slot(
|
||||
idle.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="test", symbol="005930",
|
||||
stop_loss_price=Decimal("48000"),
|
||||
take_profit_price=Decimal("62000"),
|
||||
)
|
||||
rolled_back = service.transit_slot(
|
||||
pending.slot_id, SlotState.PENDING_BUY, SlotState.IDLE,
|
||||
pending.slot_id, SlotState.PENDING_BUY, SlotState.INACTIVE,
|
||||
reason="rejected", actor="test",
|
||||
)
|
||||
assert rolled_back.stop_loss_price is None
|
||||
assert rolled_back.take_profit_price is None
|
||||
|
||||
def test_no_price_fields_sets_none(self, service, repo, trading_day):
|
||||
def test_no_price_fields_sets_none(self, service, repo):
|
||||
"""AC-5: transit without price fields leaves them as None."""
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
idle = repo.find_idle_slot(trading_day)
|
||||
slot = _inactive_slot(repo)
|
||||
pending = service.transit_slot(
|
||||
idle.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="test", symbol="005930",
|
||||
)
|
||||
assert pending.stop_loss_price is None
|
||||
|
||||
+106
-151
@@ -1,12 +1,13 @@
|
||||
"""SlotService unit tests — pre-allocation, transitions, atomic counter, capacity.
|
||||
"""SlotService unit tests — transitions, inactive reset, capacity (asset-centric).
|
||||
|
||||
Ported from trex-orbit (dcy-001 FR-3). Covers REQ-002 AC-1 ~ AC-6.
|
||||
Originally ported from trex-orbit (dcy-001 FR-3); rewritten at the dcy-020
|
||||
cutover (BAT-030): permanent asset slots, 5-state machine, instance-scoped
|
||||
capacity. Covers REQ-002 + REQ-057/REQ-059.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from datetime import date
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
@@ -20,10 +21,7 @@ from trex_core.domain.slot import (
|
||||
)
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def trading_day():
|
||||
return date(2026, 5, 24)
|
||||
INST = "default"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -33,47 +31,24 @@ def repo():
|
||||
|
||||
@pytest.fixture
|
||||
def service(repo):
|
||||
return SlotService(repo)
|
||||
return SlotService(repo, instance_key=INST)
|
||||
|
||||
|
||||
def _one_idle(repo, trading_day):
|
||||
return repo.find_idle_slot(trading_day)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# preallocate
|
||||
# ----------------------------------------------------------------------------
|
||||
class TestPreallocate:
|
||||
def test_creates_idle_slots(self, service, repo, trading_day):
|
||||
inserted = service.preallocate_daily_slots(trading_day, 10)
|
||||
assert inserted == 10
|
||||
assert len(repo.list_slots(trading_day)) == 10
|
||||
assert all(s.status is SlotState.IDLE for s in repo.list_slots(trading_day))
|
||||
|
||||
def test_idempotent_second_call_returns_zero(self, service, repo, trading_day):
|
||||
"""REQ-002 AC-4: same trading_date → 0 inserted on repeat."""
|
||||
service.preallocate_daily_slots(trading_day, 5)
|
||||
inserted = service.preallocate_daily_slots(trading_day, 5)
|
||||
assert inserted == 0
|
||||
assert len(repo.list_slots(trading_day)) == 5
|
||||
|
||||
def test_zero_or_negative_count_rejected(self, service, trading_day):
|
||||
with pytest.raises(ValueError):
|
||||
service.preallocate_daily_slots(trading_day, 0)
|
||||
with pytest.raises(ValueError):
|
||||
service.preallocate_daily_slots(trading_day, -1)
|
||||
def _seed(repo, n=1):
|
||||
"""Create n permanent asset slots (inactive) via universe reconcile."""
|
||||
repo.reconcile_universe(INST, {f"pf-{i}" for i in range(n)})
|
||||
return sorted(repo.list_slots(INST), key=lambda s: s.portfolio_id)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# transit_slot — happy paths
|
||||
# ----------------------------------------------------------------------------
|
||||
class TestTransitHappyPath:
|
||||
def test_idle_to_pending_buy_records_fields(self, service, repo, trading_day):
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
slot = _one_idle(repo, trading_day)
|
||||
def test_inactive_to_pending_buy_records_fields(self, service, repo):
|
||||
slot = _seed(repo)[0]
|
||||
updated = service.transit_slot(
|
||||
slot.slot_id,
|
||||
SlotState.IDLE,
|
||||
SlotState.INACTIVE,
|
||||
SlotState.PENDING_BUY,
|
||||
reason="orb_breakout",
|
||||
actor="buy_decision_thread",
|
||||
@@ -86,19 +61,19 @@ class TestTransitHappyPath:
|
||||
assert updated.symbol == "005930"
|
||||
assert updated.qty == 10
|
||||
assert updated.strategy_id == "orb_v1"
|
||||
assert updated.portfolio_id == slot.portfolio_id # asset binding kept
|
||||
# log appended
|
||||
log = repo.transition_log_for(slot.slot_id)
|
||||
assert len(log) == 1
|
||||
assert log[0].from_state is SlotState.IDLE
|
||||
assert log[0].from_state is SlotState.INACTIVE
|
||||
assert log[0].to_state is SlotState.PENDING_BUY
|
||||
assert log[0].reason == "orb_breakout"
|
||||
assert log[0].actor == "buy_decision_thread"
|
||||
|
||||
def test_pending_buy_to_holding(self, service, repo, trading_day):
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
slot = _one_idle(repo, trading_day)
|
||||
def test_pending_buy_to_holding(self, service, repo):
|
||||
slot = _seed(repo)[0]
|
||||
service.transit_slot(
|
||||
slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="orb_breakout", actor="buy", symbol="005930",
|
||||
)
|
||||
service.transit_slot(
|
||||
@@ -112,135 +87,108 @@ class TestTransitHappyPath:
|
||||
# dcy-012: limit_price (pending order limit) is cleared on entering holding.
|
||||
assert cur.limit_price is None
|
||||
|
||||
def test_pending_buy_to_idle_rollback_preserves_symbol_until_recovery(
|
||||
self, service, repo, trading_day,
|
||||
):
|
||||
"""pending_buy -> idle (rejection rollback) is allowed by state machine.
|
||||
|
||||
Note: this rollback does NOT bump daily_symbol_entries (only closed→idle does).
|
||||
"""
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
slot = _one_idle(repo, trading_day)
|
||||
def test_buy_rollback_resets_occupancy_keeps_asset_identity(self, service, repo):
|
||||
"""pending_buy -> inactive (rejection rollback): position/order fields
|
||||
are cleared while the permanent slot keeps its asset binding."""
|
||||
slot = _seed(repo)[0]
|
||||
service.transit_slot(
|
||||
slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
reason="orb_breakout", actor="buy", symbol="005930",
|
||||
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="orb_breakout", actor="buy", symbol="005930", qty=10,
|
||||
allocated_capital=1_000_000, strategy_id="orb_v1",
|
||||
)
|
||||
service.transit_slot(
|
||||
slot.slot_id, SlotState.PENDING_BUY, SlotState.IDLE,
|
||||
rolled = service.transit_slot(
|
||||
slot.slot_id, SlotState.PENDING_BUY, SlotState.INACTIVE,
|
||||
reason="broker_reject:001", actor="order_exec",
|
||||
)
|
||||
assert repo.get_symbol_entry_count(trading_day, "005930") == 0
|
||||
assert rolled.status is SlotState.INACTIVE
|
||||
assert rolled.qty is None
|
||||
assert rolled.allocated_capital is None
|
||||
assert rolled.strategy_id is None
|
||||
assert rolled.limit_price is None
|
||||
assert rolled.broker_order_id is None
|
||||
# asset identity preserved (dcy-020/REQ-056 permanent slot)
|
||||
assert rolled.portfolio_id == slot.portfolio_id
|
||||
assert rolled.instance_key == INST
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# transit_slot — closed→idle recovery REMOVED (dcy-020 / REQ-057)
|
||||
# ----------------------------------------------------------------------------
|
||||
class TestClosedRecoveryRemoved:
|
||||
def test_closed_to_idle_is_rejected(self, service, repo, trading_day):
|
||||
"""dcy-020: closed→idle recovery is removed (idle/closed both = inactive)."""
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
slot = _one_idle(repo, trading_day)
|
||||
# drive to closed
|
||||
service.transit_slot(slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
reason="r", actor="a", symbol="005930")
|
||||
def test_sell_fill_returns_to_inactive_and_clears_position(self, service, repo):
|
||||
slot = _seed(repo)[0]
|
||||
service.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="r", actor="a", symbol="005930", qty=10)
|
||||
service.transit_slot(slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="r", actor="a", avg_price=70_000)
|
||||
service.transit_slot(slot.slot_id, SlotState.HOLDING, SlotState.PENDING_SELL,
|
||||
reason="r", actor="a")
|
||||
service.transit_slot(slot.slot_id, SlotState.PENDING_SELL, SlotState.CLOSED,
|
||||
reason="r", actor="a")
|
||||
with pytest.raises(SlotTransitionError):
|
||||
service.transit_slot(slot.slot_id, SlotState.CLOSED, SlotState.IDLE,
|
||||
reason="recovery", actor="a")
|
||||
|
||||
def test_reuse_via_closed_to_pending_buy_reallocation(
|
||||
self, service, repo, trading_day,
|
||||
):
|
||||
"""Asset-centric reuse: a closed slot is reactivated via closed→pending_buy
|
||||
(dcy-019 reallocation), not closed→idle recovery."""
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
slot = _one_idle(repo, trading_day)
|
||||
service.transit_slot(slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
reason="r", actor="a", symbol="005930")
|
||||
service.transit_slot(slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="r", actor="a", avg_price=70_000)
|
||||
service.transit_slot(slot.slot_id, SlotState.HOLDING, SlotState.PENDING_SELL,
|
||||
reason="r", actor="a")
|
||||
service.transit_slot(slot.slot_id, SlotState.PENDING_SELL, SlotState.CLOSED,
|
||||
reason="r", actor="a")
|
||||
realloc = service.transit_slot(
|
||||
slot.slot_id, SlotState.CLOSED, SlotState.PENDING_BUY,
|
||||
reason="reallocate", actor="a", symbol="000660",
|
||||
done = service.transit_slot(slot.slot_id, SlotState.PENDING_SELL,
|
||||
SlotState.INACTIVE, reason="sell_filled", actor="a")
|
||||
assert done.status is SlotState.INACTIVE
|
||||
assert done.qty is None and done.avg_price is None
|
||||
assert done.portfolio_id == slot.portfolio_id
|
||||
# the same asset slot can activate again (permanent reuse)
|
||||
again = service.transit_slot(
|
||||
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="re-entry", actor="a", symbol="005930",
|
||||
)
|
||||
assert realloc.status is SlotState.PENDING_BUY
|
||||
assert realloc.symbol == "000660" # clean-slate
|
||||
assert realloc.entry_count == 2 # cumulative
|
||||
assert again.status is SlotState.PENDING_BUY
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# transit_slot — rejections
|
||||
# ----------------------------------------------------------------------------
|
||||
class TestTransitRejections:
|
||||
def test_disallowed_transition_raises(self, service, repo, trading_day):
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
slot = _one_idle(repo, trading_day)
|
||||
def test_disallowed_transition_raises(self, service, repo):
|
||||
slot = _seed(repo)[0]
|
||||
with pytest.raises(SlotTransitionError):
|
||||
service.transit_slot(
|
||||
slot.slot_id, SlotState.IDLE, SlotState.HOLDING,
|
||||
slot.slot_id, SlotState.INACTIVE, SlotState.HOLDING,
|
||||
reason="x", actor="a",
|
||||
)
|
||||
|
||||
def test_state_mismatch_raises_when_current_state_drifted(
|
||||
self, service, repo, trading_day,
|
||||
):
|
||||
def test_state_mismatch_raises_when_current_state_drifted(self, service, repo):
|
||||
"""Race detection — if slot already advanced, transit_slot from old state fails."""
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
slot = _one_idle(repo, trading_day)
|
||||
slot = _seed(repo)[0]
|
||||
service.transit_slot(
|
||||
slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="r", actor="a",
|
||||
)
|
||||
with pytest.raises(SlotStateMismatchError):
|
||||
# caller still thinks status=idle but it's pending_buy
|
||||
# caller still thinks status=inactive but it's pending_buy
|
||||
service.transit_slot(
|
||||
slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="r", actor="a",
|
||||
)
|
||||
|
||||
def test_unknown_slot_raises(self, service):
|
||||
with pytest.raises(SlotNotFoundError):
|
||||
service.transit_slot(
|
||||
uuid4(), SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
uuid4(), SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="r", actor="a",
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# operator error → idle/holding (REQ-004)
|
||||
# operator error → inactive/holding (REQ-004)
|
||||
# ----------------------------------------------------------------------------
|
||||
class TestOperatorResolve:
|
||||
def test_error_to_idle_via_operator(self, service, repo, trading_day):
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
slot = _one_idle(repo, trading_day)
|
||||
def test_error_to_inactive_via_operator(self, service, repo):
|
||||
slot = _seed(repo)[0]
|
||||
# force into error
|
||||
service.transit_slot(
|
||||
slot.slot_id, SlotState.IDLE, SlotState.ERROR,
|
||||
slot.slot_id, SlotState.INACTIVE, SlotState.ERROR,
|
||||
reason="reconcile_mismatch", actor="system",
|
||||
)
|
||||
result = service.transit_slot(
|
||||
slot.slot_id, SlotState.ERROR, SlotState.IDLE,
|
||||
slot.slot_id, SlotState.ERROR, SlotState.INACTIVE,
|
||||
reason="broker confirmed no position", actor="operator:alice",
|
||||
)
|
||||
assert result.status is SlotState.IDLE
|
||||
assert result.status is SlotState.INACTIVE
|
||||
log = repo.transition_log_for(slot.slot_id)
|
||||
assert log[-1].actor == "operator:alice"
|
||||
assert "broker confirmed" in log[-1].reason
|
||||
|
||||
def test_error_to_pending_buy_rejected(self, service, repo, trading_day):
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
slot = _one_idle(repo, trading_day)
|
||||
def test_error_to_pending_buy_rejected(self, service, repo):
|
||||
slot = _seed(repo)[0]
|
||||
service.transit_slot(
|
||||
slot.slot_id, SlotState.IDLE, SlotState.ERROR,
|
||||
slot.slot_id, SlotState.INACTIVE, SlotState.ERROR,
|
||||
reason="r", actor="s",
|
||||
)
|
||||
with pytest.raises(SlotTransitionError):
|
||||
@@ -251,62 +199,77 @@ class TestOperatorResolve:
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# compute_slot_capacity (REQ-002 AC-6)
|
||||
# compute_slot_capacity (REQ-002 AC-6; dcy-020/REQ-059)
|
||||
# ----------------------------------------------------------------------------
|
||||
class TestCapacity:
|
||||
def test_all_idle(self, service, trading_day):
|
||||
service.preallocate_daily_slots(trading_day, 10)
|
||||
cap = service.compute_slot_capacity(trading_day, 10)
|
||||
assert cap.idle_count == 10
|
||||
def test_all_inactive_no_cap(self, service, repo):
|
||||
_seed(repo, 10)
|
||||
cap = service.compute_slot_capacity()
|
||||
assert cap.inactive_count == 10
|
||||
assert cap.holding_count == 0
|
||||
assert cap.pending_count == 0
|
||||
assert cap.active_count == 0
|
||||
assert cap.available_count == 10
|
||||
assert cap.total_slots == 10
|
||||
assert cap.scarcity_ratio == 0.0
|
||||
|
||||
def test_mixed_states(self, service, repo, trading_day):
|
||||
service.preallocate_daily_slots(trading_day, 10)
|
||||
def test_mixed_states_under_m_cap(self, service, repo):
|
||||
slots = _seed(repo, 10)
|
||||
# move 3 to pending_buy + 2 to holding
|
||||
slots = repo.list_slots(trading_day)
|
||||
for s in slots[:3]:
|
||||
service.transit_slot(
|
||||
s.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
s.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="r", actor="a",
|
||||
)
|
||||
for s in slots[3:5]:
|
||||
service.transit_slot(
|
||||
s.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
s.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="r", actor="a",
|
||||
)
|
||||
service.transit_slot(
|
||||
s.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="r", actor="a",
|
||||
)
|
||||
cap = service.compute_slot_capacity(trading_day, 10)
|
||||
assert cap.idle_count == 5
|
||||
cap = service.compute_slot_capacity(max_active_slots=8)
|
||||
assert cap.inactive_count == 5
|
||||
assert cap.pending_count == 3
|
||||
assert cap.holding_count == 2
|
||||
assert cap.scarcity_ratio == pytest.approx(0.5)
|
||||
assert cap.active_count == 5
|
||||
assert cap.available_count == 3 # M(8) − active(5)
|
||||
assert cap.total_slots == 8
|
||||
assert cap.scarcity_ratio == pytest.approx(1 - 3 / 8)
|
||||
|
||||
def test_zero_total_rejected(self, service, trading_day):
|
||||
with pytest.raises(ValueError):
|
||||
service.compute_slot_capacity(trading_day, 0)
|
||||
def test_over_cap_clamps_available_to_zero(self, service, repo):
|
||||
slots = _seed(repo, 3)
|
||||
for s in slots:
|
||||
service.transit_slot(s.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="r", actor="a")
|
||||
cap = service.compute_slot_capacity(max_active_slots=2)
|
||||
assert cap.active_count == 3
|
||||
assert cap.available_count == 0
|
||||
assert cap.scarcity_ratio == 1.0
|
||||
|
||||
def test_empty_instance(self, service):
|
||||
cap = service.compute_slot_capacity()
|
||||
assert cap.total_slots == 0
|
||||
assert cap.available_count == 0
|
||||
assert cap.scarcity_ratio == 0.0
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# advisory lock serialization (REQ-002 AC-5)
|
||||
# ----------------------------------------------------------------------------
|
||||
class TestAdvisoryLockSerialization:
|
||||
def test_concurrent_transitions_are_serialized(self, service, repo, trading_day):
|
||||
def test_concurrent_transitions_are_serialized(self, service, repo):
|
||||
"""Two threads competing for the same slot — one must observe state mismatch."""
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
slot = _one_idle(repo, trading_day)
|
||||
slot = _seed(repo)[0]
|
||||
|
||||
results: dict[str, Exception | None] = {}
|
||||
|
||||
def worker(name: str):
|
||||
try:
|
||||
service.transit_slot(
|
||||
slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason=f"r-{name}", actor=name,
|
||||
)
|
||||
results[name] = None
|
||||
@@ -327,21 +290,13 @@ class TestAdvisoryLockSerialization:
|
||||
assert len(failures) == 1
|
||||
assert isinstance(failures[0] and results[failures[0]], SlotStateMismatchError)
|
||||
|
||||
def test_lock_hold_time_under_50ms(self, service, trading_day):
|
||||
def test_lock_hold_time_under_50ms(self, service, repo):
|
||||
"""REQ-002 AC-5 numeric: transition under the in-memory repo finishes < 50 ms."""
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
slot = list(service.compute_slot_capacity.__self__._repo.list_slots(trading_day))[0] # type: ignore[attr-defined]
|
||||
slot = _seed(repo)[0]
|
||||
t0 = time.perf_counter()
|
||||
service.transit_slot(
|
||||
slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="r", actor="a",
|
||||
)
|
||||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||
assert elapsed_ms < 50.0, f"transition took {elapsed_ms:.2f} ms"
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# get_today_entry_count (REQ-001 + REQ-002 boundary)
|
||||
# ----------------------------------------------------------------------------
|
||||
def test_get_today_entry_count_for_unseen_symbol_is_zero(service, trading_day):
|
||||
assert service.get_today_entry_count(trading_day, "999999") == 0
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"""SlotService.sell() unit tests (REQ-036 / BAT-010)."""
|
||||
"""SlotService.sell() unit tests (REQ-036 / BAT-010; asset-centric BAT-030)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from trex_core.domain.slot import (
|
||||
@@ -13,10 +11,7 @@ from trex_core.domain.slot import (
|
||||
)
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def trading_day():
|
||||
return date(2026, 6, 7)
|
||||
INST = "default"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -26,15 +21,19 @@ def repo():
|
||||
|
||||
@pytest.fixture
|
||||
def service(repo):
|
||||
return SlotService(repo)
|
||||
return SlotService(repo, instance_key=INST)
|
||||
|
||||
|
||||
def _holding_slot(service, repo, trading_day):
|
||||
def _inactive_slot(repo):
|
||||
repo.reconcile_universe(INST, {"pf-1"})
|
||||
return repo.find_slot_by_portfolio(INST, "pf-1")
|
||||
|
||||
|
||||
def _holding_slot(service, repo):
|
||||
"""Create one slot in HOLDING state."""
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
idle = repo.find_idle_slot(trading_day)
|
||||
slot = _inactive_slot(repo)
|
||||
pending = service.transit_slot(
|
||||
idle.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="test", symbol="005930",
|
||||
)
|
||||
return service.transit_slot(
|
||||
@@ -44,16 +43,16 @@ def _holding_slot(service, repo, trading_day):
|
||||
|
||||
|
||||
class TestSell:
|
||||
def test_sell_holding_transitions_to_pending_sell(self, service, repo, trading_day):
|
||||
def test_sell_holding_transitions_to_pending_sell(self, service, repo):
|
||||
"""AC-1: sell() on a holding slot transitions it to pending_sell."""
|
||||
holding = _holding_slot(service, repo, trading_day)
|
||||
holding = _holding_slot(service, repo)
|
||||
service.sell(holding.slot_id, actor="safety-sell", reason="stop_loss")
|
||||
updated = repo.fetch_slot(holding.slot_id)
|
||||
assert updated.status is SlotState.PENDING_SELL
|
||||
|
||||
def test_sell_records_transition_log(self, service, repo, trading_day):
|
||||
def test_sell_records_transition_log(self, service, repo):
|
||||
"""sell() appends a transition log entry."""
|
||||
holding = _holding_slot(service, repo, trading_day)
|
||||
holding = _holding_slot(service, repo)
|
||||
service.sell(holding.slot_id, actor="safety-sell", reason="stop_loss")
|
||||
log = repo.transition_log_for(holding.slot_id)
|
||||
sell_entries = [e for e in log if e.to_state is SlotState.PENDING_SELL]
|
||||
@@ -61,9 +60,9 @@ class TestSell:
|
||||
assert sell_entries[0].actor == "safety-sell"
|
||||
assert sell_entries[0].reason == "stop_loss"
|
||||
|
||||
def test_sell_idempotent_on_pending_sell(self, service, repo, trading_day):
|
||||
def test_sell_idempotent_on_pending_sell(self, service, repo):
|
||||
"""AC-2: sell() on already-pending_sell slot returns silently, no second transition."""
|
||||
holding = _holding_slot(service, repo, trading_day)
|
||||
holding = _holding_slot(service, repo)
|
||||
service.sell(holding.slot_id, actor="safety-sell", reason="stop_loss")
|
||||
# Call again — must not raise and must not add another transition
|
||||
service.sell(holding.slot_id, actor="safety-sell", reason="stop_loss")
|
||||
@@ -71,16 +70,15 @@ class TestSell:
|
||||
sell_entries = [e for e in log if e.to_state is SlotState.PENDING_SELL]
|
||||
assert len(sell_entries) == 1 # exactly one, not two
|
||||
|
||||
def test_sell_idle_slot_raises_transition_error(self, service, repo, trading_day):
|
||||
"""AC-3: sell() on an idle slot raises SlotTransitionError."""
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
idle = repo.find_idle_slot(trading_day)
|
||||
def test_sell_inactive_slot_raises_transition_error(self, service, repo):
|
||||
"""AC-3: sell() on an inactive slot raises SlotTransitionError."""
|
||||
slot = _inactive_slot(repo)
|
||||
with pytest.raises(SlotTransitionError):
|
||||
service.sell(idle.slot_id, actor="safety-sell", reason="stop_loss")
|
||||
service.sell(slot.slot_id, actor="safety-sell", reason="stop_loss")
|
||||
# Slot state unchanged
|
||||
assert repo.fetch_slot(idle.slot_id).status is SlotState.IDLE
|
||||
assert repo.fetch_slot(slot.slot_id).status is SlotState.INACTIVE
|
||||
|
||||
def test_sell_unknown_slot_raises_not_found(self, service, trading_day):
|
||||
def test_sell_unknown_slot_raises_not_found(self, service):
|
||||
"""sell() with unknown slot_id raises SlotNotFoundError."""
|
||||
from uuid import uuid4
|
||||
with pytest.raises(SlotNotFoundError):
|
||||
@@ -88,13 +86,12 @@ class TestSell:
|
||||
|
||||
|
||||
class TestSellPreservesPriceFields:
|
||||
def test_sell_preserves_stop_loss_price(self, service, repo, trading_day):
|
||||
def test_sell_preserves_stop_loss_price(self, service, repo):
|
||||
"""Price threshold fields survive the sell transition."""
|
||||
from decimal import Decimal
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
idle = repo.find_idle_slot(trading_day)
|
||||
slot = _inactive_slot(repo)
|
||||
pending = service.transit_slot(
|
||||
idle.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="buy", actor="test", symbol="005930",
|
||||
stop_loss_price=Decimal("45000"),
|
||||
take_profit_price=Decimal("60000"),
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
"""dcy-019 / BAT-025: slot-scoped entry_count + closed-slot reallocation.
|
||||
|
||||
Covers REQ-002 (entry_count field + closed→pending_buy clean-slate transition)
|
||||
and REQ-006 (find_allocatable_slot selector ordering over idle ∪ closed).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from trex_core.domain.slot import SlotService, SlotState
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def trading_day():
|
||||
return date(2026, 6, 9)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo():
|
||||
return InMemorySlotRepository()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(repo):
|
||||
return SlotService(repo)
|
||||
|
||||
|
||||
def _cycle_to_closed(service, slot_id, symbol="005930"):
|
||||
"""Drive a slot idle→pending_buy→holding→pending_sell→closed."""
|
||||
service.transit_slot(slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
reason="r", actor="a", symbol=symbol)
|
||||
service.transit_slot(slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="r", actor="a", avg_price=70_000)
|
||||
service.transit_slot(slot_id, SlotState.HOLDING, SlotState.PENDING_SELL,
|
||||
reason="r", actor="a")
|
||||
service.transit_slot(slot_id, SlotState.PENDING_SELL, SlotState.CLOSED,
|
||||
reason="r", actor="a")
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# entry_count increment (REQ-002 FR-4)
|
||||
# ----------------------------------------------------------------------------
|
||||
class TestEntryCount:
|
||||
def test_new_slot_starts_at_zero(self, service, repo, trading_day):
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
slot = repo.find_idle_slot(trading_day)
|
||||
assert slot.entry_count == 0
|
||||
|
||||
def test_increments_on_each_pending_buy(self, service, repo, trading_day):
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
slot = repo.find_idle_slot(trading_day)
|
||||
updated = service.transit_slot(
|
||||
slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
reason="r", actor="a", symbol="005930",
|
||||
)
|
||||
assert updated.entry_count == 1
|
||||
|
||||
def test_not_incremented_on_non_buy_transitions(self, service, repo, trading_day):
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
slot = repo.find_idle_slot(trading_day)
|
||||
service.transit_slot(slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
reason="r", actor="a", symbol="005930")
|
||||
held = service.transit_slot(slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="r", actor="a")
|
||||
# holding entry does not bump the per-slot entry counter
|
||||
assert held.entry_count == 1
|
||||
|
||||
def test_preserved_while_closed(self, service, repo, trading_day):
|
||||
# dcy-020: closed→idle recovery removed; entry_count persists on the
|
||||
# closed slot until the next allocation (closed→pending_buy) bumps it.
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
slot = repo.find_idle_slot(trading_day)
|
||||
_cycle_to_closed(service, slot.slot_id)
|
||||
closed = repo.fetch_slot(slot.slot_id)
|
||||
assert closed.status is SlotState.CLOSED
|
||||
assert closed.entry_count == 1 # slot-scoped cumulative, preserved
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# closed → pending_buy direct reallocation (REQ-002 FR / state machine #9)
|
||||
# ----------------------------------------------------------------------------
|
||||
class TestClosedReallocation:
|
||||
def test_closed_to_pending_buy_clean_slate_and_bumps_count(
|
||||
self, service, repo, trading_day,
|
||||
):
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
slot = repo.find_idle_slot(trading_day)
|
||||
_cycle_to_closed(service, slot.slot_id, symbol="005930")
|
||||
closed = repo.fetch_slot(slot.slot_id)
|
||||
assert closed.status is SlotState.CLOSED
|
||||
assert closed.entry_count == 1
|
||||
|
||||
realloc = service.transit_slot(
|
||||
slot.slot_id, SlotState.CLOSED, SlotState.PENDING_BUY,
|
||||
reason="reallocate", actor="a",
|
||||
symbol="000660", qty=5, allocated_capital=500_000,
|
||||
limit_price=120_000, strategy_id="orb_v2",
|
||||
)
|
||||
# new entry's fields applied
|
||||
assert realloc.status is SlotState.PENDING_BUY
|
||||
assert realloc.symbol == "000660"
|
||||
assert realloc.qty == 5
|
||||
assert realloc.limit_price == 120_000
|
||||
assert realloc.strategy_id == "orb_v2"
|
||||
# entry_count cumulative across reallocation (1 → 2)
|
||||
assert realloc.entry_count == 2
|
||||
|
||||
def test_closed_to_pending_buy_clears_prior_position_when_not_supplied(
|
||||
self, service, repo, trading_day,
|
||||
):
|
||||
"""A reallocation that omits a field must not leak the prior cycle's value."""
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
slot = repo.find_idle_slot(trading_day)
|
||||
# first cycle sets stop_loss/take_profit, then closes
|
||||
service.transit_slot(slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||
reason="r", actor="a", symbol="005930",
|
||||
stop_loss_price=60_000, take_profit_price=80_000)
|
||||
service.transit_slot(slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="r", actor="a", avg_price=70_000)
|
||||
service.transit_slot(slot.slot_id, SlotState.HOLDING, SlotState.PENDING_SELL,
|
||||
reason="r", actor="a")
|
||||
service.transit_slot(slot.slot_id, SlotState.PENDING_SELL, SlotState.CLOSED,
|
||||
reason="r", actor="a")
|
||||
# reallocate WITHOUT thresholds → must be cleared, not inherited
|
||||
realloc = service.transit_slot(
|
||||
slot.slot_id, SlotState.CLOSED, SlotState.PENDING_BUY,
|
||||
reason="reallocate", actor="a", symbol="000660",
|
||||
)
|
||||
assert realloc.symbol == "000660"
|
||||
assert realloc.stop_loss_price is None
|
||||
assert realloc.take_profit_price is None
|
||||
assert realloc.avg_price is None
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# find_allocatable_slot selector ordering (REQ-006, OQ-2)
|
||||
# ----------------------------------------------------------------------------
|
||||
class TestAllocatableSelector:
|
||||
def test_returns_none_when_no_idle_or_closed(self, service, repo, trading_day):
|
||||
assert repo.find_allocatable_slot(trading_day) is None
|
||||
|
||||
def test_prefers_idle_when_only_idle_present(self, service, repo, trading_day):
|
||||
service.preallocate_daily_slots(trading_day, 3)
|
||||
cand = repo.find_allocatable_slot(trading_day)
|
||||
assert cand is not None and cand.status is SlotState.IDLE
|
||||
|
||||
def test_includes_closed_when_no_idle_remains(self, service, repo, trading_day):
|
||||
service.preallocate_daily_slots(trading_day, 1)
|
||||
slot = repo.find_idle_slot(trading_day)
|
||||
_cycle_to_closed(service, slot.slot_id)
|
||||
# no idle left, but the closed slot is allocatable
|
||||
cand = repo.find_allocatable_slot(trading_day)
|
||||
assert cand is not None and cand.status is SlotState.CLOSED
|
||||
|
||||
def test_orders_by_entry_count_ascending(self, service, repo, trading_day):
|
||||
"""A closed slot (entry_count=1) loses to a fresh idle slot (entry_count=0)."""
|
||||
service.preallocate_daily_slots(trading_day, 2)
|
||||
slots = repo.list_slots(trading_day)
|
||||
_cycle_to_closed(service, slots[0].slot_id) # this one now entry_count=1, closed
|
||||
# the still-idle slot has entry_count 0 and must be chosen first
|
||||
cand = repo.find_allocatable_slot(trading_day)
|
||||
assert cand.entry_count == 0
|
||||
assert cand.status is SlotState.IDLE
|
||||
@@ -1,7 +1,7 @@
|
||||
"""State machine unit tests — asset-centric redefinition (dcy-020 / REQ-057).
|
||||
"""State machine unit tests — asset-centric (dcy-020 / REQ-057, BAT-030).
|
||||
|
||||
`inactive` is the canonical no-position state; `idle`/`closed` are aliases that
|
||||
normalize to `inactive`. `closed->idle` recovery is removed. Covers REQ-057.
|
||||
`inactive` is the only no-position state. The pool-model `idle`/`closed`
|
||||
aliases were removed at the cutover (D2); migration 010 rewrites legacy rows.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -12,29 +12,28 @@ from trex_core.domain.slot.state import (
|
||||
SLOT_ADVISORY_LOCK_ID,
|
||||
SlotState,
|
||||
is_allowed_transition,
|
||||
normalize_state,
|
||||
slot_advisory_lock_id,
|
||||
)
|
||||
|
||||
|
||||
def test_slot_state_members():
|
||||
"""dcy-020: 5 canonical + 2 aliases(idle/closed) = 7 enum members."""
|
||||
"""dcy-020 (BAT-030): 5 canonical members — idle/closed aliases removed."""
|
||||
assert {s.value for s in SlotState} == {
|
||||
"inactive",
|
||||
"idle",
|
||||
"pending_buy",
|
||||
"holding",
|
||||
"pending_sell",
|
||||
"closed",
|
||||
"error",
|
||||
}
|
||||
|
||||
|
||||
def test_idle_and_closed_normalize_to_inactive():
|
||||
assert normalize_state(SlotState.IDLE) is SlotState.INACTIVE
|
||||
assert normalize_state(SlotState.CLOSED) is SlotState.INACTIVE
|
||||
assert normalize_state(SlotState.INACTIVE) is SlotState.INACTIVE
|
||||
assert normalize_state(SlotState.HOLDING) is SlotState.HOLDING
|
||||
def test_legacy_alias_values_rejected():
|
||||
"""`idle`/`closed` are no longer valid SlotState values (migration 010
|
||||
rewrites any remaining DB rows before this code reads them)."""
|
||||
with pytest.raises(ValueError):
|
||||
SlotState("idle")
|
||||
with pytest.raises(ValueError):
|
||||
SlotState("closed")
|
||||
|
||||
|
||||
def test_advisory_lock_id_is_stable_constant():
|
||||
@@ -56,27 +55,20 @@ def test_slot_advisory_lock_id_is_instance_scoped():
|
||||
@pytest.mark.parametrize(
|
||||
"from_state,to_state",
|
||||
[
|
||||
# canonical (inactive-based)
|
||||
# canonical lifecycle
|
||||
(SlotState.INACTIVE, SlotState.PENDING_BUY),
|
||||
(SlotState.PENDING_BUY, SlotState.HOLDING),
|
||||
(SlotState.PENDING_BUY, SlotState.INACTIVE),
|
||||
(SlotState.HOLDING, SlotState.PENDING_SELL),
|
||||
(SlotState.PENDING_SELL, SlotState.INACTIVE),
|
||||
(SlotState.PENDING_SELL, SlotState.HOLDING),
|
||||
# alias equivalence: idle/closed normalize to inactive
|
||||
(SlotState.IDLE, SlotState.PENDING_BUY), # idle->pending_buy
|
||||
(SlotState.CLOSED, SlotState.PENDING_BUY), # closed->pending_buy (reallocation)
|
||||
(SlotState.PENDING_SELL, SlotState.CLOSED), # sell filled (closed alias)
|
||||
(SlotState.PENDING_BUY, SlotState.IDLE), # buy rollback (idle alias)
|
||||
# to error (every non-error)
|
||||
(SlotState.INACTIVE, SlotState.ERROR),
|
||||
(SlotState.PENDING_BUY, SlotState.ERROR),
|
||||
(SlotState.HOLDING, SlotState.ERROR),
|
||||
(SlotState.PENDING_SELL, SlotState.ERROR),
|
||||
(SlotState.CLOSED, SlotState.ERROR),
|
||||
# operator resolve out of error
|
||||
(SlotState.ERROR, SlotState.INACTIVE),
|
||||
(SlotState.ERROR, SlotState.IDLE), # alias
|
||||
(SlotState.ERROR, SlotState.HOLDING),
|
||||
],
|
||||
)
|
||||
@@ -87,16 +79,14 @@ def test_allowed_transition(from_state: SlotState, to_state: SlotState):
|
||||
@pytest.mark.parametrize(
|
||||
"from_state,to_state",
|
||||
[
|
||||
# closed->idle recovery REMOVED (normalizes to inactive self-loop)
|
||||
(SlotState.CLOSED, SlotState.IDLE),
|
||||
# skipping intermediate states
|
||||
(SlotState.INACTIVE, SlotState.HOLDING),
|
||||
(SlotState.INACTIVE, SlotState.PENDING_SELL),
|
||||
(SlotState.HOLDING, SlotState.INACTIVE),
|
||||
(SlotState.HOLDING, SlotState.CLOSED),
|
||||
(SlotState.HOLDING, SlotState.PENDING_BUY),
|
||||
(SlotState.PENDING_BUY, SlotState.PENDING_SELL),
|
||||
(SlotState.PENDING_SELL, SlotState.PENDING_BUY),
|
||||
# error has restricted exits — only inactive(+aliases) and holding
|
||||
# error has restricted exits — only inactive and holding
|
||||
(SlotState.ERROR, SlotState.PENDING_BUY),
|
||||
(SlotState.ERROR, SlotState.PENDING_SELL),
|
||||
(SlotState.ERROR, SlotState.ERROR),
|
||||
@@ -107,14 +97,11 @@ def test_disallowed_transition(from_state: SlotState, to_state: SlotState):
|
||||
|
||||
|
||||
def test_self_loops_are_not_allowed():
|
||||
"""No state may transition to itself (including inactive and its aliases)."""
|
||||
"""No state may transition to itself."""
|
||||
for s in SlotState:
|
||||
assert not is_allowed_transition(s, s)
|
||||
# alias self-loops also rejected
|
||||
assert not is_allowed_transition(SlotState.IDLE, SlotState.CLOSED)
|
||||
assert not is_allowed_transition(SlotState.CLOSED, SlotState.IDLE)
|
||||
|
||||
|
||||
def test_allowed_transitions_set_size():
|
||||
"""6 base (inactive-canonical) + 4 to-error + 2 operator-resolve = 12."""
|
||||
"""6 base + 4 to-error + 2 operator-resolve = 12."""
|
||||
assert len(ALLOWED_TRANSITIONS) == 12
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
"""dcy-020 / REQ-058 (BAT-028): universe reconcile lifecycle (in-memory)."""
|
||||
"""dcy-020 / REQ-058 (BAT-028; BAT-030 trading_date-free): universe reconcile lifecycle (in-memory)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from trex_core.domain.slot import SlotService, SlotState
|
||||
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||
|
||||
DAY = date(2026, 6, 10)
|
||||
INST = "inst-1"
|
||||
|
||||
|
||||
@@ -18,17 +15,17 @@ def repo():
|
||||
|
||||
|
||||
def test_creates_asset_slots_idempotently(repo):
|
||||
r1 = repo.reconcile_universe(INST, DAY, {"pf-a", "pf-b"})
|
||||
r1 = repo.reconcile_universe(INST, {"pf-a", "pf-b"})
|
||||
assert r1["created"] == 2
|
||||
# idempotent — second pass creates nothing
|
||||
r2 = repo.reconcile_universe(INST, DAY, {"pf-a", "pf-b"})
|
||||
r2 = repo.reconcile_universe(INST, {"pf-a", "pf-b"})
|
||||
assert r2["created"] == 0
|
||||
assert repo.find_slot_by_portfolio(INST, "pf-a").status is SlotState.IDLE
|
||||
assert repo.find_slot_by_portfolio(INST, "pf-a").status is SlotState.INACTIVE
|
||||
|
||||
|
||||
def test_soft_retire_inactive_asset_that_left_universe(repo):
|
||||
repo.reconcile_universe(INST, DAY, {"pf-a", "pf-b"})
|
||||
r = repo.reconcile_universe(INST, DAY, {"pf-a"}) # pf-b left
|
||||
repo.reconcile_universe(INST, {"pf-a", "pf-b"})
|
||||
r = repo.reconcile_universe(INST, {"pf-a"}) # pf-b left
|
||||
assert r["retired"] == 1
|
||||
slot_b = repo.find_slot_by_portfolio(INST, "pf-b")
|
||||
assert slot_b.retired_at is not None # soft-retired, row preserved
|
||||
@@ -36,28 +33,28 @@ def test_soft_retire_inactive_asset_that_left_universe(repo):
|
||||
|
||||
def test_tombstone_holding_asset_that_left_universe(repo):
|
||||
svc = SlotService(repo, instance_key=INST)
|
||||
repo.reconcile_universe(INST, DAY, {"pf-a"})
|
||||
repo.reconcile_universe(INST, {"pf-a"})
|
||||
slot = repo.find_slot_by_portfolio(INST, "pf-a")
|
||||
svc.transit_slot(slot.slot_id, slot.status, SlotState.PENDING_BUY,
|
||||
reason="r", actor="a", symbol="005930")
|
||||
svc.transit_slot(slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="r", actor="a", avg_price=70_000)
|
||||
r = repo.reconcile_universe(INST, DAY, set()) # pf-a left while holding
|
||||
r = repo.reconcile_universe(INST, set()) # pf-a left while holding
|
||||
assert r["tombstoned"] == 1 and r["retired"] == 0
|
||||
held = repo.find_slot_by_portfolio(INST, "pf-a")
|
||||
assert held.status is SlotState.HOLDING and held.retired_at is None # protected
|
||||
|
||||
|
||||
def test_allow_retire_false_skips_retire(repo):
|
||||
repo.reconcile_universe(INST, DAY, {"pf-a", "pf-b"})
|
||||
r = repo.reconcile_universe(INST, DAY, {"pf-a"}, allow_retire=False)
|
||||
repo.reconcile_universe(INST, {"pf-a", "pf-b"})
|
||||
r = repo.reconcile_universe(INST, {"pf-a"}, allow_retire=False)
|
||||
assert r["retired"] == 0
|
||||
assert repo.find_slot_by_portfolio(INST, "pf-b").retired_at is None
|
||||
|
||||
|
||||
def test_count_active(repo):
|
||||
svc = SlotService(repo, instance_key=INST)
|
||||
repo.reconcile_universe(INST, DAY, {"pf-a", "pf-b"})
|
||||
repo.reconcile_universe(INST, {"pf-a", "pf-b"})
|
||||
assert repo.count_active(INST) == 0
|
||||
slot = repo.find_slot_by_portfolio(INST, "pf-a")
|
||||
svc.transit_slot(slot.slot_id, slot.status, SlotState.PENDING_BUY,
|
||||
@@ -66,8 +63,8 @@ def test_count_active(repo):
|
||||
|
||||
|
||||
def test_instance_isolation(repo):
|
||||
repo.reconcile_universe("inst-1", DAY, {"pf-a"})
|
||||
repo.reconcile_universe("inst-2", DAY, {"pf-a"})
|
||||
repo.reconcile_universe("inst-1", {"pf-a"})
|
||||
repo.reconcile_universe("inst-2", {"pf-a"})
|
||||
# same portfolio_id, different instances -> two distinct slots
|
||||
assert repo.find_slot_by_portfolio("inst-1", "pf-a").slot_id != \
|
||||
repo.find_slot_by_portfolio("inst-2", "pf-a").slot_id
|
||||
|
||||
@@ -32,11 +32,11 @@ _SNAPSHOT = {
|
||||
# BAT-022: envelope per-slot state (operator-facing). `slots` queries read
|
||||
# this, not the universe `assets[]`.
|
||||
"slots": [
|
||||
{"slot_id": "S-1", "trading_date": "2026-06-08", "status": "holding",
|
||||
{"slot_id": "S-1", "status": "holding",
|
||||
"symbol": "005930", "qty": 10.0, "avg_price": 28700.0, "entry_price": None,
|
||||
"allocated_capital": None, "strategy_id": "orb", "sell_strategy_id": None,
|
||||
"portfolio_id": "PF-1", "forced_close_at": None},
|
||||
{"slot_id": "S-2", "trading_date": "2026-06-08", "status": "idle",
|
||||
{"slot_id": "S-2", "status": "inactive",
|
||||
"symbol": None, "qty": None, "avg_price": None, "entry_price": None,
|
||||
"allocated_capital": None, "strategy_id": None, "sell_strategy_id": None,
|
||||
"portfolio_id": None, "forced_close_at": None},
|
||||
@@ -81,7 +81,7 @@ def test_slots_shows_real_slot_state_not_universe(capsys):
|
||||
assert rpc.calls[0]["payload"]["touch_watchdog"] is False
|
||||
# both slots shown, with status column (slot state, not universe assets)
|
||||
assert "count=2" in out
|
||||
assert "S-1\tholding" in out and "S-2\tidle" in out
|
||||
assert "S-1\tholding" in out and "S-2\tinactive" in out
|
||||
|
||||
|
||||
def test_slots_status_filters_by_slot_status(capsys):
|
||||
@@ -97,7 +97,7 @@ def test_slots_marks_watchdog_owned(capsys):
|
||||
resp = {
|
||||
"assets": [], "view": "all", "available_slots": 9, "degraded": False,
|
||||
"reason": None,
|
||||
"slots": [{"slot_id": "S-1", "trading_date": "2026-06-08",
|
||||
"slots": [{"slot_id": "S-1",
|
||||
"status": "holding", "symbol": "005930", "qty": 10.0,
|
||||
"avg_price": 28700.0, "entry_price": None,
|
||||
"allocated_capital": None, "strategy_id": "orb",
|
||||
|
||||
@@ -6,8 +6,8 @@ import pytest
|
||||
from trex_core.runtime.config import DaemonConfig
|
||||
|
||||
_KEYS = (
|
||||
"TREX_POSTGRESQL_DSN", "TREX_TOTAL_SLOTS", "TREX_API_HOST", "TREX_API_PORT",
|
||||
"TREX_PAPER_FILL_AFTER_POLLS", "TREX_PAPER_QTY_PER_POLL",
|
||||
"TREX_POSTGRESQL_DSN", "TREX_EXECUTION_INTERVAL_SEC", "TREX_API_HOST",
|
||||
"TREX_API_PORT", "TREX_PAPER_FILL_AFTER_POLLS", "TREX_PAPER_QTY_PER_POLL",
|
||||
"TREX_PAPER_REJECT_SYMBOLS",
|
||||
)
|
||||
|
||||
@@ -21,12 +21,12 @@ def _clean_env(monkeypatch):
|
||||
def test_loads_values_from_dotenv(tmp_path, monkeypatch):
|
||||
"""AC-1: .env in CWD is loaded when the OS env var is unset."""
|
||||
(tmp_path / ".env").write_text(
|
||||
"TREX_POSTGRESQL_DSN=dbname=from_dotenv\nTREX_TOTAL_SLOTS=7\n"
|
||||
"TREX_POSTGRESQL_DSN=dbname=from_dotenv\nTREX_EXECUTION_INTERVAL_SEC=7.0\n"
|
||||
)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
cfg = DaemonConfig.from_env()
|
||||
assert cfg.dsn == "dbname=from_dotenv"
|
||||
assert cfg.total_slots == 7
|
||||
assert cfg.execution_interval_sec == 7.0
|
||||
|
||||
|
||||
def test_os_env_takes_precedence_over_dotenv(tmp_path, monkeypatch):
|
||||
@@ -44,7 +44,7 @@ def test_no_dotenv_uses_os_env_only(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("TREX_POSTGRESQL_DSN", "dbname=os_only")
|
||||
cfg = DaemonConfig.from_env()
|
||||
assert cfg.dsn == "dbname=os_only"
|
||||
assert cfg.total_slots == 10 # default
|
||||
assert cfg.execution_interval_sec == 1.0 # default
|
||||
|
||||
|
||||
def test_missing_dsn_raises(tmp_path, monkeypatch):
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Cutover rehearsal tests (dcy-020 / REQ-064, BAT-030 M4).
|
||||
|
||||
PostgreSQL-gated (TREX_POSTGRESQL_TEST_DSN) — the rehearsal the design
|
||||
requires before the operator runs the live cutover: full rebuild, broker
|
||||
holding carry-over, in-flight abort, unmapped abort/skip, dry-run rollback.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from trex_core.adapters.persistence.postgresql import PostgreSQLSlotRepository
|
||||
from trex_core.domain.slot import SlotService, SlotState
|
||||
from trex_core.runtime.cutover import CutoverAbort, run_cutover
|
||||
|
||||
INST = "default"
|
||||
UNIVERSE = [("pf-a", "005930"), ("pf-b", "000660"), ("pf-c", "035720")]
|
||||
|
||||
|
||||
def _legacy_rows(dsn):
|
||||
"""Seed pre-cutover state: an old pool-ish slot + a holding slot."""
|
||||
repo = PostgreSQLSlotRepository(dsn)
|
||||
svc = SlotService(repo, instance_key=INST)
|
||||
repo.reconcile_universe(INST, {"pf-old", "pf-b"})
|
||||
held = repo.find_slot_by_portfolio(INST, "pf-b")
|
||||
svc.transit_slot(held.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="r", actor="t", symbol="000660", qty=7.0)
|
||||
svc.transit_slot(held.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||
reason="r", actor="t", avg_price=100.0)
|
||||
return repo, svc
|
||||
|
||||
|
||||
def test_full_rebuild_with_broker_carry_over(pg_clean):
|
||||
repo, _ = _legacy_rows(pg_clean)
|
||||
report = run_cutover(
|
||||
pg_clean, INST, UNIVERSE, [("000660", 12)], # broker ground truth qty=12
|
||||
)
|
||||
assert report.deleted == 2 # pf-old + pf-b wiped
|
||||
assert report.created == 3 # one slot per universe asset
|
||||
assert report.holdings == 1
|
||||
slots = {s.portfolio_id: s for s in repo.list_slots(INST)}
|
||||
assert set(slots) == {"pf-a", "pf-b", "pf-c"}
|
||||
assert slots["pf-a"].status is SlotState.INACTIVE
|
||||
held = slots["pf-b"]
|
||||
assert held.status is SlotState.HOLDING
|
||||
assert held.qty == 12.0 # broker qty, NOT the old mirror (7)
|
||||
assert held.avg_price is None # balance API has no cost basis (WARN)
|
||||
assert held.symbol == "000660"
|
||||
# old pf-old row is gone
|
||||
assert repo.find_slot_by_portfolio(INST, "pf-old") is None
|
||||
|
||||
|
||||
def test_aborts_on_in_flight_pending(pg_clean):
|
||||
repo = PostgreSQLSlotRepository(pg_clean)
|
||||
svc = SlotService(repo, instance_key=INST)
|
||||
repo.reconcile_universe(INST, {"pf-a"})
|
||||
s = repo.find_slot_by_portfolio(INST, "pf-a")
|
||||
svc.transit_slot(s.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
|
||||
reason="r", actor="t", symbol="005930")
|
||||
with pytest.raises(CutoverAbort, match="in-flight"):
|
||||
run_cutover(pg_clean, INST, UNIVERSE, [])
|
||||
# nothing was changed
|
||||
assert repo.fetch_slot(s.slot_id).status is SlotState.PENDING_BUY
|
||||
|
||||
|
||||
def test_aborts_on_unmapped_broker_holding(pg_clean):
|
||||
with pytest.raises(CutoverAbort, match="outside the universe"):
|
||||
run_cutover(pg_clean, INST, UNIVERSE, [("999999", 3)])
|
||||
# DB untouched (abort happened before any write)
|
||||
repo = PostgreSQLSlotRepository(pg_clean)
|
||||
assert repo.list_slots(INST) == []
|
||||
|
||||
|
||||
def test_allow_unmapped_skips_with_warning(pg_clean):
|
||||
report = run_cutover(
|
||||
pg_clean, INST, UNIVERSE, [("999999", 3), ("005930", 5)],
|
||||
allow_unmapped=True,
|
||||
)
|
||||
assert report.unmapped == ["999999"]
|
||||
assert report.holdings == 1 # only the mapped one carried over
|
||||
repo = PostgreSQLSlotRepository(pg_clean)
|
||||
held = repo.find_slot_by_portfolio(INST, "pf-a")
|
||||
assert held.status is SlotState.HOLDING and held.qty == 5.0
|
||||
|
||||
|
||||
def test_dry_run_rolls_back(pg_clean):
|
||||
repo, _ = _legacy_rows(pg_clean)
|
||||
before = {s.slot_id for s in repo.list_slots(INST)}
|
||||
report = run_cutover(pg_clean, INST, UNIVERSE, [("000660", 12)], dry_run=True)
|
||||
assert report.dry_run and report.created == 3
|
||||
after = {s.slot_id for s in repo.list_slots(INST)}
|
||||
assert after == before # rolled back — nothing changed
|
||||
|
||||
|
||||
def test_instance_isolation_other_instance_untouched(pg_clean):
|
||||
other = PostgreSQLSlotRepository(pg_clean)
|
||||
other.reconcile_universe("inst-other", {"pf-z"})
|
||||
run_cutover(pg_clean, INST, UNIVERSE, [])
|
||||
assert other.find_slot_by_portfolio("inst-other", "pf-z") is not None
|
||||
|
||||
|
||||
def test_promote_unique_sets_not_null(pg_clean):
|
||||
import psycopg
|
||||
run_cutover(pg_clean, INST, UNIVERSE, [], promote_unique=True)
|
||||
with psycopg.connect(pg_clean) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT is_nullable FROM information_schema.columns "
|
||||
"WHERE table_name='daily_slot' AND column_name='portfolio_id'"
|
||||
)
|
||||
assert cur.fetchone()[0] == "NO"
|
||||
# restore for other tests (pg_clean truncates but keeps schema)
|
||||
cur.execute("ALTER TABLE daily_slot ALTER COLUMN portfolio_id DROP NOT NULL")
|
||||
conn.commit()
|
||||
|
||||
|
||||
def test_audit_rows_written(pg_clean):
|
||||
import psycopg
|
||||
run_cutover(pg_clean, INST, UNIVERSE, [("005930", 5)])
|
||||
with psycopg.connect(pg_clean) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT count(*) FROM slot_transition_log WHERE reason='cutover_rebuild'"
|
||||
)
|
||||
assert int(cur.fetchone()[0]) == 3
|
||||
@@ -52,11 +52,11 @@ def test_default_instance_key_when_from_env(monkeypatch):
|
||||
|
||||
|
||||
def test_trading_timezone_defaults_to_kst(tmp_path, monkeypatch):
|
||||
"""dcy-014 / REQ-048: trading TZ + daily trigger default to KST / midnight."""
|
||||
"""dcy-014 / REQ-048: trading TZ defaults to KST (preallocation trigger
|
||||
removed at BAT-030 with the pool model)."""
|
||||
monkeypatch.setenv("TREX_POSTGRESQL_DSN", "postgresql://x/y")
|
||||
inst = DaemonInstanceConfig.from_toml(_write_toml(tmp_path, 'instance_key = "i1"\n'))
|
||||
assert inst.trading_timezone == "Asia/Seoul"
|
||||
assert inst.daily_preallocation_time == "00:00"
|
||||
assert DaemonConfig.from_env().trading_timezone == "Asia/Seoul"
|
||||
|
||||
|
||||
@@ -65,11 +65,10 @@ def test_trading_timezone_from_toml_propagates(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("TREX_POSTGRESQL_DSN", "postgresql://x/y")
|
||||
path = _write_toml(
|
||||
tmp_path,
|
||||
'instance_key = "i2"\ntrading_timezone = "UTC"\ndaily_preallocation_time = "07:30"\n',
|
||||
'instance_key = "i2"\ntrading_timezone = "UTC"\n',
|
||||
)
|
||||
cfg = DaemonConfig.from_config(path)
|
||||
assert cfg.trading_timezone == "UTC"
|
||||
assert cfg.daily_preallocation_time == "07:30"
|
||||
|
||||
|
||||
def test_config_required_cli_exits_nonzero():
|
||||
|
||||
Reference in New Issue
Block a user