From 7d727824dcc5a047eb926484380e890995995ca3 Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Wed, 10 Jun 2026 15:16:15 +0900 Subject: [PATCH] =?UTF-8?q?feat(slot):=20BAT-030=20=E2=80=94=20=EC=9E=90?= =?UTF-8?q?=EC=82=B0=20=EC=A4=91=EC=8B=AC=20cutover=20=EC=99=84=EC=84=B1?= =?UTF-8?q?=20(dcy-020=20BAT-=CE=B4,=20REQ-056=C2=B7057=C2=B7062=C2=B7064?= =?UTF-8?q?=C2=B7065)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude/skills/run-trex-core-daemon/SKILL.md | 77 +++--- .claude/skills/run-trex-core-daemon/smoke.py | 225 +++++---------- config/instance.example.toml | 10 +- .../implementation.md | 49 +++- .../index.md | 2 +- docs/batches/index.md | 2 +- docs/data-model.md | 54 ++-- docs/interface-contract.md | 61 +++-- docs/project-structure.md | 29 +- docs/runtime-flows.md | 65 ++--- pyproject.toml | 1 + .../messaging/test_rabbitmq_intent_e2e.py | 6 +- tests/adapters/persistence/test_ops_checks.py | 55 +++- .../persistence/test_postgresql_repository.py | 110 +++++--- .../test_asset_centric_admission.py | 58 ++-- .../test_buy_admission_reallocation.py | 85 ------ tests/application/test_execution.py | 105 +++++-- tests/application/test_execution_cancel.py | 17 +- tests/application/test_execution_ledger.py | 28 +- tests/application/test_holding_divergence.py | 5 +- tests/application/test_intent_concurrency.py | 67 +++-- .../application/test_killswitch_execution.py | 17 +- .../test_messaging_event_publisher.py | 19 +- .../test_messaging_intent_consumer.py | 46 ++-- .../test_messaging_snapshot_consumer.py | 29 +- .../test_preallocation_scheduler.py | 109 -------- .../test_safety_sell_visibility.py | 15 +- tests/application/test_safety_sell_worker.py | 14 +- tests/application/test_sell_pricing.py | 16 +- tests/application/test_snapshot_handler.py | 6 +- .../application/test_snapshot_rpc_watchdog.py | 23 +- tests/application/test_snapshot_selection.py | 9 +- .../test_update_thresholds_handler.py | 22 +- .../slot/test_price_threshold_fields.py | 51 ++-- tests/domain/slot/test_service.py | 257 ++++++++---------- tests/domain/slot/test_service_sell.py | 55 ++-- tests/domain/slot/test_slot_entry_count.py | 166 ----------- tests/domain/slot/test_state.py | 45 ++- tests/domain/slot/test_universe_reconcile.py | 29 +- tests/runtime/test_cli_operator.py | 8 +- tests/runtime/test_config_dotenv.py | 10 +- tests/runtime/test_cutover_rehearsal.py | 124 +++++++++ tests/runtime/test_instance_config.py | 7 +- trex_core/adapters/persistence/ops_checks.py | 30 +- trex_core/adapters/persistence/postgresql.py | 175 ++++-------- .../sql/001_create_slot_core_tables.sql | 7 +- .../sql/008_add_slot_entry_count.sql | 13 +- .../sql/010_literal_inactive_status.sql | 11 + .../sql/011_drop_pool_model_columns.sql | 9 + trex_core/application/execution.py | 51 +++- trex_core/application/intents.py | 109 ++------ trex_core/application/killswitch.py | 8 +- trex_core/application/messaging_handlers.py | 44 +-- trex_core/application/portfolio.py | 9 +- .../application/preallocation_scheduler.py | 72 ----- trex_core/application/safety_sell.py | 11 +- trex_core/application/snapshot_scheduler.py | 17 +- trex_core/domain/slot/__init__.py | 6 +- trex_core/domain/slot/in_memory_repository.py | 97 ++----- trex_core/domain/slot/models.py | 47 ++-- trex_core/domain/slot/repository.py | 57 +--- trex_core/domain/slot/service.py | 129 +++------ trex_core/domain/slot/state.py | 48 +--- trex_core/runtime/cli.py | 6 +- trex_core/runtime/config.py | 25 +- trex_core/runtime/cutover.py | 235 ++++++++++++++++ trex_core/runtime/daemon.py | 42 +-- 67 files changed, 1584 insertions(+), 1862 deletions(-) delete mode 100644 tests/application/test_buy_admission_reallocation.py delete mode 100644 tests/application/test_preallocation_scheduler.py delete mode 100644 tests/domain/slot/test_slot_entry_count.py create mode 100644 tests/runtime/test_cutover_rehearsal.py create mode 100644 trex_core/adapters/persistence/sql/010_literal_inactive_status.sql create mode 100644 trex_core/adapters/persistence/sql/011_drop_pool_model_columns.sql delete mode 100644 trex_core/application/preallocation_scheduler.py create mode 100644 trex_core/runtime/cutover.py diff --git a/.claude/skills/run-trex-core-daemon/SKILL.md b/.claude/skills/run-trex-core-daemon/SKILL.md index 111b48c..f92ff5b 100644 --- a/.claude/skills/run-trex-core-daemon/SKILL.md +++ b/.claude/skills/run-trex-core-daemon/SKILL.md @@ -19,7 +19,7 @@ The daemon needs three live services. Verify before launching: .venv/bin/python - <<'PY' import os, psycopg, pika from dotenv import load_dotenv -load_dotenv(".env") +load_dotenv("config/.env") # BAT-026: .env lives in the config dir psycopg.connect(os.environ["TREX_POSTGRESQL_DSN"], connect_timeout=3).close() print("postgres OK") p = pika.URLParameters(os.environ["TREX_RABBITMQ_URL"]); p.socket_timeout = 3 @@ -32,28 +32,34 @@ If either fails, fix the infra first — the daemon will not boot without `TREX_POSTGRESQL_DSN` and will boot silently degraded if `TREX_RABBITMQ_URL` is missing (stub `InMemoryTransport` — strategy modules can't reach it). -### Required env (`.env`, gitignored) +### Required env (`config/.env`, gitignored) + per-instance TOML | Variable | Required | Notes | |---|---|---| | `TREX_POSTGRESQL_DSN` | yes | psycopg DSN | | `TREX_RABBITMQ_URL` | yes for real use | e.g. `amqp://admin:admin@localhost:5672/` | | `TREX_BROKER` | no | `paper` (default) or `kis` | -| `TREX_TOTAL_SLOTS` | no | default 10 | | `TREX_EXECUTION_INTERVAL_SEC` | no | default 1.0 | | `TREX_LEDGER_INTERVAL_SEC` | no | default 1.0 | -| `TREX_PORTFOLIO_HUB_BASE_URL` | no | if set, real portfolio-hub ledger; else in-memory stub | +| `TREX_PORTFOLIO_HUB_BASE_URL` | no | if set, real portfolio-hub universe+ledger; else stub universe (pf-005930/pf-000660) + in-memory ledger | -The daemon preallocates slots for **UTC today** (`datetime.now(tz=timezone.utc).date()`). -KST users near midnight: check `date -u` before driving the daemon — the -trading_date you query must match what was preallocated. +`--config ` (required since BAT-013) resolves `config/.toml`: +`instance_key` (queue names: `trex.intent.req.` etc.; `default` keeps +the literal names) and `max_active_slots` (M cap; unset = no cap). + +**BAT-030 (asset-centric)**: there is NO daily preallocation. Permanent asset +slots (one per universe portfolio, status `inactive`) are created by the +snapshot scheduler's universe reconcile right after boot. Slot states are +`inactive / pending_buy / holding / pending_sell / error` — `idle`/`closed` +no longer exist. ## Launch -Background, redirect log: +Background, redirect log (smoke against the test DB: point +TREX_POSTGRESQL_DSN at `trex_core_test` via `--env-file`, never the live DB): ```bash -.venv/bin/trex-core-daemon &> /tmp/trex-core.log & +.venv/bin/trex-core-daemon --config &> /tmp/trex-core.log & DAEMON_PID=$! ``` @@ -92,35 +98,40 @@ worked. The canonical end-to-end smoke is in `smoke.py` — copy/adapt it. It exercises: -1. **snapshot RPC** (view=all) — confirms the daemon answers. -2. **buy intent RPC** with a fresh `correlation_id` — admitted, slot → - `pending_buy`. -3. **duplicate buy** with the *same* `correlation_id` — identical response, +1. **snapshot RPC** (view=all) — daemon answers; `slots[]` holds the permanent + asset slots from the universe reconcile (status `inactive`). +2. **buy intent RPC keyed by `slot_id`** (REQ-061 canonical; `portfolio_id` + dual-accept) — admitted, slot → `pending_buy`. Rejections: `unknown_slot`, + `already_active`, `capacity_full` (M cap). +3. **duplicate buy** with the *same* `intent_id` — identical response, no double transition (intent_log dedup gate, REQ-023 AC-1). -4. **wait one execution loop tick** — paper broker fills, slot → `holding`, - `slot_filled_buy.{strategy_id}.{symbol}` event published. -5. **sell intent RPC** on the holding slot — slot → `closed`, - `slot_filled_sell.{strategy_id}.{symbol}` event published. +4. **wait one execution loop tick** — paper broker fills, slot → `holding`. +5. **sell intent RPC** on the holding slot — fill returns the slot to + `inactive` (occupancy cleared, asset binding kept — NOT `closed`). + Sell dispatch quantity is the **broker balance** (REQ-062); broker flat → + the sell is skipped with a WARN. -Run it: +Run it (instance key optional, default `default`): ```bash -.venv/bin/python .claude/skills/run-trex-core-daemon/smoke.py +.venv/bin/python .claude/skills/run-trex-core-daemon/smoke.py [instance_key] ``` -Sample output (paper broker, `strategy_id=STRAT-A`): +Sample output (paper broker, stub universe): ``` === 1) snapshot RPC === -slots=10 capacity={idle=10, holding=0, pending=0} -=== 2) buy intent === -admitted, slot_id=b2d60fa3-… -=== 3) duplicate buy === +assets=1 slots=2 available_slots=1 +target slot_id=8a160254-… portfolio_id=pf-005930 +=== 2) buy by slot_id === +-> admitted reason=None +=== 3) duplicate buy (same intent_id) === identical: True === 4) wait for fill === -slot status=holding qty=10 avg_price=60000.0 +slot status=holding qty=10.0 avg_price=1000.0 === 5) sell intent === -admitted; event rk=slot_filled_sell.STRAT-A.005930 status=closed qty=10 +-> admitted +slot status=inactive qty=None portfolio_id=pf-005930 (asset binding kept) DONE ``` @@ -153,12 +164,12 @@ The daemon's source of truth is PostgreSQL `daily_slot`: .venv/bin/python - <<'PY' import os, psycopg from dotenv import load_dotenv -load_dotenv(".env") +load_dotenv("config/.env") with psycopg.connect(os.environ["TREX_POSTGRESQL_DSN"]) as c: with c.cursor() as cur: cur.execute(""" - SELECT trading_date, status, COUNT(*) - FROM daily_slot GROUP BY trading_date, status ORDER BY trading_date DESC, status + SELECT instance_key, status, COUNT(*) + FROM daily_slot GROUP BY instance_key, status ORDER BY 1, 2 """) for r in cur.fetchall(): print(r) PY @@ -170,7 +181,7 @@ Ledger outbox state (BAT-006): .venv/bin/python - <<'PY' import os, psycopg from dotenv import load_dotenv -load_dotenv(".env") +load_dotenv("config/.env") with psycopg.connect(os.environ["TREX_POSTGRESQL_DSN"]) as c: with c.cursor() as cur: cur.execute("SELECT status, last_outcome, COUNT(*) FROM ledger_outbox GROUP BY 1,2 ORDER BY 1,2") @@ -182,8 +193,10 @@ PY | Symptom | Cause | Fix | |---|---|---| -| `slots returned: 0` from snapshot | querying wrong `trading_date` | use UTC today, see `date -u` | -| intent gets `rejected: no_idle_slot` | no preallocated slots for that day OR all 10 in flight | check `daily_slot` rows; restart daemon to repreallocate next UTC day | +| `slots returned: 0` from snapshot | universe reconcile hasn't run (portfolio-hub down / boot race) | check the scheduler log line `slot reconcile: {'created': N, ...}`; with no hub config the stub universe gives 2 slots | +| intent gets `rejected: unknown_slot` / `unknown_portfolio` | slot_id/portfolio_id not in this instance's slot set | read `slots[]` from a snapshot RPC first and reuse its `slot_id` | +| intent gets `rejected: capacity_full` | `max_active_slots` (M) reached — FCFS, no eviction | sell something first or raise M in the instance TOML | +| sell never dispatches, WARN `sell dispatch skipped` | broker balance 0 for the symbol (REQ-062 divergence) | reconcile the divergence (operator); no mirror fallback by design | | `RpcTimeoutError` on the client | daemon not up, or consumer thread crashed | check `/tmp/trex-core.log` for exception trace | | `TransportUnavailableError` | RabbitMQ down | the daemon does NOT buffer locally (REQ-024) — fix the broker, then re-issue | | fill event missed by ad-hoc subscriber | queue bound *after* publish | bind queue + sleep ≥0.5s before triggering the intent | diff --git a/.claude/skills/run-trex-core-daemon/smoke.py b/.claude/skills/run-trex-core-daemon/smoke.py index b4a9ac1..afc19c8 100644 --- a/.claude/skills/run-trex-core-daemon/smoke.py +++ b/.claude/skills/run-trex-core-daemon/smoke.py @@ -1,186 +1,107 @@ #!/usr/bin/env python3 -"""End-to-end smoke for trex-core (BAT-007). +"""End-to-end smoke for trex-core (BAT-030 asset-centric). -Assumes the daemon is already running (`.venv/bin/trex-core-daemon &`). -Drives it through every RabbitMQ surface: +Assumes the daemon is already running with `--config ` (see SKILL.md). +Drives every RabbitMQ surface of the asset-centric model: - 1. snapshot RPC (view=all) — daemon answers - 2. buy intent RPC — admission + slot transition - 3. duplicate buy (same correlation_id) — intent_log dedup gate (REQ-023 AC-1) - 4. wait for execution tick + fill event - 5. sell intent RPC — slot → closed + sell event + 1. snapshot RPC (view=all) — daemon answers; slots[] = permanent + asset slots created by the universe + reconcile (status `inactive`) + 2. buy intent RPC keyed by slot_id — admission + slot → pending_buy + (REQ-061 canonical key; portfolio_id is the legacy dual-accept) + 3. duplicate buy (same intent_id) — intent_log dedup gate (REQ-023 AC-1) + 4. wait for execution tick + fill — slot → holding + 5. sell intent RPC — slot → pending_sell → fill → + `inactive` (occupancy cleared, + asset binding kept; NOT 'closed') -Trading_date defaults to UTC today (matches what the daemon preallocates). +Pass the instance key as argv[1] (default: "default"). Env from config/.env. """ from __future__ import annotations -import json import os import sys -import threading import time import uuid -from datetime import date, datetime, timezone from dotenv import load_dotenv -load_dotenv(".env") - -import pika # noqa: E402 +load_dotenv("config/.env") from trex_core.adapters.messaging import topology # noqa: E402 from trex_core.adapters.messaging.rabbitmq import RabbitMQTransport # noqa: E402 URL = os.environ.get("TREX_RABBITMQ_URL", "amqp://admin:admin@localhost:5672/") -TODAY = datetime.now(tz=timezone.utc).date() -STRATEGY = "STRAT-A" -SYMBOL = "005930" - - -def _subscribe_events(received: list, stop: threading.Event, queue: str) -> threading.Thread: - """Background event consumer bound BEFORE we trigger intents.""" - def loop() -> None: - t = RabbitMQTransport(URL, timeout=5.0) - try: - t.consume_events_forever( - queue, - topology.strategy_event_binding_pattern(STRATEGY), - lambda rk, p: received.append((rk, p)), - stop, - ) - finally: - t.close() - th = threading.Thread(target=loop, name="smoke-events", daemon=True) - th.start() - return th - - -def _cleanup_queue(queue: str) -> None: - try: - c = pika.BlockingConnection(pika.URLParameters(URL)) - try: - c.channel().queue_delete(queue) - finally: - c.close() - except Exception: - pass - - -def _print_slots(slots: list) -> None: - for s in slots: - print(f" slot {s['slot_id'][:8]}.. status={s['status']:<12} " - f"symbol={s['symbol']} strategy_id={s['strategy_id']} qty={s['qty']} " - f"avg_price={s['avg_price']}") +INST = sys.argv[1] if len(sys.argv) > 1 else "default" def main() -> int: - ev_queue = f"trex.events.strategy.{STRATEGY}.smoke.{uuid.uuid4().hex[:6]}" - ev_received: list[tuple[str, dict]] = [] - ev_stop = threading.Event() - ev_thread = _subscribe_events(ev_received, ev_stop, ev_queue) - time.sleep(0.6) # let queue bind before triggering events + mq = RabbitMQTransport(URL, timeout=10.0) + snap_q = topology.snapshot_queue(INST) + intent_q = topology.intent_queue(INST) - client = RabbitMQTransport(URL, timeout=5.0) - try: - # 1) snapshot RPC --------------------------------------------------- - print(f"=== 1) snapshot RPC (view=all, day={TODAY.isoformat()}) ===") - resp = client.call_rpc( - topology.SNAPSHOT_REQ_QUEUE, - {"view": "all", "trading_date": TODAY.isoformat()}, - correlation_id=f"snap-{uuid.uuid4().hex[:8]}", - timeout=5.0, + def snap(): + return mq.call_rpc( + snap_q, + {"view": "all", "actor": "operator", "touch_watchdog": False}, + correlation_id=str(uuid.uuid4()), timeout=8.0, ) - print(f"slots={len(resp['slots'])} capacity={resp['capacity']}") - if resp["capacity"]["idle_count"] == 0: - print("\n!! no idle slots for today — restart the daemon to preallocate, " - "or check `date -u`") - return 2 - # 2) buy intent RPC ------------------------------------------------ - print(f"\n=== 2) buy intent RPC ({SYMBOL}, qty=10@60000, " - f"strategy={STRATEGY}, portfolio=PF-smoke) ===") - cid = f"smoke-buy-{uuid.uuid4().hex[:8]}" - resp = client.call_rpc( - topology.INTENT_REQ_QUEUE, - { - "intent_type": "buy", "intent_id": cid, - "trading_date": TODAY.isoformat(), "symbol": SYMBOL, - "qty": 10, "entry_price": 60000, - "strategy_id": STRATEGY, "portfolio_id": "PF-smoke", - }, - correlation_id=cid, timeout=5.0, - ) - print(f"admission: {json.dumps(resp, ensure_ascii=False)}") - if resp["status"] != "admitted": - print("\n!! buy not admitted — aborting"); return 3 + def intent(payload): + cid = payload.get("intent_id") or str(uuid.uuid4()) + return mq.call_rpc(intent_q, payload, correlation_id=cid, timeout=8.0) - # 3) duplicate ----------------------------------------------------- - print(f"\n=== 3) duplicate buy (same correlation_id={cid[-8:]}…) ===") - resp_dup = client.call_rpc( - topology.INTENT_REQ_QUEUE, - { - "intent_type": "buy", "intent_id": cid, - "trading_date": TODAY.isoformat(), "symbol": SYMBOL, - "qty": 10, "strategy_id": STRATEGY, "portfolio_id": "PF-smoke", - }, - correlation_id=cid, timeout=5.0, - ) - print(f"identical: {resp == resp_dup} (REQ-023 AC-1 intent_log dedup)") + print("=== 1) snapshot RPC ===") + s = snap() + slots = s["slots"] + print(f"assets={len(s['assets'])} slots={len(slots)} " + f"available_slots={s['available_slots']}") + inactive = [x for x in slots if x["status"] == "inactive" + and not x["universe_detached"]] + if not inactive: + print("no admittable (inactive) asset slot — universe reconcile not " + "done yet, or all assets active") + return 1 + target = inactive[0] + sid = target["slot_id"] + print(f"target slot_id={sid} portfolio_id={target['portfolio_id']}") - # 4) wait for fill + event ----------------------------------------- - print(f"\n=== 4) wait for execution tick + fill event (paper broker) ===") - deadline = time.monotonic() + 10 - while time.monotonic() < deadline: - resp = client.call_rpc( - topology.SNAPSHOT_REQ_QUEUE, - {"view": "open", "trading_date": TODAY.isoformat()}, - correlation_id=f"snap-{uuid.uuid4().hex[:8]}", timeout=5.0, - ) - holdings = [s for s in resp["slots"] if s["status"] == "holding"] - if holdings: - _print_slots(holdings) - break - time.sleep(0.5) - else: - print("\n!! no holding slot appeared in 10s — execution loop stuck?"); return 4 + print("=== 2) buy by slot_id ===") + iid = str(uuid.uuid4()) + d = intent({"intent_type": "buy", "intent_id": iid, "slot_id": sid, + "qty": 10, "entry_price": 1000, "strategy_id": "STRAT-A"}) + print(f"-> {d['status']} reason={d['reason']}") + if d["status"] != "admitted": + return 1 - print(f"buy events captured so far: " - f"{[rk for rk, _ in ev_received if rk.startswith('slot_filled_buy.')]}") + print("=== 3) duplicate buy (same intent_id) ===") + d2 = intent({"intent_type": "buy", "intent_id": iid, "slot_id": sid, "qty": 10}) + print(f"identical: {d2 == d}") - # 5) sell intent --------------------------------------------------- - sid = holdings[0]["slot_id"] - scid = f"smoke-sell-{uuid.uuid4().hex[:8]}" - print(f"\n=== 5) sell intent on slot {sid[:8]}.. ===") - resp_sell = client.call_rpc( - topology.INTENT_REQ_QUEUE, - {"intent_type": "sell", "intent_id": scid, "slot_id": sid}, - correlation_id=scid, timeout=5.0, - ) - print(f"admission: {json.dumps(resp_sell, ensure_ascii=False)}") + print("=== 4) wait for fill ===") + for _ in range(20): + time.sleep(0.5) + st = next(x for x in snap()["slots"] if x["slot_id"] == sid) + if st["status"] == "holding": + break + print(f"slot status={st['status']} qty={st['qty']} avg_price={st['avg_price']}") - # Wait for the sell fill event - deadline = time.monotonic() + 10 - while time.monotonic() < deadline and not any( - rk.startswith("slot_filled_sell.") for rk, _ in ev_received - ): - time.sleep(0.3) + print("=== 5) sell intent ===") + d3 = intent({"intent_type": "sell", "intent_id": str(uuid.uuid4()), + "slot_id": sid}) + print(f"-> {d3['status']}") + for _ in range(20): + time.sleep(0.5) + st = next(x for x in snap()["slots"] if x["slot_id"] == sid) + if st["status"] == "inactive": + break + print(f"slot status={st['status']} qty={st['qty']} " + f"portfolio_id={st['portfolio_id']} (asset binding kept)") - print(f"\nall events captured ({len(ev_received)}):") - for rk, payload in ev_received: - print(f" rk={rk} status={payload.get('status'):<8} " - f"qty={payload.get('qty')} sequence_at={payload.get('sequence_at')}") - - print("\nDONE") - return 0 - finally: - ev_stop.set() - try: - client.close() - except Exception: - pass - ev_thread.join(timeout=2.0) - _cleanup_queue(ev_queue) + mq.close() + print("DONE") + return 0 if __name__ == "__main__": - sys.exit(main()) + raise SystemExit(main()) diff --git a/config/instance.example.toml b/config/instance.example.toml index 279c729..c91e39a 100644 --- a/config/instance.example.toml +++ b/config/instance.example.toml @@ -11,10 +11,10 @@ instance_key = "default" # when TREX_PORTFOLIO_HUB_BASE_URL is set in the environment. portfolio_hub_token = "" -# dcy-014 / REQ-005: trading_date is computed in this timezone (IANA name), -# while all timestamps stay UTC. Default Asia/Seoul (KST). +# Operator-local timezone (IANA name) for CLI date display. Timestamps stay UTC. trading_timezone = "Asia/Seoul" -# Daily slot preallocation trigger (HH:MM, in trading_timezone). The next day's -# slots are created at this local time without a daemon restart. Default midnight. -daily_preallocation_time = "00:00" +# dcy-020 / REQ-060: max concurrent active (holding+pending) slots — the FCFS +# admission capacity cap M. Unset = no cap. Production deployments are +# expected to set this (BAT-030 design §5). +# max_active_slots = 5 diff --git a/docs/batches/bat-030_20260610_asset-centric-cutover/implementation.md b/docs/batches/bat-030_20260610_asset-centric-cutover/implementation.md index ad013d6..7dacd0c 100644 --- a/docs/batches/bat-030_20260610_asset-centric-cutover/implementation.md +++ b/docs/batches/bat-030_20260610_asset-centric-cutover/implementation.md @@ -1,16 +1,51 @@ # BAT-030 Implementation -- Status: not-started +- Status: done - Batch: BAT-030 +- 일자: 2026-06-10 ## 구현 요약 -- (run-batch-delivery 진입 시 작성) design.md 체크포인트(D1~D4) 확정 후 채운다. -## 변경 내역 -- (대기) +design.md 확정 결정(D1=전체 rebuild / D2=전부 완성 / D3=매도 qty 브로커 정본 / D4=실 cutover 운영자) 전부 구현 완료. M1~M5 전 마일스톤 수행. + +- **M1 — literal inactive 전면화 (REQ-057 잔여)**: `SlotState` 5-state(inactive/pending_buy/holding/pending_sell/error) — `IDLE`/`CLOSED` 별칭·`normalize_state` 완전 제거, 전이 12종(6 base + 4 to-error + 2 resolve). 마이그레이션 **010**: 기존 idle/closed 행 → inactive UPDATE + status CHECK 교체(drop-and-add 멱등). `execution.py` 매도 체결 `PENDING_SELL→INACTIVE`, 매수 롤백 `PENDING_BUY→INACTIVE`. `cli.py` buyable 필터 `inactive`+`universe_detached` 제외. `ops_checks.py` ledger-gap 검출을 `slot_transition_log`(reason=`sell_filled`/`sell_partial_cancel`) 기반으로 재작성(`closed` 상태 소멸 대응). +- **M2 — 집행 매도 수량 브로커 정본 (REQ-062 M2(b), D3)**: dispatch 매도 qty = `broker.get_balance()` 의 해당 symbol `hldg_qty`(tick 당 1회 lazy fetch). 미러 `slot.qty` fallback 없음. 브로커 0 → 주문 미제출 + WARN + `ExecutionResult.sell_skipped_no_balance` 카운터(divergence 는 reconcile/운영자 경로로 표면화). skip 매도가 큐 head 를 막지 않도록 단건 claim(`find_pending_for_dispatch`) → **리스트 기반**(`list_undispatched_pending(instance_key, limit)`) dispatch 로 전환. +- **M3 — 컬럼 drop + legacy pool 제거 (REQ-056 잔여)**: 마이그레이션 **011**: `daily_slot.entry_count`·`trading_date` DROP(IF EXISTS 멱등). 글롭 재적용 정합: 001 의 `(trading_date,status)` 인덱스 → `(status)` 교체, 008(entry_count ADD)은 no-op 화(매 boot 컬럼 부활 방지). `Slot` 모델/`EXPECTED_SLOT_FIELDS`/`_SLOT_COLUMNS`/`_row_to_slot` 정합(19 컬럼). repo 계약 개편: `pre_allocate_slots`/`find_idle_slot`/`find_allocatable_slot`/`increment_symbol_entry`/`get_symbol_entry_count` 제거, `list_slots(instance_key)`/`count_slots_by_status(instance_key)`/`reconcile_universe(instance_key, universe)` 로 전환, 집행 큐(`list_undispatched_pending`/`list_dispatched_pending`)도 **instance 스코프**(공유 DB 의 두 데몬이 서로의 주문을 집행하지 않음 — REQ-056). `preallocation_scheduler.py` 삭제 + 데몬 7 스레드. admission 은 자산 중심 단일 경로(slot_id 정본/portfolio_id dual-accept; `max_active_slots` 미설정 = 무캡 — design §5: 운영 TOML 로 M 부여). `BuyIntent.trading_date`·intent/이벤트/슬롯 뷰 payload 의 `trading_date` 키 제거. capacity 를 `SlotCapacity(inactive/holding/pending/active/available/total/scarcity)` 자산 중심 재정의(available = max(0, M−active)). `daily_symbol_entries` 코드 경로 제거(테이블 잔존, baseline 에 폐기 명시). config 에서 `total_slots`/`daily_preallocation_time` 제거. SnapshotScheduler reconcile 은 slot_repo 주입 시 항상 on. +- **M4 — cutover 도구 (REQ-064, D1·D4)**: `trex_core/runtime/cutover.py` + console script `trex-core-cutover`(기본 dry-run, `--yes` 적용). 절차: preflight(pending_* in-flight 0, 위반 시 CutoverAbort) → instance 행 전체 DELETE → universe 자산별 영구 슬롯(inactive) 생성 → 브로커 잔고 (symbol,qty) → universe 매핑 → holding 설정(qty=브로커 정본, avg_price=NULL+WARN — 잔고 API 에 평단 없음) → `slot_transition_log` 감사(`cutover_rebuild`) → 옵션 `--promote-unique`(portfolio_id NOT NULL; 공유 DB 전 인스턴스 cutover 후). 매핑 실패 기본 중단(`--allow-unmapped` 로 WARN+skip). 단일 트랜잭션(중단/실패 시 무변경, dry-run 은 rollback). +- **M5 — baseline 마감 (REQ-065)**: data-model/runtime-flows/project-structure/interface-contract 4문서 자산 중심 최종 갱신(daily_symbol_entries·dcy-014 preallocation·풀 admission 폐기 확정, slot_id 계약·5-state·M 캡·브로커 정본 반영). `config/instance.example.toml` 에 `max_active_slots` 안내. `.claude/skills/run-trex-core-daemon`(SKILL.md+smoke.py) 자산 중심으로 갱신. + +## 변경 내역 (Changed Files) + +- trex_core/domain/slot/: state.py(5-state) · models.py(컬럼 drop+SlotCapacity 재정의) · service.py(preallocate/카운터 제거, INACTIVE 리셋=점유 초기화+자산 정체성 보존, capacity 자산 중심) · repository.py · in_memory_repository.py(+add_slot 테스트 헬퍼) · __init__.py +- trex_core/adapters/persistence/: postgresql.py · ops_checks.py(재작성) · sql/001(인덱스 교체)·008(no-op 화)·**010_literal_inactive_status.sql**(신규)·**011_drop_pool_model_columns.sql**(신규) +- trex_core/application/: intents.py(단일 자산 중심 admission) · execution.py(M2+inactive 전이+instance 스코프) · messaging_handlers.py · portfolio.py · safety_sell.py · killswitch.py · snapshot_scheduler.py · **preallocation_scheduler.py 삭제** +- trex_core/runtime/: daemon.py(7 스레드) · config.py · cli.py · **cutover.py(신규)** +- pyproject.toml: `trex-core-cutover` console script +- docs: data-model.md · runtime-flows.md · project-structure.md · interface-contract.md (자산 중심 마감) +- config/instance.example.toml · .claude/skills/run-trex-core-daemon/{SKILL.md,smoke.py} +- tests: 도메인/애플리케이션/어댑터/런타임 전면 정합(test_state·test_service 재작성, test_slot_entry_count·test_buy_admission_reallocation·test_preallocation_scheduler 삭제, test_execution 에 M2 3건 신설, **tests/runtime/test_cutover_rehearsal.py 신규 8건(PG-gated)**, test_postgresql_repository 자산 중심 재작성+dispatch 인스턴스 격리, test_ops_checks 재작성) ## 테스트 -- (대기) -## 검증 실행 -- (대기) +- 단위 전체: **367 passed** (PG/RabbitMQ/KIS unset 시 40 skipped), ruff clean +- PG-gated(trex_core_test): **29 passed** — 마이그레이션 010/011 round-trip + 멱등 재적용, daily_slot 컬럼 == EXPECTED_SLOT_FIELDS 드리프트 가드(컬럼 drop 후 정합), reconcile ON CONFLICT, dispatch 인스턴스 격리, ops_checks 재작성, **cutover 리허설 8건**(전체 rebuild·브로커 carry-over(미러 7 vs 브로커 12 → 12)·in-flight 중단·unmapped 중단/allow-skip·dry-run rollback·인스턴스 격리·promote-unique·감사 로그) +- RabbitMQ-gated: 21 passed (실 브로커) +- 통합(전부 enabled): **405 passed, 10 skipped**(KIS sandbox — 기존 환경 이슈, 본 배치 무관) + +## 검증 실행 (run-the-app, 자산 중심 e2e) + +test DB(trex_core_test) + 실 RabbitMQ + paper broker + stub universe(2 자산) + `instance_key=e2etest`, `max_active_slots=1` 로 데몬 기동: +1. boot 직후 universe reconcile `{'created': 2}` — 영구 슬롯 자동 생성 +2. snapshot RPC: slots=2(inactive), `available_slots=1`(M−active) +3. **buy by slot_id** admitted(REQ-061) → 4. 동일 intent_id 중복 → identical 응답(전이 1회) +5. M=1 에서 2번째 자산 buy → `capacity_full`(REQ-059) → 6. paper fill → holding, available=0 +7. 동일 슬롯 재매수 → `already_active` → 8. sell by slot_id → 체결 → **inactive**(qty=None, portfolio_id 보존, available=1) +9. **동일 영구 슬롯 재활성**(re-entry) admitted +- DB 최종: `('e2etest','pf-000660','inactive',None)`, `('e2etest','pf-005930','holding',3.0)`; transition_log reason {buy_intent×2, buy_filled×2, discretionary_sell×1, sell_filled×1}; ledger_outbox completed×3; 데몬 로그 ERROR/Traceback 0, Normal shutdown. + +## Remaining Risks / Blockers + +- **실 cutover 미실행(의도)** — D4: 운영자가 REL 단계에서 리허설 후 `trex-core-cutover --config --yes` 실행. 사전 백업 + 거래 세션 외 + in-flight 0 필수. 롤백 = 백업 복원 + 직전 리비전. +- cutover carry-over 의 `avg_price=NULL` — 잔고 API 미제공. SL/TP 는 절대가 기준이라 안전 매도 무영향이나, 운영자/전략이 필요 시 후속 갱신. +- 전략 모듈(trex-orb) 계약 전환: buy `slot_id` 정본(snapshot slots[] round-trip), `trading_date` 키 소멸, 이벤트 payload 변경 — portfolio_id dual-accept 로 무중단이나 전략 측 전환 권고(REL 노트 대상). +- `max_active_slots` 미설정 = 무캡 — 운영 TOML 에 반드시 설정(배포 체크리스트, design §5). diff --git a/docs/batches/bat-030_20260610_asset-centric-cutover/index.md b/docs/batches/bat-030_20260610_asset-centric-cutover/index.md index 369abdd..49e880b 100644 --- a/docs/batches/bat-030_20260610_asset-centric-cutover/index.md +++ b/docs/batches/bat-030_20260610_asset-centric-cutover/index.md @@ -1,6 +1,6 @@ # BAT-030: 자산 중심 cutover — 라이브 전환·집행 정본·모델 완성 (dcy-020 BAT-δ) -- Status: planning +- Status: in-delivery - Profile: standard - Source Discovery: dcy-020 - Included REQ: REQ-064 (풀→영구 cutover), REQ-062 (잔여: 청산 수량 브로커 정본), REQ-056/057/065 (잔여: entry_count/trading_date drop·literal inactive·baseline 마감) diff --git a/docs/batches/index.md b/docs/batches/index.md index 0c5ad72..021eed0 100644 --- a/docs/batches/index.md +++ b/docs/batches/index.md @@ -37,4 +37,4 @@ | BAT-027 | standard | planning | REQ-056, REQ-057 | dcy-020 (BAT-α) | [bat-027](./bat-027_20260610_asset-permanent-slot-foundation/) | | BAT-028 | standard | planning | REQ-058, REQ-059, REQ-060 | dcy-020 (BAT-β) | [bat-028](./bat-028_20260610_universe-reconcile-and-active-admission/) | | BAT-029 | standard | planning | REQ-061, REQ-062, REQ-063 | dcy-020 (BAT-γ) | [bat-029](./bat-029_20260610_asset-keyed-intent-and-divergence/) | -| BAT-030 | standard | planning | REQ-064, REQ-062, REQ-056·057·065(잔여) | dcy-020 (BAT-δ) | [bat-030](./bat-030_20260610_asset-centric-cutover/) | +| BAT-030 | standard | in-delivery | REQ-064, REQ-062, REQ-056·057·065(잔여) | dcy-020 (BAT-δ) | [bat-030](./bat-030_20260610_asset-centric-cutover/) | diff --git a/docs/data-model.md b/docs/data-model.md index f6cecb0..61eb3ee 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -4,14 +4,14 @@ - Baseline Seed: .stage-pilot/bootstrap/baseline.yaml - Status: draft - Owner: project maintainers -- Last Updated (KST): 2026-06-08 (BAT-019) +- Last Updated (KST): 2026-06-10 (BAT-030 — dcy-020 자산 중심 모델 마감) - Source Discovery / Batch: bootstrap-baseline (pre-Discovery) ## Project Summary - trex 제품군의 공용 베이스로, 첫 도메인으로 trex-orbit 의 일일 거래 슬롯 생명주기(slot) 순수 도메인을 storage-agnostic 하게 이식한 코어다. ## Primary Domain -- 자동매매 일일 거래 슬롯 생명주기 도메인 (equity trading slot lifecycle) +- 자동매매 **자산 중심 영구 슬롯** 생명주기 도메인 (dcy-020 — portfolio-hub universe 자산 1:1 영구 슬롯; 일일 풀 모델은 BAT-030 cutover 로 폐기) ## Primary Persistence Backend - PostgreSQL (BAT-002, REQ-006; BAT-006 확장) — 코어 데몬의 durable 단일 writer. 도메인은 여전히 storage-agnostic(SlotRepository Protocol); in-memory 어댑터는 테스트용으로 잔존. 슬라이스 1 테이블: daily_slot / daily_symbol_entries / slot_transition_log / intent_log / ledger_outbox (daily_slot_archive·prune 은 범위 밖). @@ -27,22 +27,23 @@ ## Model Summary -- Slot :: 하나의 일일 거래 슬롯의 현재 생명주기 상태/점유 정보(source of truth) -- SlotTransitionRecord :: 슬롯 상태 전이의 append-only 감사 기록 -- daily_symbol_entries (카운터) :: (trading_date, symbol) 별 재진입 횟수(결정 입력) -- SlotCapacity (계산 스냅샷) :: idle/holding/pending + scarcity_ratio (영속 엔티티 아님) +- Slot :: universe 자산 1:1 **영구 슬롯**의 현재 생명주기 상태/점유 정보(source of truth). 키 = (instance_key, portfolio_id) UNIQUE. +- SlotTransitionRecord :: 슬롯 상태 전이의 append-only 감사 기록 (시간 속성의 정본 — 슬롯 행에는 거래일 없음) +- SlotCapacity (계산 스냅샷) :: inactive/holding/pending/active + available(= max(0, M − active)) + scarcity_ratio (영속 엔티티 아님; dcy-020/REQ-059) +- ~~daily_symbol_entries~~ :: **폐기(dcy-020/BAT-030)** — closed→idle 복구 제거로 writer 소멸. 테이블은 잔존하나 코드 경로 없음. ## Entity: `Slot` -- Purpose: 하나의 일일 거래 슬롯의 현재 생명주기 상태와 점유 정보를 담는 source of truth. -- Source of Truth: daily_slot (향후 테이블) / 현재는 in-memory dict -- Lifecycle: idle -> pending_buy -> holding -> pending_sell -> closed -> (recovery) idle ; closed -> pending_buy (직접 재할당, dcy-019) ; 모든 비-error 상태 -> error ; error -> {idle, holding} +- Purpose: portfolio-hub universe 자산 1:1 **영구 슬롯**의 현재 생명주기 상태와 점유 정보를 담는 source of truth (dcy-020/REQ-056). 거래일을 넘어 존속하며, universe reconcile(REQ-058)이 생성/소프트 폐기한다. +- Source of Truth: daily_slot (PostgreSQL; 이름은 역사적 잔존 — 행은 영구) +- Lifecycle (5-state, REQ-057): inactive -> pending_buy -> holding -> pending_sell -> inactive ; pending_buy -> inactive (롤백) ; pending_sell -> holding (롤백) ; 모든 비-error -> error ; error -> {inactive, holding}. `idle`/`closed` 는 BAT-030(마이그레이션 010)에서 `inactive` 로 폐합. ### Key Fields -- slot_id :: UUID :: 슬롯 식별자(PK) -- trading_date :: date :: 거래일 — 설정 타임존(`trading_timezone`, 기본 `Asia/Seoul`) 기준 날짜로 계산(dcy-014/REQ-005). 시스템 UTC 날짜가 아니라 거래 캘린더 일자와 일치. 일일 preallocation 이 TZ 일자 경계마다 멱등 생성. -- status :: SlotState :: 6 상태(idle/pending_buy/holding/pending_sell/closed/error) 중 현재 상태 +- slot_id :: UUID :: 슬롯 식별자(PK) — 외부 intent 계약의 정본 키(REQ-061) +- instance_key :: TEXT NOT NULL DEFAULT 'default' :: 공유 DB 에서 데몬 인스턴스 스코프(REQ-056). UNIQUE(instance_key, portfolio_id) (partial — portfolio_id NOT NULL; cutover `--promote-unique` 후 NOT NULL 승격) +- retired_at :: TIMESTAMPTZ? :: universe 이탈 자산의 soft-retire 마커(REQ-058; 운영 뷰 `universe_detached`) +- status :: SlotState :: 5 상태(inactive/pending_buy/holding/pending_sell/error) 중 현재 상태 — CHECK 제약(마이그레이션 010) - symbol :: str? :: 점유 종목 - qty / avg_price :: numeric? :: 수량 / 평단 - limit_price :: numeric? :: 현재 pending 주문 지정가(side 무관, dcy-012/REQ-002 — 과거 `entry_price`에서 rename). pending 진입(매수/매도 지정가) 시 set, pending 이탈(→holding/→closed) 시 None 으로 clear → 매수 지정가가 매도로 누수되지 않음(issue #10). None 이면 dispatch 시 시장가. @@ -51,20 +52,21 @@ - strategy_id / sell_strategy_id :: str? :: 진입/청산 전략 식별 - portfolio_id :: str? :: 포트폴리오 식별 - forced_close_at :: datetime? :: 강제 청산 마감선 -- entry_count :: int :: 슬롯 단위 누적 진입 횟수(dcy-019/REQ-002). 매 `→pending_buy`(idle→ / closed→ 모두) 시 +1, 그 외 전이·closed→idle 복구는 보존. buy admission selector 의 공정 정렬 키(최저 진입 우선). 종목 단위 `daily_symbol_entries.entry_count`(재진입 캡)와 별개. -- created_at / updated_at :: datetime? :: 생성/갱신 시각 — **UTC 저장 유지**(dcy-014/REQ-005 NFR-1: `trading_date` 만 TZ 기준, timestamp 는 UTC) +- created_at / updated_at :: datetime? :: 생성/갱신 시각 — UTC 저장 +- (DROP, BAT-030/마이그레이션 011) ~~trading_date~~ — 영구 슬롯에 거래일 정체성 없음(시간 속성은 transition_log/원장). ~~entry_count~~ — 풀 공정 정렬 키 폐기. ### Relationships -- daily_symbol_entries :: closed->idle 복구 시 (trading_date, symbol) 카운트 +1 -- slot_transition_log :: 매 전이마다 append-only 기록 1행 +- portfolio-hub universe :: portfolio_id 1:1 — universe reconcile(REQ-058)이 신규 자산 슬롯 생성(inactive)·이탈 자산 soft-retire(retired_at)·보유 중 이탈은 tombstone(유지). +- slot_transition_log :: 매 전이마다 append-only 기록 1행 (매도 체결 검출·ledger-gap 검출의 정본) ### State Rules -- 전이 패밀리(8 base[dcy-019 closed→pending_buy 포함] + *→error + error→{idle,holding})에 없는 전이는 거부한다. +- 전이 패밀리(6 base + *→error + error→{inactive,holding} = 12)에 없는 전이는 거부한다(REQ-057). - 전이 전 current status == from_state 여야 한다(레이스 감지). -- closed->idle / pending_buy->idle 복구 시 점유 필드(symbol/qty/.../forced_close_at)를 초기화한다. -- closed->pending_buy 직접 재할당 시에도 이전 사이클의 점유 필드를 clean-slate 로 초기화한 뒤 새 진입 필드를 적용한다. entry_count 만 누적 보존(dcy-019/REQ-002). +- **INACTIVE 진입 시**(매수 롤백 / 매도 체결 / error 해소) 포지션·주문 점유 필드(qty/avg_price/limit_price/allocated_capital/order_id/strategy_id/broker_order_id/sell_strategy_id/forced_close_at/SL·TP)를 초기화하되 **자산 정체성(instance_key/portfolio_id/symbol/retired_at)은 보존**한다 — 영구 슬롯 재사용의 전제. +- buy admission(REQ-059/061): slot_id 정본(portfolio_id dual-accept) → 대상 자산 슬롯 활성화. FCFS, M(`max_active_slots`) 캡, 무치환(over-cap 시 기존 보유 유지·신규만 거부). active = holding+pending_buy+pending_sell. +- 청산 수량(REQ-062, D3): 집행 dispatch 의 매도 수량은 **브로커 잔고(hldg_qty) 정본** — slot.qty 미러 fallback 없음, 브로커 0 이면 skip+WARN(divergence 표면화). ## Entity: `SlotTransitionRecord` @@ -162,9 +164,9 @@ ## Shared Consistency Rules -- (trading_date, symbol) 재진입 카운트는 closed->idle 복구와 원자적으로 증가한다. -- 모든 상태 변경과 전이 로그 append 는 동일 advisory-locked 트랜잭션 안에서 일어난다. -- 재진입 정책 결정 입력은 daily_symbol_entries 카운터에서 읽고, slot_transition_log(감사 전용)를 join 하지 않는다. +- 모든 상태 변경과 전이 로그 append 는 동일 advisory-locked 트랜잭션 안에서 일어난다. advisory lock 은 인스턴스 스코프(`slot_advisory_lock_id(instance_key)`, REQ-057 NFR-1). +- 집행 dispatch/poll 큐 조회는 instance_key 스코프 — 공유 DB 의 두 데몬이 서로의 주문을 집행하지 않는다(REQ-056). +- holding ↔ 브로커 정합(REQ-062): 브로커 잔고가 reconcile ground truth. core holding/브로커 0 → slot error 격리, 수량 불일치 → WARN(미러 비정본), 브로커 보유/슬롯 없음 → WARN(unmanaged). ## Persistence / Integration Notes @@ -174,9 +176,10 @@ ## Current Gaps / Planned Changes -- daily_slot / daily_symbol_entries / slot_transition_log 의 물리 스키마(DDL)는 미이식 — 영속화 단계에서 작성. -- daily_slot_archive(EOD 아카이브)와 retention prune 은 trex-orbit 인프라 기능으로, 이번 순수 도메인 범위 밖. -- SlotState/필드 계약을 코드와 문서 사이에서 드리프트 없이 유지할 방법은 첫 Discovery 에서 정의(plan.md OQ-4). +- `daily_symbol_entries` 테이블은 폐기 상태로 잔존(코드 경로 없음) — 후속 정리 시 DROP 가능. +- daily_slot_archive(EOD 아카이브)와 retention prune 은 범위 밖 유지. +- 포지션 사이징(issue #1 잔여, dcy-020 OQ-4 분리)은 후속 Discovery. +- 실 cutover(REQ-064)는 운영자 실행: `trex-core-cutover --config --yes` (전체 rebuild, 보유 carry-over=브로커 잔고; avg_price 는 NULL 로 carry — 잔고 API 미제공). ## Update Triggers @@ -190,3 +193,4 @@ - 2026-06-03 :: BAT-002 — PostgreSQL 영속화 확정 + `intent_log` 엔티티 추가(REQ-006/007/009). - 2026-06-04 :: BAT-004 — 브로커 집행 레이어 반영(BrokerPort/paper·KIS 어댑터, execution 루프, broker_order_id 실행큐·멱등, reconciliation) (REQ-011~015). - 2026-06-04 :: BAT-006 — ledger 통합 데이터 모델 반영(LedgerTrade DTO + `ledger_outbox` 테이블/`correlation_id` 멱등/outcome 어휘, FillStatus.execution_id/fee/tax 운반) (REQ-017~020). +- 2026-06-10 :: BAT-030 (dcy-020 마감) — 자산 중심 영구 슬롯 모델 확정: 5-state(inactive), idle/closed 폐합(마이그레이션 010), trading_date/entry_count DROP(011), instance_key/retired_at 정식화, slot_id intent 계약, M 캡 admission, 매도 수량 브로커 정본, daily_symbol_entries·일일 preallocation(dcy-014)·풀 admission(dcy-001/019) 폐기, cutover 도구(trex-core-cutover) (REQ-056~065). diff --git a/docs/interface-contract.md b/docs/interface-contract.md index 5d7db3a..0dd8f01 100644 --- a/docs/interface-contract.md +++ b/docs/interface-contract.md @@ -63,7 +63,7 @@ | 패턴 | 표면 | 요청 속성 | 응답 | |---|---|---|---| -| 스냅샷 RPC | `trex.snapshot.req` | `correlation_id`, `reply_to=amq.rabbitmq.reply-to` | reply-to 큐로 `{view, strategy_id, trading_date, slots[], capacity{}}` | +| 스냅샷 RPC | `trex.snapshot.req` | `correlation_id`, `reply_to=amq.rabbitmq.reply-to` | reply-to 큐로 envelope(`assets[]`/`slots[]`/`available_slots`/…); stub 경로는 `{view, strategy_id, slots[], capacity{}}` | | intent RPC | `trex.intent.req` | `correlation_id` = intent 멱등키, `reply_to=amq.rabbitmq.reply-to`, `delivery_mode=2` | reply-to 큐로 `{intent_id, status:admitted|rejected, slot_id?, reason?}`. 체결/종료는 응답 아닌 이벤트로 | | update_thresholds | `trex.intent.req` | `action="update_thresholds"`, `reply_to` | reply-to 큐로 `{status:updated|ignored, slot_id, reason?}` (REQ-040) | | 이벤트 pub/sub | `trex.events` (topic) | routing key `{event_type}.{strategy_id}.{symbol}` | 전략 큐가 자기 `strategy_id` 패턴으로 바인딩 | @@ -72,22 +72,25 @@ #### `trex.snapshot.req` 요청 payload ``` -{ "view": "open" | "all", "strategy_id": str | null, "trading_date": "YYYY-MM-DD" } +{ "view": "open" | "all", "strategy_id": str | null, + "trading_date": "YYYY-MM-DD"? # optional — kill-switch 당일 제외 마스크 기준일만 (BAT-030) +} ``` -응답 payload는 슬롯 목록과 capacity 를 운반한다. view 모드와 strategy_id 는 payload(요청 매핑 원칙 — REQ-021). +응답 payload는 슬롯 목록과 capacity 를 운반한다. view 모드와 strategy_id 는 payload(요청 매핑 원칙 — REQ-021). BAT-030: 슬롯 조회는 instance 스코프(영구 슬롯)라 `trading_date` 로 필터하지 않는다. #### `trex.intent.req` 요청 payload (buy) ``` { "intent_type": "buy", "intent_id": "", - "trading_date": "YYYY-MM-DD", - "portfolio_id": str, # REQUIRED (dcy-011/REQ-007) — canonical key(asset_id) + "slot_id": str(UUID), # CANONICAL key (dcy-020/REQ-061) — snapshot slots[] round-trip + "portfolio_id": str?, # legacy dual-accept 매핑(점진 전환용) "qty": float?, "allocated_capital": float?, "entry_price": float?, "strategy_id": str?, "sell_strategy_id": str?, "forced_close_at": "ISO-8601"?, "stop_loss_price": number?, "take_profit_price": number? } ``` -- **`portfolio_id` 필수(dcy-011/REQ-007/021)**: 매수의 정식 식별자. 누락 시 admission 에서 `portfolio_id_required` 로 거부. **`symbol` 은 client payload 에 없다** — core 가 admission 시 universe(`SnapshotStore`)에서 `portfolio_id → symbol` 을 resolve 해 슬롯/KIS 주문에 사용한다. universe 에 portfolio_id 가 없으면 `unknown_portfolio` 로 거부(하위호환·symbol fallback 없음, hard cutover). +- **`slot_id` 정본(dcy-020/REQ-061, BAT-029/030)**: 매수의 정식 키 — 전략 모듈은 snapshot `slots[]` 의 `slot_id` 를 그대로 회신한다. 미스 시 `unknown_slot` 거부. `portfolio_id` 는 legacy dual-accept(미스 시 `unknown_portfolio`); 둘 다 없으면 `slot_id_or_portfolio_required`. `trading_date` 는 계약에서 제거(BAT-030). +- admission(REQ-059): 대상 영구 자산 슬롯이 active(holding/pending) 면 `already_active`; instance active 수 ≥ `max_active_slots`(M) 면 `capacity_full`(FCFS·무치환). **`symbol` 은 client payload 에 없다** — core 가 슬롯의 `portfolio_id` 로 universe 에서 resolve 한다. - `stop_loss_price`/`take_profit_price`(선택, REQ-035/038): 매수 시 설정하면 워치독 만료 후 safety-sell 가격 트리거의 기준이 된다. 미포함 시 NULL(트리거 비활성). - `forced_close_at`(선택, REQ-066/037): 시간 기반 자동청산(만기) 절대시각. 운영자 CLI `enter --forced-close-at `(절대시간)로 설정하며(REQ-032 CHG-20260609-01, issue #17, BAT-024), 미지정 시 payload 에 미포함(NULL). 설정 시 forced-close 만기 매도 루프(REQ-037)가 그 시각에 시장가 청산한다. 상대시간 옵션은 제공하지 않는다. sell payload 는 `{"intent_type":"sell", "intent_id":..., "slot_id":..., "reason":..., "sell_strategy_id":..., "limit_price": float?}`. @@ -111,8 +114,8 @@ sell payload 는 `{"intent_type":"sell", "intent_id":..., "slot_id":..., "reason { "event_type": "slot_filled_buy"|"slot_filled_sell"|..., "slot_id": str, "strategy_id": str?, "symbol": str?, "status": "", "qty": float?, "avg_price": float?, - "portfolio_id": str?, "trading_date": "YYYY-MM-DD", - "sequence_at": "ISO-8601" } + "portfolio_id": str?, + "sequence_at": "ISO-8601" } # BAT-030: trading_date 키 제거(영구 슬롯) ``` - `sequence_at` = 슬롯 `updated_at` 의 단조 증가 토큰. 수신자는 last-wins 적용 가능 → 순서 보증 불요. - 이벤트의 `strategy_id`/`symbol` 은 routing key 에, 본문에도 함께 운반(이벤트 매핑 원칙 — REQ-021). @@ -156,7 +159,7 @@ sell payload 는 `{"intent_type":"sell", "intent_id":..., "slot_id":..., "reason }, ... ], "view": "all" | "buyable" | "held", # echo - "available_slots": int, # idle_count snapshot + "available_slots": int, # admission headroom = max(0, M − active); M 미설정 시 inactive 수 (REQ-059) "degraded": bool, # REQ-028 AC-2 "reason": "portfolio_hub_unavailable" | "snapshot_fetch_failed" | "cash_position_stale" | null, @@ -168,17 +171,17 @@ sell payload 는 `{"intent_type":"sell", "intent_id":..., "slot_id":..., "reason - 슬롯 상태 (REQ-028 AC-4, BAT-022): envelope 은 strategy `assets[]`(universe) 와 **별도로** 데몬의 per-slot 상태를 운반한다. 운영자용(`trex-core slots`)이며 universe 와 명확히 구분된다. ``` "slots": [ # always a list (REQ-028 AC-4, never null) - { "slot_id": str, - "trading_date": "YYYY-MM-DD", - "status": "idle"|"pending_buy"|"holding"|"pending_sell"|"closed", + { "slot_id": str, # buy intent 의 canonical key (REQ-061) + "status": "inactive"|"pending_buy"|"holding"|"pending_sell"|"error", "symbol": str | null, "qty": number | null, "avg_price": number | null, # 매수 체결가/평단 "entry_price": number | null, # pending 주문 지정가 (dcy-012 외부 호환명; 내부 limit_price) "allocated_capital": number | null, "strategy_id": str | null, "sell_strategy_id": str | null, - "portfolio_id": str | null, # == asset_id - "forced_close_at": "ISO-8601" | null } + "portfolio_id": str | null, # == asset_id (영구 자산 바인딩) + "forced_close_at": "ISO-8601" | null, + "universe_detached": bool } # soft-retire/tombstone 마커 (REQ-058/063) ] ``` - BAT-007 stub 경로와 BAT-008 envelope 경로가 **동일 `slots[]` 스키마**(`portfolio.slot_to_dict`)를 낸다. `assets[]`(portfolio 단위, universe, no-price) 와 독립이며 strategy 소비자는 무시 가능(additive, 하위호환). `slots[]` 는 operator-facing 이라 `entry_price`/`avg_price` 가격을 노출한다(no-price 규칙은 `assets[]` 한정). @@ -318,16 +321,15 @@ resume_kill_switch 요청·응답 (REQ-046): ### Inputs -- preallocate_daily_slots(trading_date, count) :: 거래일 idle 슬롯 멱등 사전 할당 (count > 0) -- transit_slot(slot_id, from_state, to_state, reason, actor, **fields) :: advisory lock 하 원자 상태 전이 -- get_today_entry_count(trading_date, symbol) :: 재진입 정책 입력 조회 -- compute_slot_capacity(trading_date, total_slots) :: 가용성 스냅샷 계산 (total_slots > 0) +- transit_slot(slot_id, from_state, to_state, reason, actor, **fields) :: advisory lock 하 원자 상태 전이 (INACTIVE 진입 시 점유 초기화 + 자산 정체성 보존) +- sell(slot_id, actor, reason, limit_price=None) :: holding 슬롯 자율 매도(멱등) +- update_thresholds(slot_id, ...) :: SL/TP/forced_close 부분 갱신 +- compute_slot_capacity(max_active_slots=None) :: instance 스코프 가용성 스냅샷 (BAT-030) ### Outputs - Slot :: 전이 후 슬롯 행 스냅샷 -- SlotCapacity :: idle/holding/pending 카운트 + scarcity_ratio -- int :: 사전 할당 수 / 재진입 카운트 +- SlotCapacity :: inactive/holding/pending/active/available 카운트 + total + scarcity_ratio - slot_transition_log append :: 모든 전이의 append-only 감사 기록(부수 효과) ### Error Contract @@ -335,12 +337,11 @@ resume_kill_switch 요청·응답 (REQ-046): - SlotTransitionError :: 허용되지 않은 (from_state, to_state) 전이 - SlotStateMismatchError :: read-write 사이 상태 변동(레이스 감지) - SlotNotFoundError :: 존재하지 않는 slot_id -- ValueError :: count <= 0 또는 total_slots <= 0 ### Compatibility Rules -- 6 상태 / 8 전이 패밀리 계약을 깨는 변경은 breaking 으로 취급한다. -- 상태 변경 API 는 transactional_advisory_lock 안에서만 수행하고 lock 보유 중 외부 I/O 를 금지한다. +- 5 상태 / 12 전이 계약(REQ-057)을 깨는 변경은 breaking 으로 취급한다. +- 상태 변경 API 는 transactional_advisory_lock(인스턴스 스코프 lock id) 안에서만 수행하고 lock 보유 중 외부 I/O 를 금지한다. ## Interface: `SlotRepository (Protocol)` @@ -352,16 +353,17 @@ resume_kill_switch 요청·응답 (REQ-046): ### Inputs - transactional_advisory_lock(lock_id) :: 단일 트랜잭션 + advisory lock 컨텍스트 매니저 -- pre_allocate_slots / fetch_slot / find_idle_slot / update_slot :: daily_slot CRUD +- list_slots(instance_key) / fetch_slot / update_slot :: daily_slot CRUD (instance 스코프) +- find_slot_by_portfolio / count_active / list_holding_slots / reconcile_universe :: 자산 중심 표면 (REQ-058/059/062) +- list_undispatched_pending / mark_dispatched / list_dispatched_pending / clear_dispatch :: 집행 큐 (instance 스코프) - append_transition_log :: append-only 전이 로그 -- increment_symbol_entry / get_symbol_entry_count :: 재진입 카운터 -- count_slots_by_status :: 상태별 집계 +- count_slots_by_status(instance_key) :: 상태별 집계 ### Outputs - Slot / SlotTransitionRecord :: 도메인 모델 - dict[SlotState, int] :: 상태별 카운트 -- int :: 재진입 카운트 / 사전 할당 수 +- dict[str, int] :: reconcile 결과 {created, retired, tombstoned} ### Error Contract @@ -456,8 +458,8 @@ resume_kill_switch 요청·응답 (REQ-046): - `cancelled :: bool` :: 외부 취소 감지(dcy-013/REQ-012, issue #9). 기본 False. - `state` 값과 집행 처리(dcy-013): - `accepted` / `partially_filled`(취소 아님) :: 다음 tick 대기. (FR-3) `TREX_PENDING_STALE_THRESHOLD`(초, 0=비활성) 초과 시 Alert-only WARN+카운터(상태 미변경). - - `filled` :: 전량 체결 → `pending_buy→holding` / `pending_sell→closed` + 원장 1건. - - `cancelled`(체결 0 외부 취소) :: `is_terminal_reject` → 롤백(`pending_sell→holding` / `pending_buy→idle`), `clear_dispatch` → 자동 재매도 가능. + - `filled` :: 전량 체결 → `pending_buy→holding` / `pending_sell→inactive` + 원장 1건. + - `cancelled`(체결 0 외부 취소) :: `is_terminal_reject` → 롤백(`pending_sell→holding` / `pending_buy→inactive`), `clear_dispatch` → 자동 재매도 가능. - `partially_filled` + `cancelled=True`(부분 체결 후 외부 취소) :: `is_partial_cancel` → 체결분 원장 1회 기록 + 슬롯 qty 차감(매도: 잔여 held / 매수: 체결분만) + `holding` 복귀. 매도는 cost basis(`avg_price`) 유지. - KIS 매핑: `inquire-daily-ccld` 응답 row 의 `cncl_yn="Y"` 를 취소로 해석한다(미지/부재 값은 보수적으로 비취소 처리, stale 워치독이 backstop). @@ -516,3 +518,4 @@ resume_kill_switch 요청·응답 (REQ-046): - 2026-06-04 :: BAT-006 — LedgerPort/LedgerTrade/LedgerWriteResult 계약, LedgerOutbox/LedgerWriter, portfolio-hub `/v1/assets/{asset_id}/ledger/trades` 엔드포인트 + 409→`LedgerConflictError`, FillStatus.execution_id/fee/tax 확장 (REQ-017~020). - 2026-06-05 :: BAT-007 — HTTP `/v1` API 인터페이스 제거(adapters/api + fastapi/uvicorn 의존 정리), RabbitMQ messaging 계약 추가(스냅샷 RPC `trex.snapshot.req`, intent RPC `trex.intent.req`, 이벤트 topic `trex.events`, 전략별 durable queue + TTL/max-length, DLQ, publisher confirms, ack-after-commit), `MessagingTransport` Protocol + `TransportHealth` 노출 (REQ-021~026). - 2026-06-06 :: BAT-008 — `trex.snapshot.req` envelope 계약 정의(`assets[]/view/available_slots/degraded/reason/message`, no price field), `trex.pause.req` 제어 RPC 추가, `PortfolioHubReadPort`/`SnapshotStore`/`PauseStore`/`SnapshotScheduler` 인터페이스, `BrokerPort.get_balance()` 확장, `trex-core` CLI 추가 (REQ-027~033; REQ-030 옵션 D 는 portfolio-hub#8 배포 후). +- 2026-06-10 :: BAT-030 (dcy-020 마감) — buy intent 계약 키 `slot_id` 정본(+`portfolio_id` dual-accept, `trading_date` 제거), 거부 어휘(`unknown_slot`/`already_active`/`capacity_full`/`slot_id_or_portfolio_required`), `slots[]` 5-state + `universe_detached`(trading_date 키 제거), 이벤트 payload trading_date 제거, `available_slots`=admission headroom, SlotService/SlotRepository 자산 중심 계약, 매도 체결 `pending_sell→inactive` (REQ-056~065). diff --git a/docs/project-structure.md b/docs/project-structure.md index 240cc39..785c0c8 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -4,14 +4,14 @@ - Baseline Seed: .stage-pilot/bootstrap/baseline.yaml - Status: draft - Owner: project maintainers -- Last Updated (KST): 2026-06-07 (BAT-013) +- Last Updated (KST): 2026-06-10 (BAT-030 — dcy-020 자산 중심 모델 마감) - Source Discovery / Batch: bootstrap-baseline (pre-Discovery, 3-phase lifecycle 밖의 bootstrap 산출물) ## Project Summary - trex 제품군의 공용 베이스로, 첫 도메인으로 trex-orbit 의 일일 거래 슬롯 생명주기(slot) 순수 도메인을 storage-agnostic 하게 이식한 코어다. ## Primary Domain -- 자동매매 일일 거래 슬롯 생명주기 도메인 (equity trading slot lifecycle) +- 자동매매 **자산 중심 영구 슬롯** 생명주기 도메인 (dcy-020 — universe 자산 1:1 영구 슬롯; 일일 풀 모델은 BAT-030 cutover 로 폐기) ## Tech Stack - Python >=3.12 @@ -23,8 +23,9 @@ ## Primary Runtime - Type: daemon (sync + thread, no HTTP) + library (slot 도메인) - Entry Points: - - `trex-core-daemon --config .toml` (console_script → `trex_core.runtime.daemon:main`) :: 슬롯 코어 데몬 — `--config` 필수(per-instance TOML, BAT-013). migrate+preallocate+reconcile 후 7 백그라운드 스레드(intent RPC consumer / snapshot RPC consumer / control RPC consumer / snapshot scheduler / execution / ledger / safety-sell)를 `instance_key` 큐로 기동, RabbitMQ messaging 으로 전략 모듈과 통신 (BAT-002 + BAT-007 + BAT-008 + BAT-011 + BAT-012 + BAT-013) - - `trex-core` (console_script → `trex_core.runtime.cli:main`) :: 운영 CLI (`pause` / `resume` / `paused`) (BAT-008) + - `trex-core-daemon --config .toml` (console_script → `trex_core.runtime.daemon:main`) :: 슬롯 코어 데몬 — `--config` 필수(per-instance TOML; `max_active_slots`=M 캡). migrate+reconcile 후 7 백그라운드 스레드(intent RPC consumer / snapshot RPC consumer / control RPC consumer / snapshot scheduler[universe 슬롯 reconcile 포함] / execution / ledger / safety-sell)를 `instance_key` 큐로 기동, RabbitMQ messaging 으로 전략 모듈과 통신 (BAT-002 + BAT-007 + BAT-008 + BAT-011 + BAT-012 + BAT-013 + BAT-028/030) + - `trex-core` (console_script → `trex_core.runtime.cli:main`) :: 운영 CLI (`pause`/`resume`/`paused` + `universe`/`slots`/`enter`/`exit`) (BAT-008/020) + - `trex-core-cutover` (console_script → `trex_core.runtime.cutover:main`) :: 풀→자산 중심 cutover 도구 (REQ-064, BAT-030 — 운영자 실행, dry-run 기본) - RabbitMQ `trex.snapshot.req` / `trex.intent.req` / `trex.pause.req` / `trex.events` :: 매수·매도 전략 데몬과의 transport (docs/interface-contract.md) - SlotService / SlotRepository (Protocol) :: in-process 도메인 서비스 + 저장소 경계(라이브러리 사용) - MessagingTransport (Protocol) :: transport 추상화 (RabbitMQ / InMemory stub) @@ -64,7 +65,7 @@ │ ├── __init__.py │ ├── domain/slot/ # slot 순수 도메인 (BAT-001, 무의존) │ │ ├── __init__.py · state.py · models.py(+EXPECTED_SLOT_FIELDS) · errors.py -│ │ ├── repository.py(Protocol) · service.py · in_memory_repository.py +│ │ ├── repository.py(Protocol, instance 스코프) · service.py · in_memory_repository.py │ ├── application/ # 유스케이스 (BAT-002, BAT-004, BAT-006, BAT-007, BAT-008) │ │ ├── ports.py # IntentStore Protocol │ │ ├── intents.py # IntentService / BuyIntent / SellIntent / IntentDecision @@ -75,8 +76,8 @@ │ │ ├── messaging.py # MessagingTransport Protocol + DTO + TransportHealth (BAT-007) │ │ ├── messaging_handlers.py # intent/snapshot/pause RPC handlers (+update_thresholds REQ-040) + slot event payload builder (BAT-007/BAT-008/BAT-011) │ │ ├── portfolio.py # PortfolioHubReadPort + SnapshotStore + PauseStore + envelope builder (BAT-008) -│ │ ├── reconciliation.py # reconcile_positions (hub ↔ broker symbol-aggregate) (BAT-008) -│ │ ├── snapshot_scheduler.py # SnapshotScheduler — daily fetch + swap + reconcile (BAT-008) +│ │ ├── reconciliation.py # reconcile_positions + reconcile_core_holdings(REQ-062) (BAT-008/029) +│ │ ├── snapshot_scheduler.py # SnapshotScheduler — daily fetch + swap + universe 슬롯 reconcile(REQ-058) (BAT-008/028) │ │ ├── watchdog.py # WatchdogStore — per-slot last_seen_at + expiry/grace 판정 (BAT-010 / REQ-034) │ │ ├── safety_sell.py # SafetySellService — forced_close / stop-loss·take-profit / 장 마감 트리거 (BAT-011 / REQ-037~039) │ │ └── killswitch.py # KillSwitchService — 범위별 holding 즉시 청산 + day-scoped 제외 (BAT-012 / REQ-045) @@ -85,11 +86,12 @@ │ │ ├── ledger/ # in_memory.py(stub) · portfolio_hub.py(HTTP 어댑터) (BAT-006) │ │ ├── messaging/ # topology.py(동적 큐명 빌더 + trex.direct/trex.broadcast exchange, BAT-013) · in_memory.py(stub) · rabbitmq.py(pika 동기, declare_topology(instance_key)) (BAT-007/BAT-013) │ │ ├── portfolio/ # hub_read.py(HTTP) · stub_read.py(stub) · pause_store.py(Postgres) · killswitch_store.py(day-scoped, BAT-012) (BAT-008) -│ │ └── persistence/ # postgresql.py(+PostgreSQLLedgerOutbox) · in_memory_intent_store.py · migrate.py · sql/(001_…, 002_create_ledger_outbox.sql, 003_create_snapshot_pause.sql, 004_add_price_thresholds.sql, 005_create_killswitch_exclusion.sql) +│ │ └── persistence/ # postgresql.py(+PostgreSQLLedgerOutbox) · ops_checks.py(ledger-gap, transition_log 기반) · in_memory_intent_store.py · migrate.py · sql/(001~009, 010_literal_inactive_status.sql, 011_drop_pool_model_columns.sql) │ └── runtime/ # 데몬 (BAT-002, BAT-004, BAT-006, BAT-007, BAT-008) -│ ├── config.py # DaemonConfig(+instance_key) + KISConfig + PortfolioHubConfig + RabbitMQConfig + SnapshotScheduleConfig + SafetySellConfig + DaemonInstanceConfig.from_toml/from_config (env + per-instance TOML, BAT-013) -│ ├── daemon.py # Daemon class: `--config` TOML 진입점(BAT-013) + setup(migrate/preallocate/reconcile/declare-topology(instance_key)) + 7 worker threads(instance_key 큐 구독) + WatchdogStore + KillSwitchService + graceful shutdown -│ └── cli.py # trex-core CLI: pause/resume/paused subcommands (BAT-008 / REQ-032) +│ ├── config.py # DaemonConfig(+instance_key, max_active_slots) + KISConfig + PortfolioHubConfig + RabbitMQConfig + SnapshotScheduleConfig + SafetySellConfig + DaemonInstanceConfig.from_toml/from_config (env + per-instance TOML, BAT-013/028/030) +│ ├── daemon.py # Daemon class: `--config` TOML 진입점(BAT-013) + setup(migrate/reconcile/declare-topology(instance_key)) + 7 worker threads(instance_key 큐 구독) + WatchdogStore + KillSwitchService + graceful shutdown +│ ├── cutover.py # trex-core-cutover: 풀→자산 중심 전체 rebuild cutover (REQ-064, BAT-030) +│ └── cli.py # trex-core CLI: pause/resume/paused + universe/slots/enter/exit (BAT-008/020 / REQ-032) └── tests/ ├── domain/slot/ # test_state / test_service / test_contract ├── domain/ # test_domain_import_sweep (NFR-1 가드, BAT-006/BAT-007) @@ -103,7 +105,7 @@ ## Package / Module Responsibilities - `trex_core/domain/slot/` - - 슬롯 생명주기 순수 도메인. 6 상태 머신(`state.py`), 데이터 모델 + 컬럼 계약(`models.py`, `EXPECTED_SLOT_FIELDS` 포함), 예외(`errors.py`), 저장소 계약(`repository.py` Protocol), 도메인 서비스(`service.py`), in-memory 구현(`in_memory_repository.py`). + - 자산 중심 영구 슬롯 순수 도메인. 5 상태 머신(`state.py`, inactive 기준 + 인스턴스 스코프 advisory lock id), 데이터 모델 + 컬럼 계약(`models.py`, `EXPECTED_SLOT_FIELDS` 포함), 예외(`errors.py`), 저장소 계약(`repository.py` Protocol — instance 스코프 list/count/reconcile), 도메인 서비스(`service.py`), in-memory 구현(`in_memory_repository.py`). - 외부 의존 0 — stdlib 와 패키지 내부 참조만 사용(정적 import 스윕으로 확인됨). - `tests/domain/slot/` - 상태머신/서비스 단위 테스트(`test_state`, `test_service`) + 컬럼 계약 가드(`test_contract`). InMemorySlotRepository 기반으로 실제 DB 없이 검증. @@ -121,7 +123,7 @@ ## Shared Boundaries - `SlotRepository` Protocol 이 도메인 ↔ 저장소 사이의 단일 경계다. -- `SLOT_ADVISORY_LOCK_ID` 상수가 모든 상태 변경 직렬화의 공유 기준점이다. +- `slot_advisory_lock_id(instance_key)` 가 상태 변경 직렬화의 공유 기준점이다(인스턴스 스코프 — 공유 DB 인스턴스 간 격리, REQ-057). ## Current Gaps / Planned Changes @@ -150,3 +152,4 @@ - 2026-06-07 :: BAT-011 — safety-sell 트리거 레이어(`application/safety_sell.py` SafetySellService 신설 — forced_close/stop-loss·take-profit/장 마감, `BrokerPort.get_current_price()` + KIS `inquire-price`(`FHKST01010100`, CHG-04 정정)·paper stub, `SlotService.update_thresholds()` + intent RPC `update_thresholds` 액션, runtime/daemon 을 7 스레드로 확장(`trex-safety-sell`) + WatchdogStore 배선) (REQ-037~040, 042). - 2026-06-07 :: BAT-012 — 비상 정지/kill-switch(`trex.pause.req`→`trex.control.req` 제어 채널 + action dispatch, `application/killswitch.py` KillSwitchService + `adapters/portfolio/killswitch_store.py` + `005_create_killswitch_exclusion.sql` day-scoped 제외, snapshot masking 에 killswitch_today 추가, `make_control_rpc_handler` + kill_switch/resume_kill_switch, daemon control consumer 전환) (REQ-043~047). - 2026-06-07 :: BAT-013 — 다중 인스턴스 RabbitMQ 토폴로지(`topology.py` 동적 큐명 빌더 `intent/snapshot/control_queue(instance_key)` default 리터럴 하위호환 + `trex.direct`(topic)/`trex.broadcast`(fanout) exchange + `direct_routing_key`, `rabbitmq.declare_topology(instance_key)` per-instance 큐/바인딩+control dual, `config.DaemonInstanceConfig.from_toml`+`DaemonConfig.from_config`+`instance_key` 필드, daemon `--config` 필수 진입점 + instance_key 큐 구독, `.gitignore` 인스턴스 TOML + `config/instance.example.toml`) (REQ-048~051). +- 2026-06-10 :: BAT-030 (dcy-020 마감) — 자산 중심 전환: `runtime/cutover.py` + `trex-core-cutover` 콘솔 스크립트 신설, `application/preallocation_scheduler.py` 삭제(일일 preallocation 폐기), 마이그레이션 010(inactive CHECK)/011(trading_date·entry_count drop), repo 계약 instance 스코프 개편, `ops_checks.py` transition_log 기반 재작성, config 에서 `total_slots`/`daily_preallocation_time` 제거 (REQ-056~065). diff --git a/docs/runtime-flows.md b/docs/runtime-flows.md index c736508..09b9960 100644 --- a/docs/runtime-flows.md +++ b/docs/runtime-flows.md @@ -4,7 +4,7 @@ - Baseline Seed: .stage-pilot/bootstrap/baseline.yaml - Status: draft - Owner: project maintainers -- Last Updated (KST): 2026-06-08 (BAT-023) +- Last Updated (KST): 2026-06-10 (BAT-030 — dcy-020 자산 중심 모델 마감) - Source Discovery / Batch: bootstrap-baseline (pre-Discovery); 데몬/HTTP 흐름 = dcy-002 / BAT-002 - Primary Runtime: daemon (HTTP API) + library (slot 도메인) @@ -22,25 +22,25 @@ ## Covered Entry Points - `trex-core-daemon` :: 슬롯 코어 데몬 (lifecycle + RabbitMQ 통신, BAT-002/BAT-007) +- `trex-core-cutover` :: 풀→자산 중심 cutover 도구 (REQ-064, 운영자 실행 — preflight/전체 rebuild/브로커 잔고 carry-over/`--promote-unique`) - RabbitMQ `trex.snapshot.req` :: 전략 모듈의 스냅샷 RPC (request-reply, BAT-007) - RabbitMQ `trex.intent.req` :: 전략 모듈의 매수/매도 admission RPC (request-reply, BAT-007) - RabbitMQ `trex.events` :: 체결/슬롯 상태 이벤트 topic exchange (pub/sub, BAT-007) -- SlotService :: 슬롯 생명주기 도메인 서비스 (preallocate / transit / capacity / entry-count) +- SlotService :: 슬롯 생명주기 도메인 서비스 (transit / sell / thresholds / capacity — 자산 중심, BAT-030) - SlotRepository (Protocol) :: 저장소 구현체 주입 경계 - MessagingTransport (Protocol) :: 메시지 transport 추상화 (RabbitMQ / InMemory stub) ## Flow: `데몬 lifecycle` (BAT-002, BAT-007) -1. `trex-core-daemon --config .toml` 기동 → `DaemonConfig.from_config(toml)` = `from_env()`(TREX_POSTGRESQL_DSN/`TREX_RABBITMQ_URL` 등) + TOML(`instance_key`, `portfolio_hub_token`, `trading_timezone`, `daily_preallocation_time`) 병합. `--config` 필수(없으면 오류 종료, BAT-013/REQ-048). `instance_key` 로 큐명·라우팅 분리. -2. setup: `apply_migrations(dsn)`(멱등) → `preallocation_scheduler.run_once()`(거래일 TZ 기준 당일 슬롯 `preallocate_daily_slots`, 멱등 — dcy-014/REQ-005) → `execution.reconcile()`(REQ-014) → 이벤트 publisher 의 `declare_topology()`. -3. start: 8개 백그라운드 스레드 동시 기동 - - `trex-execution` :: 브로커 dispatch/poll 루프 (REQ-013/014) +1. `trex-core-daemon --config .toml` 기동 → `DaemonConfig.from_config(toml)` = `from_env()`(TREX_POSTGRESQL_DSN/`TREX_RABBITMQ_URL` 등) + TOML(`instance_key`, `portfolio_hub_token`, `trading_timezone`, `max_active_slots`) 병합. `--config` 필수(없으면 오류 종료, BAT-013/REQ-048). `instance_key` 로 큐명·라우팅·DB 행 스코프 분리(REQ-056). +2. setup: `apply_migrations(dsn)`(멱등; 010 inactive CHECK·011 컬럼 drop 포함) → `execution.reconcile()`(REQ-014) → 이벤트 publisher 의 `declare_topology()`. (일일 preallocation 은 BAT-030 에서 폐기 — 슬롯 생성은 universe reconcile 이 담당.) +3. start: 7개 백그라운드 스레드 동시 기동 + - `trex-execution` :: 브로커 dispatch/poll 루프 (REQ-013/014; 매도 수량 브로커 정본 REQ-062) - `trex-ledger` :: ledger outbox 드레인 (REQ-019) - `trex-intent-rpc` :: `trex.intent.req` 소비자 (prefetch=1, FIFO, dedup via intent_log; `update_thresholds` 액션 포함 REQ-040) - `trex-snapshot-rpc` :: `trex.snapshot.req` 소비자 (응답 holding 슬롯마다 워치독 `last_seen_at` 갱신 REQ-034) - `trex-control-rpc` :: `trex.control.req` 소비자 — pause/resume/list + kill_switch/resume_kill_switch (REQ-032/043) - - `trex-snapshot-scheduler` :: 일일 portfolio-hub 스냅샷 refresh (REQ-030) - - `trex-preallocation` :: 거래일 TZ 일자 경계 일일 슬롯 preallocation (dcy-014/REQ-005, 아래 흐름) + - `trex-snapshot-scheduler` :: 일일 portfolio-hub 스냅샷 refresh + **universe 슬롯 reconcile**(REQ-058) + holding 불일치 reconcile(REQ-062) - `trex-safety-sell` :: 자율 안전 매도 트리거 루프 (forced_close / stop-loss·take-profit / 장 마감, REQ-037/038/039) 4. shutdown: `SIGINT`/`SIGTERM` → `stop.set()` → 모든 스레드 join + 모든 transport `close()`. HTTP 표면 0, 별도 포트 바인딩 없음. @@ -56,7 +56,7 @@ 1. 전략 모듈이 RPC 요청을 `trex.intent.req` 큐로 publish — payload `{intent_type, intent_id, ...}`, properties `correlation_id` = intent 멱등키, `reply_to`. 2. `trex-intent-rpc` 스레드(단일, prefetch=1)가 메시지 수신 → `IntentService.submit_buy/sell` 호출. 3. `intent_log.try_insert(intent_id, ...)` 가 dedup 게이트 — 중복 메시지는 기존 decision 재반환(슬롯 무전이). -4. 신규면 admission: **portfolio_id 필수 검사**(누락 시 `portfolio_id_required` 거부) → **symbol resolve**(`SnapshotStore.get(portfolio_id).symbol`, universe 부재 시 `unknown_portfolio` 거부, dcy-011/REQ-007) → `find_allocatable_slot`(idle ∪ closed 후보를 `entry_count` asc → `updated_at` asc → `slot_id` 정렬, 최저 진입 우선; dcy-019/REQ-006/REQ-007) → `SlotService.transit_slot(slot.status→pending_buy, symbol=resolved, portfolio_id)`(후보가 closed 면 from_state=closed 직접 재할당) → Postgres durable commit. 후보 소진 시 `no_idle_slot` 거부. (buy payload 에 client symbol 없음) +4. 신규면 admission(자산 중심, REQ-059/061): **slot_id 정본 키**(미스 시 `unknown_slot`) 또는 **portfolio_id dual-accept**(미스 시 `unknown_portfolio`; 둘 다 없으면 `slot_id_or_portfolio_required`) 로 대상 영구 자산 슬롯 resolve → 이미 active(holding/pending) 면 `already_active` → symbol resolve(슬롯 `portfolio_id` 기준 universe) → `count_active >= max_active_slots(M)` 면 `capacity_full`(FCFS·무치환) → `SlotService.transit_slot(inactive→pending_buy)` → Postgres durable commit. (buy payload 에 client symbol·trading_date 없음) 5. admission 응답(`status, slot_id?, reason?`)을 `reply_to` 큐로 publish → manual ack. 6. ack-after-commit 시퀀스: durable commit 이 ack 보다 먼저이므로 ack 전 크래시 시 재전달돼도 (3) 의 dedup 으로 재실행되지 않는다. 7. 체결/종료는 응답이 아니라 후속 이벤트(아래)로 전달 — admission 동기 + fill 비동기 2단계 시맨틱. @@ -80,14 +80,14 @@ 3. fire 단계: `PortfolioHubReadPort.whoami()` 로 daemon_id 도출(1회 캐시) → `get_portfolios(daemon_id)` 로 universe 페치 → `SnapshotStore.swap(items)` atomic 교체 → `reconcile_positions(store, broker)` 정합 검증. 4. 실패 분기: `PortfolioHubUnavailableError` → `degraded = portfolio_hub_unavailable`; 응답 파싱 실패 → `snapshot_fetch_failed`. 둘 다 다음 RPC 응답 envelope 에 즉시 노출(REQ-028 AC-2). -## Flow: `일일 슬롯 preallocation` (BAT-018 / REQ-005, dcy-014) +## Flow: `universe 슬롯 reconcile` (BAT-028/030 / REQ-058 — 일일 preallocation 대체) -`trex-preallocation` 스레드가 설정 타임존(`trading_timezone`, 기본 `Asia/Seoul`) 기준 `trading_date` 로 매 TZ 일자 1회 멱등 슬롯 생성을 수행한다. `trading_date` 만 TZ 기준이고 모든 timestamp(`created_at`/`updated_at`/전이 ts)는 UTC 저장을 유지한다(NFR-1). `SnapshotScheduler` 패턴 준용. +`trex-snapshot-scheduler` 가 universe fetch 직후 같은 패스에서 영구 자산 슬롯 집합을 universe 에 정합시킨다 (일일 preallocation/dcy-014 는 BAT-030 에서 폐기). -1. boot: `DailyPreallocationScheduler.run_once()` 1회 → 현재 TZ 일자의 `preallocate_daily_slots(tz_today, total_slots)`(멱등) → 기동 즉시 당일 슬롯 확보. setup 단계에서 호출(데몬 lifecycle 2). -2. tick(60s): 현재 TZ 시각이 `daily_preallocation_time`(기본 00:00, TOML 설정) 이상이고 그날 아직 안 돌았으면(`_last_fired_date != tz_today`) fire → 다음날 슬롯 자동 생성(재시작 불필요, NFR-3). -3. 멱등성: `preallocate_daily_slots` 가 동일 `trading_date` 2회차 이후 0 반환 → 중복 tick·다중 인스턴스에서도 중복 생성 0(NFR-2). -4. 비거래일: 거래일 캘린더 없이 매 캘린더일 생성(dcy-014 OQ-2). idle 슬롯은 buy admit 시에만 활성화되어 휴장일엔 무해. +1. 신규 자산(portfolio_id 에 슬롯 없음) → `inactive` 영구 슬롯 생성(멱등 — `ON CONFLICT (instance_key, portfolio_id)` partial unique). +2. universe 이탈 + inactive → soft-retire(`retired_at` set; 행 보존, 운영 뷰 `universe_detached`). 이탈 + holding/pending → tombstone(그대로 유지 — safety-sell 경로 보존). +3. 가드: universe 빈 응답이면 reconcile skip(degrade 가드); 직전 대비 ≥50% 축소면 retire 보류(`allow_retire=False`, 대량 소실 가드 — dcy-020 OQ-2 D3). +4. 이어서 `reconcile_core_holdings`(REQ-062): 브로커 잔고 ground truth 로 core holding 검증 — core holding/브로커 0 → slot `error` 격리+WARN, 수량 불일치 → WARN(상태 불변), 브로커 보유/슬롯 없음 → WARN(unmanaged). 자동 청산 없음. ## Flow: `포지션 정합 검증` (BAT-008 / REQ-031) @@ -122,7 +122,7 @@ `trex.snapshot.req` 메시지 처리. 1. 요청 payload 디코드: `{view ∈ {all, buyable, held}, strategy_key?, trading_date?}`. -2. `slot_service.compute_slot_capacity(today, total_slots)` → `available_slots = idle_count`. +2. `slot_service.compute_slot_capacity(max_active_slots)` → `available_slots = available_count`(= M 설정 시 `max(0, M − active)`, 미설정 시 inactive 수; REQ-059). 3. `build_snapshot_envelope(store, pause_store, slot_repo, view, strategy_key, available_slots, trading_date)`: - base 집합: ALL = `universe ∪ holding`, BUYABLE = `universe − (pending ∪ holding)`(`available_slots=0` 이면 `[]`), HELD = `holding 슬롯만`. - 마스킹: pending(slot) OR warning(REQ-031) OR pause(REQ-032) — 어느 하나라도면 제외. slot overlay > portfolio-hub 보유 여부(불변식 A). @@ -217,15 +217,16 @@ ## Flow: `주문 집행 루프` (백그라운드, BAT-004) 데몬 lifecycle 에서 시작되는 단일 백그라운드 execution 루프가 주기적으로(설정 간격) 수행: -1. dispatch: `find_pending_for_dispatch`(status pending_* AND `broker_order_id IS NULL`, `FOR UPDATE SKIP LOCKED`) → `BrokerPort.submit_order` → `mark_dispatched(broker_order_id)`(멱등 가드). 거부 시 `pending_buy→idle` / `pending_sell→holding` 롤백. +1. dispatch: `list_undispatched_pending(instance_key)`(status pending_* AND `broker_order_id IS NULL`; 인스턴스 스코프, REQ-056) → `BrokerPort.submit_order` → `mark_dispatched(broker_order_id)`(멱등 가드). 거부 시 `pending_buy→inactive` / `pending_sell→holding` 롤백. + - **매도 수량 = 브로커 잔고 정본**(dcy-020/REQ-062, D3): 매도 dispatch 는 `broker.get_balance()` 의 해당 symbol `hldg_qty` 로 수량을 정한다(슬롯 미러 `qty` fallback 없음). 브로커 0 이면 주문 미제출 + WARN + `sell_skipped_no_balance` 카운터(divergence 는 reconcile 경로로 표면화). skip 된 매도가 큐를 막지 않도록 리스트 기반 dispatch. - 가격/주문유형 결정(dcy-012/REQ-013, issue #10): `_dispatch` 는 side 분기 없이 슬롯 `limit_price` **한 필드**만 읽는다 — 값이 있으면 지정가(`order_type="00"`, 그 가격), 없으면 시장가(`order_type="01"`). `limit_price` 는 pending 진입 시 set 되고 pending 이탈(→holding/→closed) 시 clear 되므로, 매수 지정가(과거 `entry_price`)가 매도 주문에 재사용되지 않는다. 자율 매도(safety-sell/kill-switch)는 가격 미전달 → 시장가로 즉시 체결(미체결 `pending_sell` 갇힘 방지, issue #9 연계). -2. poll: `list_dispatched_pending`(broker_order_id NOT NULL) → `BrokerPort.poll_fill` → 체결 시 `pending_buy→holding`(avg_price/qty) / `pending_sell→closed`(+재진입), 거부 시 롤백. 전이 후 `clear_dispatch`. +2. poll: `list_dispatched_pending(instance_key)`(broker_order_id NOT NULL) → `BrokerPort.poll_fill` → 체결 시 `pending_buy→holding`(avg_price/qty) / `pending_sell→inactive`(점유 초기화·자산 정체성 보존), 거부 시 롤백. 전이 후 `clear_dispatch`. - 외부 취소 감지(dcy-013/REQ-012/013, issue #9): 계좌에서 직접 취소된 주문을 KIS `poll_fill` 이 `cncl_yn="Y"` 로 파싱한다. **체결 0 취소** → `FillStatus(state="cancelled")` → 기존 롤백 경로(`pending_sell→holding`, `clear_dispatch`)로 자동 복구(영구 갇힘 제거). **부분 체결 후 취소**(`is_partial_cancel`) → 체결분 원장 1회 기록 + 슬롯 qty 차감(매도: 잔여 held, cost basis 유지 / 매수: 체결분만) + `holding` 복귀(OQ-1 A안, 과매도·이중기록 방지). - pending stale 워치독(dcy-013/REQ-014 FR-3, Alert-only): `accepted`/`partially_filled`(취소 아님)로 다음 tick 대기 중인 슬롯이 `TREX_PENDING_STALE_THRESHOLD`(초, 0=비활성)를 초과해 머물면 WARN 로그 + `ExecutionResult.stale_pending` 카운터로 가시화한다. **상태는 변경하지 않는다**(auto-remediation 후속) — 취소 감지 누락(R-1) 등 미지 원인의 무한 갇힘이 silent 로 남지 않게 하는 안전망. 3. reconcile(재기동): 서빙 전 `list_dispatched_pending` 재폴링으로 크래시 후 복구(중복 주문 0). 미디스패치(broker_order_id NULL) 슬롯은 다음 dispatch 가 1회만 송신. - 모든 슬롯 쓰기는 SlotService(advisory lock) 단일 경로(불변식 A). 브로커 주문/취소는 BrokerPort 단일 세션만(불변식 B). -- 슬롯 `limit_price` 생애주기(dcy-012/REQ-002): IDLE→PENDING_BUY(매수 지정가 set) → PENDING_BUY→HOLDING(clear) → HOLDING→PENDING_SELL(매도 지정가 set 또는 None=시장가) → PENDING_SELL→CLOSED(clear). 롤백·복구(→IDLE) 전이에서도 clear. 외부 취소 후 holding 복귀에서도 clear 되어 재매도는 시장가. +- 슬롯 `limit_price` 생애주기(dcy-012/REQ-002): INACTIVE→PENDING_BUY(매수 지정가 set) → PENDING_BUY→HOLDING(clear) → HOLDING→PENDING_SELL(매도 지정가 set 또는 None=시장가) → PENDING_SELL→INACTIVE(clear). 롤백(→INACTIVE) 전이에서도 clear. 외부 취소 후 holding 복귀에서도 clear 되어 재매도는 시장가. - 어댑터: paper(결정적 async 더블) / KIS(모의투자 검증, 실계좌 게이팅). ## Flow: `체결 → ledger outbox` (BAT-006) @@ -258,28 +259,19 @@ ## Shared Components -- SlotRepository Protocol :: 저장소 추상화. 구현체(InMemorySlotRepository, 향후 PostgreSQLSlotRepository)를 주입. -- SLOT_ADVISORY_LOCK_ID :: 모든 상태 변경을 직렬화하는 공유 advisory lock 식별자. +- SlotRepository Protocol :: 저장소 추상화(자산 중심: list/count/reconcile 모두 instance_key 스코프). 구현체(InMemorySlotRepository, PostgreSQLSlotRepository)를 주입. +- slot_advisory_lock_id(instance_key) :: 인스턴스 스코프 advisory lock 식별자 — 공유 DB 의 인스턴스 간 직렬화 격리(REQ-057 NFR-1). - transactional_advisory_lock :: 단일 트랜잭션 + advisory lock 컨텍스트(외부 I/O 금지). -## Flow: `slot 사전 할당 (preallocate_daily_slots)` - -1. 호출자가 `preallocate_daily_slots(trading_date, count)` 를 호출한다(count > 0 검증). -2. SlotService 가 `transactional_advisory_lock(SLOT_ADVISORY_LOCK_ID)` 를 획득한다. -3. 저장소가 해당 trading_date 에 슬롯이 이미 있으면 0 을 반환(멱등), 없으면 `count` 개 idle 슬롯을 삽입한다. -4. 삽입된 슬롯 수를 반환한다. - -## Flow: `slot 상태 전이 (transit_slot)` +## Flow: `slot 상태 전이 (transit_slot)` (BAT-030 자산 중심) 1. 호출자가 `transit_slot(slot_id, from_state, to_state, reason, actor, **fields)` 를 호출한다. -2. (from_state, to_state) 가 전이 패밀리(8 base[dcy-019 closed→pending_buy 포함] + *→error + error→{idle,holding})에 속하는지 먼저 검증한다(아니면 SlotTransitionError). -3. advisory lock 획득 후 슬롯을 fetch 한다(없으면 SlotNotFoundError). +2. (from_state, to_state) 가 전이 패밀리(6 base + *→error + error→{inactive,holding} = 12)에 속하는지 먼저 검증한다(아니면 SlotTransitionError). +3. 인스턴스 스코프 advisory lock(`slot_advisory_lock_id(instance_key)`) 획득 후 슬롯을 fetch 한다(없으면 SlotNotFoundError). 4. 현재 status == from_state 인지 확인한다(아니면 SlotStateMismatchError — 레이스 감지). -5. to_state 의미에 맞춰 점유 필드를 계산하고 슬롯 행을 update 한다. (closed/pending_buy -> idle 은 점유 초기화; closed -> pending_buy 직접 재할당도 점유 필드를 clean-slate 초기화 후 새 진입 필드 적용) -6. `→pending_buy`(idle→ / closed→ 모두) 면 슬롯 `entry_count` 를 +1 한다(dcy-019/REQ-002). -7. closed -> idle 복구면 (trading_date, symbol) 재진입 카운트를 +1 한다. -8. slot_transition_log 에 전이 기록 1행을 append 하고 갱신된 슬롯을 반환한다. -9. 외부 I/O(브로커 등)는 lock 밖에서 호출자가 수행한다(lock 보유 중 금지). +5. to_state 의미에 맞춰 필드를 계산하고 슬롯 행을 update 한다 — **INACTIVE 진입 시 포지션·주문 점유 필드 초기화 + 자산 정체성(instance_key/portfolio_id/symbol/retired_at) 보존**. +6. slot_transition_log 에 전이 기록 1행을 append 하고 갱신된 슬롯을 반환한다. +7. 외부 I/O(브로커 등)는 lock 밖에서 호출자가 수행한다(lock 보유 중 금지). ## Flow Constraints @@ -309,3 +301,4 @@ - 2026-06-04 :: BAT-006 — ledger 통합 반영(체결→ledger outbox enqueue, LedgerWriter retry/dedup/409 흐름, FillStatus.execution_id/fee 운반, slot 전이와 ledger write 분리) (REQ-017~020). - 2026-06-05 :: BAT-007 — transport 전환(HTTP `/v1` API 제거, RabbitMQ 메시징 도입: 스냅샷/intent RPC + 이벤트 topic, sync+thread daemon, intent_log 멱등 ack-after-commit, publisher confirms + DLQ, per-strategy durable queue + TTL/max-length, broker degrade 분기) (REQ-021~026). - 2026-06-06 :: BAT-008 — portfolio-hub 스냅샷 read 레이어 추가(`PortfolioHubReadPort` + hub_read/stub_read 어댑터, `SnapshotStore` in-memory + `snapshot_pause` Postgres, `SnapshotScheduler` 일일 refresh, `reconcile_positions` 정합 검증 + `BrokerPort.get_balance()` 확장, `trex.snapshot.req` envelope 응답 + degraded 신호, `trex.pause.req` 제어 RPC, `trex-core` CLI) (REQ-027~033; 옵션 D AC-2/AC-3 은 portfolio-hub#8 배포 후 별도 구현). +- 2026-06-10 :: BAT-030 (dcy-020 마감) — 자산 중심 전환 완료: 일일 preallocation 폐기(→universe reconcile), intent admission slot_id 정본 + M 캡, 집행 매도 수량 브로커 정본 + 인스턴스 스코프 dispatch, 5-state(inactive) 전이, cutover 도구(`trex-core-cutover`) (REQ-056~065). diff --git a/pyproject.toml b/pyproject.toml index 26156fb..9ea9cb3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ dev = [ [project.scripts] trex-core-daemon = "trex_core.runtime.daemon:main" trex-core = "trex_core.runtime.cli:main" +trex-core-cutover = "trex_core.runtime.cutover:main" [build-system] requires = ["setuptools>=68"] diff --git a/tests/adapters/messaging/test_rabbitmq_intent_e2e.py b/tests/adapters/messaging/test_rabbitmq_intent_e2e.py index 83256c0..327d7d5 100644 --- a/tests/adapters/messaging/test_rabbitmq_intent_e2e.py +++ b/tests/adapters/messaging/test_rabbitmq_intent_e2e.py @@ -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() diff --git a/tests/adapters/persistence/test_ops_checks.py b/tests/adapters/persistence/test_ops_checks.py index 204ce28..047dda8 100644 --- a/tests/adapters/persistence/test_ops_checks.py +++ b/tests/adapters/persistence/test_ops_checks.py @@ -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} diff --git a/tests/adapters/persistence/test_postgresql_repository.py b/tests/adapters/persistence/test_postgresql_repository.py index a1c7b6f..8e6e99e 100644 --- a/tests/adapters/persistence/test_postgresql_repository.py +++ b/tests/adapters/persistence/test_postgresql_repository.py @@ -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") == [] diff --git a/tests/application/test_asset_centric_admission.py b/tests/application/test_asset_centric_admission.py index 3c47c40..2328353 100644 --- a/tests/application/test_asset_centric_admission.py +++ b/tests/application/test_asset_centric_admission.py @@ -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 diff --git a/tests/application/test_buy_admission_reallocation.py b/tests/application/test_buy_admission_reallocation.py deleted file mode 100644 index f1ed2ba..0000000 --- a/tests/application/test_buy_admission_reallocation.py +++ /dev/null @@ -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 diff --git a/tests/application/test_execution.py b/tests/application/test_execution.py index 4030fea..2793ad1 100644 --- a/tests/application/test_execution.py +++ b/tests/application/test_execution.py @@ -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" diff --git a/tests/application/test_execution_cancel.py b/tests/application/test_execution_cancel.py index a9eb55b..0338083 100644 --- a/tests/application/test_execution_cancel.py +++ b/tests/application/test_execution_cancel.py @@ -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 diff --git a/tests/application/test_execution_ledger.py b/tests/application/test_execution_ledger.py index a2506b0..8b6b771 100644 --- a/tests/application/test_execution_ledger.py +++ b/tests/application/test_execution_ledger.py @@ -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() diff --git a/tests/application/test_holding_divergence.py b/tests/application/test_holding_divergence.py index 286a2a3..ab7ea5f 100644 --- a/tests/application/test_holding_divergence.py +++ b/tests/application/test_holding_divergence.py @@ -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) diff --git a/tests/application/test_intent_concurrency.py b/tests/application/test_intent_concurrency.py index 9726764..e6c123a 100644 --- a/tests/application/test_intent_concurrency.py +++ b/tests/application/test_intent_concurrency.py @@ -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) diff --git a/tests/application/test_killswitch_execution.py b/tests/application/test_killswitch_execution.py index 2f05a58..de613a2 100644 --- a/tests/application/test_killswitch_execution.py +++ b/tests/application/test_killswitch_execution.py @@ -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"} diff --git a/tests/application/test_messaging_event_publisher.py b/tests/application/test_messaging_event_publisher.py index f6cec85..a3e704e 100644 --- a/tests/application/test_messaging_event_publisher.py +++ b/tests/application/test_messaging_event_publisher.py @@ -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 diff --git a/tests/application/test_messaging_intent_consumer.py b/tests/application/test_messaging_intent_consumer.py index 78a687e..fc9c951 100644 --- a/tests/application/test_messaging_intent_consumer.py +++ b/tests/application/test_messaging_intent_consumer.py @@ -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" diff --git a/tests/application/test_messaging_snapshot_consumer.py b/tests/application/test_messaging_snapshot_consumer.py index 113e94e..2ad560d 100644 --- a/tests/application/test_messaging_snapshot_consumer.py +++ b/tests/application/test_messaging_snapshot_consumer.py @@ -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) diff --git a/tests/application/test_preallocation_scheduler.py b/tests/application/test_preallocation_scheduler.py deleted file mode 100644 index 2b44373..0000000 --- a/tests/application/test_preallocation_scheduler.py +++ /dev/null @@ -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()) diff --git a/tests/application/test_safety_sell_visibility.py b/tests/application/test_safety_sell_visibility.py index 7ab625a..89bd48d 100644 --- a/tests/application/test_safety_sell_visibility.py +++ b/tests/application/test_safety_sell_visibility.py @@ -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) diff --git a/tests/application/test_safety_sell_worker.py b/tests/application/test_safety_sell_worker.py index 34a79b9..891f6c8 100644 --- a/tests/application/test_safety_sell_worker.py +++ b/tests/application/test_safety_sell_worker.py @@ -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, diff --git a/tests/application/test_sell_pricing.py b/tests/application/test_sell_pricing.py index 10f314c..981e5d8 100644 --- a/tests/application/test_sell_pricing.py +++ b/tests/application/test_sell_pricing.py @@ -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 ) ) diff --git a/tests/application/test_snapshot_handler.py b/tests/application/test_snapshot_handler.py index 29b62a8..0e873d9 100644 --- a/tests/application/test_snapshot_handler.py +++ b/tests/application/test_snapshot_handler.py @@ -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", diff --git a/tests/application/test_snapshot_rpc_watchdog.py b/tests/application/test_snapshot_rpc_watchdog.py index c54c5b6..30d58fc 100644 --- a/tests/application/test_snapshot_rpc_watchdog.py +++ b/tests/application/test_snapshot_rpc_watchdog.py @@ -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( diff --git a/tests/application/test_snapshot_selection.py b/tests/application/test_snapshot_selection.py index e2fbad1..8574c78 100644 --- a/tests/application/test_snapshot_selection.py +++ b/tests/application/test_snapshot_selection.py @@ -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 diff --git a/tests/application/test_update_thresholds_handler.py b/tests/application/test_update_thresholds_handler.py index c471f59..7bb81fb 100644 --- a/tests/application/test_update_thresholds_handler.py +++ b/tests/application/test_update_thresholds_handler.py @@ -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): diff --git a/tests/domain/slot/test_price_threshold_fields.py b/tests/domain/slot/test_price_threshold_fields.py index 186d6d9..a82e4a9 100644 --- a/tests/domain/slot/test_price_threshold_fields.py +++ b/tests/domain/slot/test_price_threshold_fields.py @@ -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 diff --git a/tests/domain/slot/test_service.py b/tests/domain/slot/test_service.py index 9da9217..07ef915 100644 --- a/tests/domain/slot/test_service.py +++ b/tests/domain/slot/test_service.py @@ -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 diff --git a/tests/domain/slot/test_service_sell.py b/tests/domain/slot/test_service_sell.py index f0df5b0..558b642 100644 --- a/tests/domain/slot/test_service_sell.py +++ b/tests/domain/slot/test_service_sell.py @@ -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"), diff --git a/tests/domain/slot/test_slot_entry_count.py b/tests/domain/slot/test_slot_entry_count.py deleted file mode 100644 index 702333e..0000000 --- a/tests/domain/slot/test_slot_entry_count.py +++ /dev/null @@ -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 diff --git a/tests/domain/slot/test_state.py b/tests/domain/slot/test_state.py index 507131a..088a1d6 100644 --- a/tests/domain/slot/test_state.py +++ b/tests/domain/slot/test_state.py @@ -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 diff --git a/tests/domain/slot/test_universe_reconcile.py b/tests/domain/slot/test_universe_reconcile.py index c68b8c5..37b69c5 100644 --- a/tests/domain/slot/test_universe_reconcile.py +++ b/tests/domain/slot/test_universe_reconcile.py @@ -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 diff --git a/tests/runtime/test_cli_operator.py b/tests/runtime/test_cli_operator.py index 7b4f2c7..453f775 100644 --- a/tests/runtime/test_cli_operator.py +++ b/tests/runtime/test_cli_operator.py @@ -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", diff --git a/tests/runtime/test_config_dotenv.py b/tests/runtime/test_config_dotenv.py index 58e9e56..55cfc94 100644 --- a/tests/runtime/test_config_dotenv.py +++ b/tests/runtime/test_config_dotenv.py @@ -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): diff --git a/tests/runtime/test_cutover_rehearsal.py b/tests/runtime/test_cutover_rehearsal.py new file mode 100644 index 0000000..36ea121 --- /dev/null +++ b/tests/runtime/test_cutover_rehearsal.py @@ -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 diff --git a/tests/runtime/test_instance_config.py b/tests/runtime/test_instance_config.py index b92ea50..1d1c212 100644 --- a/tests/runtime/test_instance_config.py +++ b/tests/runtime/test_instance_config.py @@ -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(): diff --git a/trex_core/adapters/persistence/ops_checks.py b/trex_core/adapters/persistence/ops_checks.py index 5186da0..a34888d 100644 --- a/trex_core/adapters/persistence/ops_checks.py +++ b/trex_core/adapters/persistence/ops_checks.py @@ -1,10 +1,13 @@ """Operational integrity checks — read-only (REQ-055 / dcy-011 FR-5, issue #11). -`find_ledger_gap_slots` detects `closed` slots that produced NO ledger_outbox -row (the silent-skip symptom): either `portfolio_id IS NULL` (skip at fill) or -no outbox row carries the slot's portfolio_id as `asset_id`. Read-only — no -schema change; `daily_slot` is durable so this is a permanent detector even -without an outbox marker row (dcy-010 OQ-4). +`find_ledger_gap_slots` detects sold slots that produced NO ledger_outbox row +(the silent-skip symptom). dcy-020 (BAT-030): the pool-model `closed` status no +longer exists — a sold slot returns to `inactive` and its occupancy is cleared — +so the detector keys off the append-only `slot_transition_log` (reason +`sell_filled` / `sell_partial_cancel`) instead of the slot row's status. A gap +is a sell fill whose slot has `portfolio_id IS NULL` (skip at fill) or whose +portfolio_id never appears as a ledger_outbox `asset_id`. Read-only — no schema +change; the transition log is durable so this is a permanent detector. """ from __future__ import annotations @@ -14,20 +17,21 @@ from typing import Any, Optional from trex_core.adapters.persistence.postgresql import _require_psycopg _LEDGER_GAP_SQL = """ -SELECT s.slot_id, s.trading_date, s.symbol, s.portfolio_id, s.broker_order_id -FROM daily_slot s -WHERE s.status = 'closed' - AND (%(td)s::date IS NULL OR s.trading_date = %(td)s) +SELECT DISTINCT s.slot_id, s.symbol, s.portfolio_id, s.broker_order_id, t.ts +FROM slot_transition_log t +JOIN daily_slot s ON s.slot_id = t.slot_id +WHERE t.reason IN ('sell_filled', 'sell_partial_cancel') + AND (%(td)s::date IS NULL OR (t.ts AT TIME ZONE 'UTC')::date = %(td)s) AND ( s.portfolio_id IS NULL OR NOT EXISTS ( SELECT 1 FROM ledger_outbox o WHERE o.asset_id = s.portfolio_id ) ) -ORDER BY s.trading_date, s.slot_id +ORDER BY t.ts, s.slot_id """ -_COLS = ("slot_id", "trading_date", "symbol", "portfolio_id", "broker_order_id") +_COLS = ("slot_id", "symbol", "portfolio_id", "broker_order_id", "sold_at") def find_ledger_gap_slots( @@ -36,9 +40,9 @@ def find_ledger_gap_slots( trading_date: Optional[date] = None, psycopg_module: Any = None, ) -> list[dict]: - """Return closed slots with no corresponding ledger_outbox row (FR-5). + """Return sell fills with no corresponding ledger_outbox row (FR-5). - Pass `trading_date` to scope to one day; omit for all days. + Pass `trading_date` to scope to one (UTC) day of sell fills; omit for all. """ psycopg = _require_psycopg(psycopg_module) with psycopg.connect(dsn) as conn: diff --git a/trex_core/adapters/persistence/postgresql.py b/trex_core/adapters/persistence/postgresql.py index c643e82..282f6d4 100644 --- a/trex_core/adapters/persistence/postgresql.py +++ b/trex_core/adapters/persistence/postgresql.py @@ -13,7 +13,7 @@ from __future__ import annotations import json from contextlib import contextmanager -from datetime import date, datetime, timezone +from datetime import datetime, timezone from typing import Any, Iterator, Optional from uuid import UUID, uuid4 @@ -27,10 +27,10 @@ from trex_core.application.ports import IntentRecord from trex_core.domain.slot import Slot, SlotNotFoundError, SlotState, SlotTransitionRecord _SLOT_COLUMNS = ( - "slot_id, trading_date, status, symbol, qty, avg_price, limit_price, " + "slot_id, status, symbol, qty, avg_price, limit_price, " "allocated_capital, order_id, strategy_id, broker_order_id, portfolio_id, " "sell_strategy_id, forced_close_at, stop_loss_price, take_profit_price, " - "created_at, updated_at, entry_count, instance_key, retired_at" + "created_at, updated_at, instance_key, retired_at" ) @@ -65,34 +65,12 @@ class PostgreSQLSlotRepository: conn.rollback() raise - def pre_allocate_slots(self, trading_date: date, count: int) -> int: - with self._psycopg.connect(self._dsn, autocommit=False) as conn: - with conn.cursor() as cur: - cur.execute( - "SELECT COUNT(*) FROM daily_slot WHERE trading_date = %s", (trading_date,) - ) - row = cur.fetchone() - if row and row[0] > 0: - conn.commit() - return 0 - now = datetime.now(tz=timezone.utc) - rows = [ - (uuid4(), trading_date, SlotState.IDLE.value, now, now) for _ in range(count) - ] - cur.executemany( - "INSERT INTO daily_slot (slot_id, trading_date, status, created_at, updated_at) " - "VALUES (%s, %s, %s, %s, %s)", - rows, - ) - conn.commit() - return count - - def list_slots(self, trading_date: date) -> list[Slot]: + def list_slots(self, instance_key: str) -> list[Slot]: with self._psycopg.connect(self._dsn) as conn: with conn.cursor() as cur: cur.execute( - f"SELECT {_SLOT_COLUMNS} FROM daily_slot WHERE trading_date = %s", - (trading_date,), + f"SELECT {_SLOT_COLUMNS} FROM daily_slot WHERE instance_key = %s", + (instance_key,), ) return [self._row_to_slot(r) for r in cur.fetchall()] @@ -105,32 +83,6 @@ class PostgreSQLSlotRepository: row = cur.fetchone() return self._row_to_slot(row) if row else None - def find_idle_slot(self, trading_date: date) -> Optional[Slot]: - with self._psycopg.connect(self._dsn) as conn: - with conn.cursor() as cur: - cur.execute( - f"SELECT {_SLOT_COLUMNS} FROM daily_slot " - "WHERE trading_date = %s AND status = 'idle' LIMIT 1", - (trading_date,), - ) - row = cur.fetchone() - return self._row_to_slot(row) if row else None - - def find_allocatable_slot(self, trading_date: date) -> Optional[Slot]: - # dcy-019 / REQ-006: idle ∪ closed candidates, ordered by entry_count asc - # → updated_at asc → slot_id (deterministic, OQ-2). Concurrency on the - # last candidate handled by the caller (advisory lock + retry, NFR-1). - with self._psycopg.connect(self._dsn) as conn: - with conn.cursor() as cur: - cur.execute( - f"SELECT {_SLOT_COLUMNS} FROM daily_slot " - "WHERE trading_date = %s AND status IN ('idle','closed') " - "ORDER BY entry_count ASC, updated_at ASC, slot_id ASC LIMIT 1", - (trading_date,), - ) - row = cur.fetchone() - return self._row_to_slot(row) if row else None - # --- asset-centric (dcy-020 / BAT-028) --- def find_slot_by_portfolio(self, instance_key: str, portfolio_id: str) -> Optional[Slot]: with self._psycopg.connect(self._dsn) as conn: @@ -165,7 +117,7 @@ class PostgreSQLSlotRepository: return [self._row_to_slot(r) for r in cur.fetchall()] def reconcile_universe( - self, instance_key: str, trading_date: date, universe_portfolio_ids: set[str], + self, instance_key: str, universe_portfolio_ids: set[str], *, allow_retire: bool = True, ) -> dict[str, int]: now = datetime.now(tz=timezone.utc) @@ -184,11 +136,11 @@ class PostgreSQLSlotRepository: continue cur.execute( "INSERT INTO daily_slot " - "(slot_id, trading_date, status, instance_key, portfolio_id, " - "created_at, updated_at) VALUES (%s, %s, 'idle', %s, %s, %s, %s) " + "(slot_id, status, instance_key, portfolio_id, " + "created_at, updated_at) VALUES (%s, 'inactive', %s, %s, %s, %s) " "ON CONFLICT (instance_key, portfolio_id) " "WHERE portfolio_id IS NOT NULL DO NOTHING", - (uuid4(), trading_date, instance_key, pid, now, now), + (uuid4(), instance_key, pid, now, now), ) created += cur.rowcount # retire / tombstone assets that left the universe (skipped under @@ -217,7 +169,7 @@ class PostgreSQLSlotRepository: "limit_price=%s, allocated_capital=%s, order_id=%s, strategy_id=%s, " "broker_order_id=%s, portfolio_id=%s, sell_strategy_id=%s, " "forced_close_at=%s, stop_loss_price=%s, take_profit_price=%s, " - "entry_count=%s, instance_key=%s, retired_at=%s, " + "instance_key=%s, retired_at=%s, " "updated_at=%s WHERE slot_id=%s", ( slot.status.value, slot.symbol, slot.qty, slot.avg_price, @@ -225,7 +177,7 @@ class PostgreSQLSlotRepository: slot.strategy_id, slot.broker_order_id, slot.portfolio_id, slot.sell_strategy_id, slot.forced_close_at, slot.stop_loss_price, slot.take_profit_price, - slot.entry_count, slot.instance_key, slot.retired_at, + slot.instance_key, slot.retired_at, slot.updated_at or datetime.now(tz=timezone.utc), slot.slot_id, ), ) @@ -248,43 +200,23 @@ class PostgreSQLSlotRepository: ) conn.commit() - def increment_symbol_entry(self, trading_date: date, symbol: str) -> int: - with self._psycopg.connect(self._dsn, autocommit=False) as conn: - with conn.cursor() as cur: - cur.execute( - "INSERT INTO daily_symbol_entries (trading_date, symbol, entry_count, last_entry_at) " - "VALUES (%s, %s, 1, %s) " - "ON CONFLICT (trading_date, symbol) DO UPDATE " - "SET entry_count = daily_symbol_entries.entry_count + 1, " - " last_entry_at = EXCLUDED.last_entry_at RETURNING entry_count", - (trading_date, symbol, datetime.now(tz=timezone.utc)), - ) - row = cur.fetchone() - conn.commit() - return int(row[0]) if row else 0 - - def get_symbol_entry_count(self, trading_date: date, symbol: str) -> int: - with self._psycopg.connect(self._dsn) as conn: - with conn.cursor() as cur: - cur.execute( - "SELECT entry_count FROM daily_symbol_entries " - "WHERE trading_date = %s AND symbol = %s", - (trading_date, symbol), - ) - row = cur.fetchone() - return int(row[0]) if row else 0 - # --- order execution queue (BAT-004 / REQ-013, option A) --- - def find_pending_for_dispatch(self) -> Optional[Slot]: + def list_undispatched_pending( + self, instance_key: str = "default", limit: int = 64, + ) -> list[Slot]: + # dcy-020/REQ-056 (BAT-030): instance-scoped so two daemons sharing a + # DB never dispatch each other's orders. List-based (vs single-row + # claim) so one skipped sell (broker flat) cannot head-block the queue. with self._psycopg.connect(self._dsn) as conn: with conn.cursor() as cur: cur.execute( f"SELECT {_SLOT_COLUMNS} FROM daily_slot " - "WHERE status IN ('pending_buy','pending_sell') AND broker_order_id IS NULL " - "ORDER BY updated_at LIMIT 1 FOR UPDATE SKIP LOCKED" + "WHERE instance_key = %s " + "AND status IN ('pending_buy','pending_sell') AND broker_order_id IS NULL " + "ORDER BY updated_at LIMIT %s", + (instance_key, limit), ) - row = cur.fetchone() - return self._row_to_slot(row) if row else None + return [self._row_to_slot(r) for r in cur.fetchall()] def mark_dispatched(self, slot_id: UUID, broker_order_id: str) -> bool: with self._psycopg.connect(self._dsn, autocommit=False) as conn: @@ -298,12 +230,14 @@ class PostgreSQLSlotRepository: conn.commit() return ok - def list_dispatched_pending(self) -> list[Slot]: + def list_dispatched_pending(self, instance_key: str = "default") -> list[Slot]: with self._psycopg.connect(self._dsn) as conn: with conn.cursor() as cur: cur.execute( f"SELECT {_SLOT_COLUMNS} FROM daily_slot " - "WHERE status IN ('pending_buy','pending_sell') AND broker_order_id IS NOT NULL" + "WHERE instance_key = %s " + "AND status IN ('pending_buy','pending_sell') AND broker_order_id IS NOT NULL", + (instance_key,), ) return [self._row_to_slot(r) for r in cur.fetchall()] @@ -316,12 +250,13 @@ class PostgreSQLSlotRepository: ) conn.commit() - def count_slots_by_status(self, trading_date: date) -> dict[SlotState, int]: + def count_slots_by_status(self, instance_key: str) -> dict[SlotState, int]: with self._psycopg.connect(self._dsn) as conn: with conn.cursor() as cur: cur.execute( - "SELECT status, COUNT(*) FROM daily_slot WHERE trading_date = %s GROUP BY status", - (trading_date,), + "SELECT status, COUNT(*) FROM daily_slot WHERE instance_key = %s " + "GROUP BY status", + (instance_key,), ) return {SlotState(r[0]): int(r[1]) for r in cur.fetchall()} @@ -330,33 +265,31 @@ class PostgreSQLSlotRepository: from decimal import Decimal f = lambda v: float(v) if v is not None else None # noqa: E731 d = lambda v: Decimal(str(v)) if v is not None else None # noqa: E731 - # Column order: slot_id(0) trading_date(1) status(2) symbol(3) qty(4) - # avg_price(5) limit_price(6) allocated_capital(7) order_id(8) - # strategy_id(9) broker_order_id(10) portfolio_id(11) sell_strategy_id(12) - # forced_close_at(13) stop_loss_price(14) take_profit_price(15) - # created_at(16) updated_at(17) entry_count(18) + # Column order: slot_id(0) status(1) symbol(2) qty(3) avg_price(4) + # limit_price(5) allocated_capital(6) order_id(7) strategy_id(8) + # broker_order_id(9) portfolio_id(10) sell_strategy_id(11) + # forced_close_at(12) stop_loss_price(13) take_profit_price(14) + # created_at(15) updated_at(16) instance_key(17) retired_at(18) return Slot( slot_id=row[0] if isinstance(row[0], UUID) else UUID(str(row[0])), - trading_date=row[1], - status=SlotState(row[2]), - symbol=row[3], - qty=f(row[4]), - avg_price=f(row[5]), - limit_price=f(row[6]), - allocated_capital=f(row[7]), - order_id=row[8], - strategy_id=row[9], - broker_order_id=row[10], - portfolio_id=row[11], - sell_strategy_id=row[12], - forced_close_at=row[13], - stop_loss_price=d(row[14]), - take_profit_price=d(row[15]), - created_at=row[16], - updated_at=row[17], - entry_count=int(row[18]) if row[18] is not None else 0, - instance_key=row[19] if row[19] is not None else "default", - retired_at=row[20], + status=SlotState(row[1]), + symbol=row[2], + qty=f(row[3]), + avg_price=f(row[4]), + limit_price=f(row[5]), + allocated_capital=f(row[6]), + order_id=row[7], + strategy_id=row[8], + broker_order_id=row[9], + portfolio_id=row[10], + sell_strategy_id=row[11], + forced_close_at=row[12], + stop_loss_price=d(row[13]), + take_profit_price=d(row[14]), + created_at=row[15], + updated_at=row[16], + instance_key=row[17] if row[17] is not None else "default", + retired_at=row[18], ) diff --git a/trex_core/adapters/persistence/sql/001_create_slot_core_tables.sql b/trex_core/adapters/persistence/sql/001_create_slot_core_tables.sql index d26e1b3..b1d117f 100644 --- a/trex_core/adapters/persistence/sql/001_create_slot_core_tables.sql +++ b/trex_core/adapters/persistence/sql/001_create_slot_core_tables.sql @@ -27,8 +27,11 @@ CREATE TABLE IF NOT EXISTS daily_slot ( updated_at TIMESTAMPTZ NOT NULL DEFAULT (now() AT TIME ZONE 'UTC') ); -CREATE INDEX IF NOT EXISTS idx_daily_slot_trading_date_status - ON daily_slot (trading_date, status); +-- BAT-030 / dcy-020: the (trading_date, status) index is superseded by a +-- status index — trading_date is dropped at migration 011 and this file is +-- re-applied on every boot, so it must not reference the dropped column. +CREATE INDEX IF NOT EXISTS idx_daily_slot_status + ON daily_slot (status); -- daily_symbol_entries — per-day re-entry counter (decision input). CREATE TABLE IF NOT EXISTS daily_symbol_entries ( diff --git a/trex_core/adapters/persistence/sql/008_add_slot_entry_count.sql b/trex_core/adapters/persistence/sql/008_add_slot_entry_count.sql index 9d198bb..e5dd037 100644 --- a/trex_core/adapters/persistence/sql/008_add_slot_entry_count.sql +++ b/trex_core/adapters/persistence/sql/008_add_slot_entry_count.sql @@ -1,7 +1,6 @@ --- BAT-025 / dcy-019 / REQ-006: slot-scoped entry_count on daily_slot. --- Tracks how many times a slot has entered pending_buy (lifetime, per trading --- day). Drives the buy-admission selector's fairness ordering (idle ∪ closed, --- least-entered first) and enables closed-slot reallocation (issue #18). --- Distinct from daily_symbol_entries.entry_count (per-symbol cap, unchanged). --- Additive + idempotent: existing rows default to 0 (no prior entries counted). -ALTER TABLE daily_slot ADD COLUMN IF NOT EXISTS entry_count INTEGER NOT NULL DEFAULT 0; +-- BAT-025 / dcy-019 / REQ-006: added slot-scoped entry_count to daily_slot. +-- BAT-030 / dcy-020 / REQ-056: entry_count was DROPPED at the asset-centric +-- cutover (migration 011). Because the migration set is re-applied on every +-- boot, the original ADD COLUMN here would resurrect the column each run — +-- so this file is intentionally a no-op now (kept for numbering continuity). +SELECT 1; diff --git a/trex_core/adapters/persistence/sql/010_literal_inactive_status.sql b/trex_core/adapters/persistence/sql/010_literal_inactive_status.sql new file mode 100644 index 0000000..f05b1ae --- /dev/null +++ b/trex_core/adapters/persistence/sql/010_literal_inactive_status.sql @@ -0,0 +1,11 @@ +-- BAT-030 / dcy-020 / REQ-057: literal `inactive` 전면화 (D2). +-- The pool-model `idle` (never-bought) and `closed` (sold, flat) statuses are +-- collapsed into the asset-centric `inactive`. Rewrites any remaining rows and +-- swaps the status CHECK constraint. Idempotent: the UPDATE matches nothing on +-- re-run and the constraint is dropped/re-added by name each time. +UPDATE daily_slot SET status = 'inactive' WHERE status IN ('idle', 'closed'); + +ALTER TABLE daily_slot DROP CONSTRAINT IF EXISTS daily_slot_status_check; +ALTER TABLE daily_slot ADD CONSTRAINT daily_slot_status_check CHECK ( + status IN ('inactive','pending_buy','holding','pending_sell','error') +); diff --git a/trex_core/adapters/persistence/sql/011_drop_pool_model_columns.sql b/trex_core/adapters/persistence/sql/011_drop_pool_model_columns.sql new file mode 100644 index 0000000..1c4b250 --- /dev/null +++ b/trex_core/adapters/persistence/sql/011_drop_pool_model_columns.sql @@ -0,0 +1,9 @@ +-- BAT-030 / dcy-020 / REQ-056: drop pool-model columns (D2, 비가역). +-- The permanent asset slot is keyed by (instance_key, portfolio_id) and lives +-- across trading days — `trading_date` (day-scoped pool identity) and +-- `entry_count` (pool fairness ordering) no longer apply. Time attributes live +-- on slot_transition_log / ledger rows. DROP COLUMN also removes any dependent +-- index. Idempotent via IF EXISTS. Rollback = restore from the pre-cutover +-- backup (see BAT-030 design §4). +ALTER TABLE daily_slot DROP COLUMN IF EXISTS entry_count; +ALTER TABLE daily_slot DROP COLUMN IF EXISTS trading_date; diff --git a/trex_core/application/execution.py b/trex_core/application/execution.py index 5568823..94cced0 100644 --- a/trex_core/application/execution.py +++ b/trex_core/application/execution.py @@ -4,8 +4,12 @@ A single background execution loop (invariant A single writer, invariant B single broker session) that, per tick: dispatch: claim pending slots with broker_order_id IS NULL (option A queue), submit the order, record broker_order_id (idempotency guard). + dcy-020/REQ-062 (BAT-030): a SELL is dispatched with the broker + balance quantity (hldg_qty) as the single source of truth — never + the slot's mirror qty. Broker flat (qty 0) -> skip + WARN + (divergence surfaces via reconcile, REQ-062 A). poll: observe fills on dispatched pending slots and transition the slot - (pending_buy->holding / pending_sell->closed), or roll back on reject. + (pending_buy->holding / pending_sell->inactive), or roll back on reject. On a successful fill, enqueue a LedgerWorkItem (slot transition is kept ON the critical path; the ledger write is eventually-consistent via a separate writer — REQ-019). @@ -47,6 +51,7 @@ class ExecutionResult: ledger_enqueued: int = 0 # REQ-019: ledger work items appended this tick ledger_skipped: int = 0 # REQ-018 AC-4: fills without portfolio_id stale_pending: int = 0 # dcy-013 / REQ-014 FR-3: pending orders over threshold + sell_skipped_no_balance: int = 0 # dcy-020 / REQ-062: broker flat -> sell skipped class ExecutionService: @@ -58,12 +63,16 @@ class ExecutionService: ledger_outbox: Optional[LedgerOutbox] = None, event_emitter: Optional[SlotEventEmitter] = None, stale_pending_threshold_sec: Optional[int] = None, + instance_key: str = "default", ) -> None: self._svc = slot_service self._repo = repo self._broker = broker self._ledger_outbox = ledger_outbox self._event_emitter = event_emitter + # dcy-020/REQ-056 (BAT-030): dispatch/poll are instance-scoped so two + # daemons sharing a DB never touch each other's orders. + self._instance_key = instance_key # dcy-013 / REQ-014 (issue #9): Alert-only pending-order stale watchdog. # <=0 or None disables it. When set, a dispatched-pending slot whose # last update is older than the threshold is surfaced (WARN + counter) @@ -93,12 +102,32 @@ class ExecutionService: # ------------------------------------------------------------ def _dispatch(self, r: ExecutionResult) -> None: - for _ in range(_MAX_DISPATCH_PER_TICK): - slot = self._repo.find_pending_for_dispatch() - if slot is None: - return + sell_balance: Optional[dict[str, int]] = None # lazy, once per tick + for slot in self._repo.list_undispatched_pending( + self._instance_key, _MAX_DISPATCH_PER_TICK + ): side = "buy" if slot.status is SlotState.PENDING_BUY else "sell" - qty = int(slot.qty or 0) + if side == "sell": + # dcy-020 / REQ-062 (BAT-030, D3): the sell quantity is the + # BROKER balance (hldg_qty) — the slot's mirror qty is never + # trusted. Broker flat -> skip dispatch + WARN; the divergence + # surfaces via reconcile_core_holdings (REQ-062 A). No mirror + # fallback by design. + if sell_balance is None: + sell_balance = { + b.symbol: int(b.hldg_qty) for b in self._broker.get_balance() + } + qty = sell_balance.get(slot.symbol or "", 0) + if qty <= 0: + r.sell_skipped_no_balance += 1 + _log.warning( + "sell dispatch skipped: slot=%s symbol=%s broker balance is 0 " + "(divergence — no order submitted; operator/reconcile attention)", + slot.slot_id, slot.symbol, + ) + continue + else: + qty = int(slot.qty or 0) # dcy-012 / REQ-013: side-agnostic — use the slot's pending-order # limit_price only. A sell never reuses a buy limit (issue #10): # limit_price is cleared on entering HOLDING, so a market sell @@ -123,7 +152,7 @@ class ExecutionService: def _poll(self, r: ExecutionResult) -> None: now = datetime.now(tz=timezone.utc) - for slot in self._repo.list_dispatched_pending(): + for slot in self._repo.list_dispatched_pending(self._instance_key): side = "buy" if slot.status is SlotState.PENDING_BUY else "sell" fs = self._broker.poll_fill(slot.broker_order_id, side=side, symbol=slot.symbol or "") if fs.is_filled: @@ -153,9 +182,9 @@ class ExecutionService: avg_price=fs.avg_price, qty=float(fs.filled_qty) or slot.qty, ) event_type = "slot_filled_buy" - else: # PENDING_SELL + else: # PENDING_SELL — asset slot returns to inactive (dcy-020/REQ-057) updated = self._svc.transit_slot( - slot.slot_id, SlotState.PENDING_SELL, SlotState.CLOSED, + slot.slot_id, SlotState.PENDING_SELL, SlotState.INACTIVE, reason="sell_filled", actor="execution_loop", ) event_type = "slot_filled_sell" @@ -271,9 +300,9 @@ class ExecutionService: try: if slot.status is SlotState.PENDING_BUY: self._svc.transit_slot( - slot.slot_id, SlotState.PENDING_BUY, SlotState.IDLE, + slot.slot_id, SlotState.PENDING_BUY, SlotState.INACTIVE, reason=reason, actor="execution_loop", - ) # idle reset clears broker_order_id in the domain + ) # inactive reset clears broker_order_id in the domain else: # PENDING_SELL rejected -> back to holding, clear order for re-dispatch self._svc.transit_slot( slot.slot_id, SlotState.PENDING_SELL, SlotState.HOLDING, diff --git a/trex_core/application/intents.py b/trex_core/application/intents.py index 40b83d7..86824f2 100644 --- a/trex_core/application/intents.py +++ b/trex_core/application/intents.py @@ -1,19 +1,24 @@ -"""Intent application service (REQ-007). +"""Intent application service (REQ-007; dcy-020/REQ-059·061 asset-centric). -Turns a buy/sell intent submitted over HTTP into a single-writer slot +Turns a buy/sell intent submitted over messaging into a single-writer slot transition, with intent_id idempotency backed by a durable intent_log. Idempotency model (dcy-002 OQ-1): `intent_store.try_insert` is the dedup gate. On a duplicate intent_id it returns the prior decision without re-driving any transition (NFR-3). Slot transitions go exclusively through SlotService (advisory-lock serialized) so the single-writer invariant -holds (NFR-2). Concurrency on the last idle slot is resolved by retrying -on SlotStateMismatchError. +holds (NFR-2). + +dcy-020 (BAT-030): admission is asset-centric only — the legacy pool path +(daily preallocation + idle∪closed candidate selection) was removed at the +cutover. Buy is keyed by slot_id (REQ-061 canonical; portfolio_id is the +legacy dual-accept mapping) and admits FCFS under the `max_active_slots` cap +M when configured (no cap when unset). """ from __future__ import annotations from dataclasses import dataclass, field -from datetime import date, datetime +from datetime import datetime from decimal import Decimal from typing import Any, Optional from uuid import UUID @@ -26,7 +31,7 @@ from trex_core.domain.slot import ( SlotStateMismatchError, ) -_MAX_IDLE_RETRY = 8 +_MAX_ADMIT_RETRY = 8 @dataclass(frozen=True) @@ -45,15 +50,11 @@ class IntentDecision: @dataclass(frozen=True) class BuyIntent: - # dcy-011 / REQ-007: portfolio_id (= asset_id) is the required canonical key. - # symbol is no longer a client input — it is resolved server-side from the - # universe (SnapshotStore) at admission. It remains here as the resolved - # value carried onto the slot. + # dcy-020 / REQ-061: slot_id is the canonical buy key (snapshot slots[] + # round-trip); portfolio_id is accepted as a legacy dual-accept mapping. + # symbol is not a client input — it is resolved server-side from the + # universe at admission and carried onto the slot. intent_id: str - trading_date: date - # dcy-020 / REQ-061 (BAT-029): asset-centric contract key. slot_id is the - # canonical buy key (snapshot slots[] round-trip); portfolio_id is retained - # for the legacy pool path and as dual-accept in asset-centric mode. portfolio_id: Optional[str] = None slot_id: Optional[UUID] = None symbol: Optional[str] = None @@ -100,9 +101,8 @@ class IntentService: # callable(portfolio_id) -> Optional[str]. When set, admission resolves symbol # from the universe and rejects unknown_portfolio if absent. self._resolve_symbol = resolve_symbol - # dcy-020 / REQ-059 (BAT-028): asset-centric mode gate. When max_active_slots - # is set, buy admission activates the asset's permanent slot under an M cap - # (FCFS, no eviction); otherwise the legacy pool path runs (unchanged). + # dcy-020 / REQ-059·060: M cap. None = no capacity cap (the operator is + # expected to set max_active_slots in the instance TOML — design §5). self._instance_key = instance_key self._max_active_slots = max_active_slots @@ -116,14 +116,14 @@ class IntentService: def submit_buy(self, intent: BuyIntent) -> IntentDecision: payload = { - "trading_date": intent.trading_date.isoformat(), + "slot_id": str(intent.slot_id) if intent.slot_id else None, + "portfolio_id": intent.portfolio_id, "symbol": intent.symbol, "qty": intent.qty, "allocated_capital": intent.allocated_capital, "entry_price": intent.entry_price, "strategy_id": intent.strategy_id, "sell_strategy_id": intent.sell_strategy_id, - "portfolio_id": intent.portfolio_id, } prior = self._dedup(intent.intent_id, "buy", payload) if prior is not None: @@ -163,68 +163,14 @@ class IntentService: return IntentDecision(intent_id, rec.status, rec.slot_id, rec.reason) def _admit_buy(self, intent: BuyIntent) -> IntentDecision: - # dcy-020 / REQ-061 (BAT-029): asset-centric mode keys buy by slot_id - # (legacy portfolio_id dual-accept); the target slot + symbol are resolved - # inside the asset-centric path. - if self._max_active_slots is not None: - return self._admit_buy_asset_centric(intent) - # legacy pool path (dcy-011 / REQ-007): portfolio_id required; symbol is - # resolved server-side from the universe. - if not intent.portfolio_id: - return IntentDecision(intent.intent_id, "rejected", None, "portfolio_id_required") - symbol = intent.symbol - if self._resolve_symbol is not None: - symbol = self._resolve_symbol(intent.portfolio_id) - if not symbol: - return IntentDecision(intent.intent_id, "rejected", None, "unknown_portfolio") - elif not symbol: - # No resolver wired and no symbol carried — cannot determine the order target. - return IntentDecision(intent.intent_id, "rejected", None, "unknown_portfolio") - # Retry on the last-candidate race: another intent may claim the slot - # between our find and transit (SlotStateMismatchError). - # dcy-019 / REQ-007: admission candidate is idle ∪ closed (closed-slot - # reallocation), ordered by entry_count asc → updated_at asc → slot_id. - # The candidate may already be CLOSED, so the transit's from_state must - # follow the slot's current status rather than a hardcoded IDLE. - for _ in range(_MAX_IDLE_RETRY): - slot = self._repo.find_allocatable_slot(intent.trading_date) - if slot is None: - return IntentDecision(intent.intent_id, "rejected", None, "no_idle_slot") - try: - updated = self._svc.transit_slot( - slot.slot_id, - slot.status, - SlotState.PENDING_BUY, - reason="buy_intent", - actor="intent_api", - symbol=symbol, - qty=intent.qty, - allocated_capital=intent.allocated_capital, - # dcy-012 / REQ-007: buy entry price maps to the slot's - # side-agnostic pending limit_price (cleared on fill→HOLDING). - limit_price=intent.entry_price, - strategy_id=intent.strategy_id, - sell_strategy_id=intent.sell_strategy_id, - portfolio_id=intent.portfolio_id, - forced_close_at=intent.forced_close_at, - stop_loss_price=intent.stop_loss_price, - take_profit_price=intent.take_profit_price, - extra={"intent_id": intent.intent_id, "symbol": symbol}, - ) - except SlotStateMismatchError: - continue # slot was claimed concurrently; try another idle slot - return IntentDecision(intent.intent_id, "admitted", updated.slot_id, None) - return IntentDecision(intent.intent_id, "rejected", None, "idle_slot_contended") - - # ------------------------------------------------------------ - def _admit_buy_asset_centric(self, intent: BuyIntent) -> IntentDecision: - """dcy-020 / REQ-059·REQ-061: activate the slot under the M cap. + """dcy-020 / REQ-059·REQ-061: activate the asset's permanent slot. Contract key is slot_id (REQ-061); portfolio_id is accepted as a legacy dual-accept mapping. Symbol is resolved from the slot's portfolio_id. - FCFS + no eviction: already-active -> already_active; at M -> capacity_full - (existing holdings untouched, incl. over-cap). Single intent thread + - instance-scoped advisory lock serialize admissions (dcy-020 OQ-1).""" + FCFS + no eviction: already-active -> already_active; at M -> + capacity_full (existing holdings untouched, incl. over-cap). Single + intent thread + instance-scoped advisory lock serialize admissions + (dcy-020 OQ-1).""" _ACTIVE = (SlotState.PENDING_BUY, SlotState.HOLDING, SlotState.PENDING_SELL) def _resolve(sl): @@ -233,7 +179,7 @@ class IntentService: return self._resolve_symbol(pid) return intent.symbol or sl.symbol - for _ in range(_MAX_IDLE_RETRY): + for _ in range(_MAX_ADMIT_RETRY): # resolve the target slot: slot_id (canonical) or portfolio_id (legacy) if intent.slot_id is not None: slot = self._repo.fetch_slot(intent.slot_id) @@ -250,7 +196,8 @@ class IntentService: symbol = _resolve(slot) if not symbol: return IntentDecision(intent.intent_id, "rejected", None, "unknown_portfolio") - if self._repo.count_active(self._instance_key) >= self._max_active_slots: + if (self._max_active_slots is not None + and self._repo.count_active(self._instance_key) >= self._max_active_slots): return IntentDecision(intent.intent_id, "rejected", None, "capacity_full") try: updated = self._svc.transit_slot( @@ -262,6 +209,8 @@ class IntentService: symbol=symbol, qty=intent.qty, allocated_capital=intent.allocated_capital, + # dcy-012 / REQ-007: buy entry price maps to the slot's + # side-agnostic pending limit_price (cleared on fill→HOLDING). limit_price=intent.entry_price, strategy_id=intent.strategy_id, sell_strategy_id=intent.sell_strategy_id, diff --git a/trex_core/application/killswitch.py b/trex_core/application/killswitch.py index 01c88b2..b0cf5e6 100644 --- a/trex_core/application/killswitch.py +++ b/trex_core/application/killswitch.py @@ -13,7 +13,7 @@ from datetime import date as date_cls from datetime import datetime, timezone from trex_core.application.portfolio import GLOBAL_KILLSWITCH, KillSwitchStore -from trex_core.domain.slot import SlotRepository, SlotService, SlotState +from trex_core.domain.slot import SlotRepository, SlotService _log = logging.getLogger(__name__) _ACTOR = "kill-switch" @@ -28,10 +28,12 @@ class KillSwitchService: slot_repo: SlotRepository, slot_service: SlotService, killswitch_store: KillSwitchStore, + instance_key: str = "default", ) -> None: self._repo = slot_repo self._svc = slot_service self._store = killswitch_store + self._instance_key = instance_key # dcy-020/REQ-056: instance-scoped sweep @staticmethod def _trading_date(now: datetime) -> date_cls: @@ -50,8 +52,8 @@ class KillSwitchService: scope: "portfolio" (requires portfolio_id) | "global". """ now = now or datetime.now(tz=timezone.utc) - day = self._trading_date(now) - holding = [s for s in self._repo.list_slots(day) if s.status is SlotState.HOLDING] + day = self._trading_date(now) # day-scoped exclusion key (REQ-044) + holding = self._repo.list_holding_slots(self._instance_key) if scope == "portfolio": holding = [s for s in holding if s.portfolio_id == portfolio_id] diff --git a/trex_core/application/messaging_handlers.py b/trex_core/application/messaging_handlers.py index 9ff2a13..95f0273 100644 --- a/trex_core/application/messaging_handlers.py +++ b/trex_core/application/messaging_handlers.py @@ -26,7 +26,6 @@ from trex_core.domain.slot import ( SlotNotFoundError, SlotRepository, SlotService, - SlotState, ) _log = logging.getLogger(__name__) @@ -72,12 +71,12 @@ def make_intent_rpc_handler( intent_type = str(body.get("intent_type") or "").lower() try: if intent_type == "buy": - # dcy-011 / REQ-021: buy payload carries portfolio_id (legacy) or, - # in asset-centric mode (dcy-020/REQ-061), slot_id (canonical key). - # symbol is NOT a client field — resolved from the universe. + # dcy-020 / REQ-061: buy is keyed by slot_id (canonical); + # portfolio_id is the legacy dual-accept mapping. symbol is NOT + # a client field — resolved from the universe. trading_date was + # dropped from the contract at the cutover (BAT-030). buy = BuyIntent( intent_id=intent_id, - trading_date=_parse_date(body.get("trading_date")), portfolio_id=_opt_str(body.get("portfolio_id")) or None, slot_id=(UUID(str(body["slot_id"])) if body.get("slot_id") else None), qty=_opt_float(body.get("qty")), @@ -166,9 +165,10 @@ def make_snapshot_rpc_handler( slot_repo: SlotRepository, slot_service: SlotService, *, + instance_key: str = "default", snapshot_store: Optional[SnapshotStore] = None, pause_store: Optional[PauseStore] = None, - default_total_slots: int = 10, + max_active_slots: Optional[int] = None, watchdog_store: Optional[WatchdogStore] = None, watchdog_timeout_sec: int = 120, killswitch_store=None, @@ -206,9 +206,8 @@ def make_snapshot_rpc_handler( if str(body.get("actor") or "").lower() == "operator": touch_watchdog = False if watchdog_store is not None and touch_watchdog: - for s in slot_repo.list_slots(day): - if s.status is SlotState.HOLDING: - watchdog_store.update(str(s.slot_id)) + for s in slot_repo.list_holding_slots(instance_key): + watchdog_store.update(str(s.slot_id)) # --- BAT-008 envelope path (snapshot store wired) --- if snapshot_store is not None and pause_store is not None: @@ -218,45 +217,49 @@ def make_snapshot_rpc_handler( except ValueError: view = View.ALL strategy_key = _opt_str(body.get("strategy_key")) - cap = slot_service.compute_slot_capacity(day, default_total_slots) + # dcy-020 / REQ-059: available = remaining admission headroom under + # the M cap (max(0, M − active)); inactive count when no cap. + cap = slot_service.compute_slot_capacity(max_active_slots) envelope = build_snapshot_envelope( store=snapshot_store, pause_store=pause_store, slot_repo=slot_repo, - available_slots=cap.idle_count, + instance_key=instance_key, + available_slots=cap.available_count, view=view, strategy_key=strategy_key, trading_date=day, killswitch_store=killswitch_store, ) payload = envelope.to_dict() - _attach_watchdog(payload, slot_repo, day, watchdog_store, watchdog_timeout_sec) + _attach_watchdog(payload, slot_repo, instance_key, watchdog_store, watchdog_timeout_sec) _apply_cash_estimate(payload, ledger_outbox) return RpcResponse(payload=payload) # --- BAT-007 legacy stub-shape path --- view_raw = str(body.get("view") or "all") strategy_filter = _opt_str(body.get("strategy_id")) - slots = slot_repo.list_slots(day) + slots = slot_repo.list_slots(instance_key) if view_raw == "open": slots = [s for s in slots if s.status.value in ("pending_buy", "holding", "pending_sell")] if strategy_filter is not None: slots = [s for s in slots if s.strategy_id == strategy_filter] - cap = slot_service.compute_slot_capacity(day, max(1, len(slot_repo.list_slots(day)))) + cap = slot_service.compute_slot_capacity(max_active_slots) payload = { "view": view_raw, "strategy_id": strategy_filter, - "trading_date": day.isoformat(), "slots": [_slot_to_dict(s) for s in slots], "capacity": { - "idle_count": cap.idle_count, + "inactive_count": cap.inactive_count, "holding_count": cap.holding_count, "pending_count": cap.pending_count, + "active_count": cap.active_count, + "available_count": cap.available_count, "total_slots": cap.total_slots, "scarcity_ratio": cap.scarcity_ratio, }, } - _attach_watchdog(payload, slot_repo, day, watchdog_store, watchdog_timeout_sec) + _attach_watchdog(payload, slot_repo, instance_key, watchdog_store, watchdog_timeout_sec) return RpcResponse(payload=payload) return handler @@ -283,7 +286,7 @@ def _apply_cash_estimate(payload, ledger_outbox) -> None: return -def _attach_watchdog(payload, slot_repo, day, watchdog_store, timeout_sec) -> None: +def _attach_watchdog(payload, slot_repo, instance_key, watchdog_store, timeout_sec) -> None: """Attach holding-slot watchdog state to a snapshot response (dcy-017/REQ-034). `watchdog` = list of {slot_id, portfolio_id, symbol, expired, last_seen} for @@ -293,9 +296,7 @@ def _attach_watchdog(payload, slot_repo, day, watchdog_store, timeout_sec) -> No return try: states = [] - for s in slot_repo.list_slots(day): - if s.status is not SlotState.HOLDING: - continue + for s in slot_repo.list_holding_slots(instance_key): sid = str(s.slot_id) last = watchdog_store.last_seen(sid) states.append({ @@ -441,7 +442,6 @@ def slot_transition_event_payload(slot, *, event_type: str) -> dict: "qty": float(slot.qty) if slot.qty is not None else None, "avg_price": float(slot.avg_price) if slot.avg_price is not None else None, "portfolio_id": slot.portfolio_id, - "trading_date": slot.trading_date.isoformat(), "sequence_at": (slot.updated_at or datetime.now(tz=timezone.utc)).isoformat(), } diff --git a/trex_core/application/portfolio.py b/trex_core/application/portfolio.py index 82cc718..8de3938 100644 --- a/trex_core/application/portfolio.py +++ b/trex_core/application/portfolio.py @@ -308,7 +308,6 @@ def slot_to_dict(s) -> dict: """ return { "slot_id": str(s.slot_id), - "trading_date": s.trading_date.isoformat(), "status": s.status.value, "symbol": s.symbol, "qty": float(s.qty) if s.qty is not None else None, @@ -344,6 +343,7 @@ def build_snapshot_envelope( store: SnapshotStore, pause_store: PauseStore, slot_repo, + instance_key: str = "default", available_slots: int, view: View = View.ALL, strategy_key: Optional[str] = None, @@ -352,14 +352,15 @@ def build_snapshot_envelope( ) -> SnapshotEnvelope: """Compose base(view) − mask(pending·warning·pause·killswitch) − strategy_key. - `slot_repo` is the SlotRepository used to read holding/pending state. - `trading_date` defaults to UTC today (matches the daemon's preallocate). + `slot_repo` is the SlotRepository used to read holding/pending state — + instance-scoped (dcy-020/REQ-056). `trading_date` (default UTC today) is + used only for the day-scoped kill-switch exclusion mask. `killswitch_store` (REQ-046) adds the day-scoped kill-switch exclusion to the mask: a `GLOBAL_KILLSWITCH` ("*") entry masks the whole universe. """ # Read core authoritative slot state once. day = trading_date or datetime.now(tz=timezone.utc).date() - slots = slot_repo.list_slots(day) + slots = slot_repo.list_slots(instance_key) holding_pids: set[str] = set() pending_pids: set[str] = set() holding_qty: dict[str, float] = {} diff --git a/trex_core/application/preallocation_scheduler.py b/trex_core/application/preallocation_scheduler.py deleted file mode 100644 index cf59322..0000000 --- a/trex_core/application/preallocation_scheduler.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Daily slot preallocation scheduler (dcy-014 / REQ-005, issue #14). - -trading_date is computed in a configurable timezone (default Asia/Seoul) so the -DB trading_date matches the local trading calendar, while all timestamps stay -UTC (NFR-1). Modeled on SnapshotScheduler (REQ-030): a tick loop fires once per -TZ-day at/after the configured trigger time, calling the idempotent -`preallocate_daily_slots` so the next day's slots are created without a daemon -restart (NFR-2/NFR-3). -""" -from __future__ import annotations - -import logging -import threading -from datetime import date, datetime, time, timezone -from typing import Callable, Optional, Protocol - -_log = logging.getLogger(__name__) - - -class _Preallocator(Protocol): - def preallocate_daily_slots(self, trading_date: date, count: int) -> int: - ... - - -class DailyPreallocationScheduler: - """Background-thread-friendly daily preallocation. Use `run_once()` for tests.""" - - def __init__( - self, - *, - slot_service: _Preallocator, - total_slots: int, - tz: timezone, - schedule_time: time = time(hour=0, minute=0), - clock: Optional[Callable[[], datetime]] = None, - ) -> None: - self._svc = slot_service - self._total = total_slots - self._tz = tz # tzinfo used to derive trading_date (default Asia/Seoul) - self._schedule = schedule_time - self._clock = clock or (lambda: datetime.now(tz=timezone.utc)) - self._last_fired_date: Optional[date] = None - - # ---- callable from a daemon thread loop -------------------------------- - def run_forever(self, stop: threading.Event, *, tick_sec: float = 60.0) -> None: - # Boot: ensure the current TZ-day's slots exist immediately (idempotent), - # then enter the daily cadence for subsequent day boundaries. - self.run_once() - while not stop.wait(tick_sec): - try: - self._maybe_tick() - except Exception: # noqa: BLE001 - loop must survive transient errors - _log.exception("preallocation scheduler tick failed") - - def _maybe_tick(self) -> None: - now_tz = self._clock().astimezone(self._tz) - target = datetime.combine(now_tz.date(), self._schedule, tzinfo=self._tz) - if now_tz < target: - return # before today's trigger time - if self._last_fired_date is not None and self._last_fired_date == now_tz.date(): - return # already created today's slots - self.run_once() - - # ---- the actual preallocation ----------------------------------------- - def run_once(self) -> date: - """Preallocate slots for the current TZ trading_date (idempotent). Returns it.""" - trading_date = self._clock().astimezone(self._tz).date() - created = self._svc.preallocate_daily_slots(trading_date, self._total) - if created: - _log.info("preallocated %d slots for trading_date=%s", created, trading_date) - self._last_fired_date = trading_date - return trading_date diff --git a/trex_core/application/safety_sell.py b/trex_core/application/safety_sell.py index c3c7872..96604f4 100644 --- a/trex_core/application/safety_sell.py +++ b/trex_core/application/safety_sell.py @@ -25,7 +25,7 @@ from datetime import datetime, time as time_cls, timedelta, timezone from trex_core.application.broker_port import BrokerPort from trex_core.application.watchdog import WatchdogStore -from trex_core.domain.slot import SlotRepository, SlotService, SlotState +from trex_core.domain.slot import SlotRepository, SlotService from trex_core.domain.slot.models import Slot from trex_core.runtime.config import SafetySellConfig @@ -133,13 +133,10 @@ class SafetySellService: )) # ---------------------------------------------------------------- helpers - @staticmethod - def _trading_date(now: datetime) -> date_cls: - return now.astimezone(timezone.utc).date() - def _holding(self, now: datetime) -> list[Slot]: - return [s for s in self._repo.list_slots(self._trading_date(now)) - if s.status is SlotState.HOLDING] + # dcy-020 (BAT-030): permanent asset slots — instance-scoped holding + # sweep (the day-scoped pool listing was removed with trading_date). + return self._repo.list_holding_slots(self._instance_key) def _watchdog_expired(self, slot: Slot) -> bool: return self._watchdog.is_expired(str(slot.slot_id), self._cfg.watchdog_timeout_sec) diff --git a/trex_core/application/snapshot_scheduler.py b/trex_core/application/snapshot_scheduler.py index 949f597..df35f58 100644 --- a/trex_core/application/snapshot_scheduler.py +++ b/trex_core/application/snapshot_scheduler.py @@ -40,8 +40,6 @@ class SnapshotScheduler: slot_repo=None, slot_service=None, instance_key: str = "default", - reconcile_enabled: bool = False, - trading_date_fn: Optional[Callable[[], object]] = None, ) -> None: self._read = read_port self._store = store @@ -50,13 +48,12 @@ class SnapshotScheduler: self._clock = clock or (lambda: datetime.now(tz=timezone.utc)) self._daemon_id: Optional[str] = None self._last_fired_date: Optional[datetime] = None - # dcy-020 / REQ-058 (BAT-028): asset-centric slot reconcile after each - # universe fetch (only when enabled = asset-centric mode). + # dcy-020 / REQ-058 (BAT-030): asset-centric slot reconcile after each + # universe fetch — always on when slot_repo is wired (the reconcile is + # what creates/retires the permanent asset slots). self._slot_repo = slot_repo self._slot_service = slot_service self._instance_key = instance_key - self._reconcile_enabled = reconcile_enabled - self._trading_date_fn = trading_date_fn self._last_universe_size: Optional[int] = None # ---- callable from a daemon thread loop -------------------------------- @@ -112,7 +109,7 @@ class SnapshotScheduler: reconcile_positions(store=self._store, broker=self._broker) except Exception: # noqa: BLE001 _log.exception("reconciliation pass failed") - if self._reconcile_enabled and self._slot_repo is not None: + if self._slot_repo is not None: try: self._reconcile_slots(items) except Exception: # noqa: BLE001 @@ -144,12 +141,8 @@ class SnapshotScheduler: "slot reconcile: universe shrank %d->%d (>=50%%) — retire held", prior, len(universe), ) - if self._trading_date_fn is not None: - trading_date = self._trading_date_fn() - else: - trading_date = self._clock().date() result = self._slot_repo.reconcile_universe( - self._instance_key, trading_date, universe, allow_retire=allow_retire, + self._instance_key, universe, allow_retire=allow_retire, ) self._last_universe_size = len(universe) if any(result.values()): diff --git a/trex_core/domain/slot/__init__.py b/trex_core/domain/slot/__init__.py index 3bf5b54..31cd9df 100644 --- a/trex_core/domain/slot/__init__.py +++ b/trex_core/domain/slot/__init__.py @@ -4,12 +4,12 @@ Storage-agnostic pure domain: no broker, DB driver, CLI, or config dependency. A repository implementation is injected via the `SlotRepository` protocol. Public API: -- SlotState: enum of 6 slot states + allowed transitions +- SlotState: enum of 5 slot states + allowed transitions - Slot: data class for a slot row - SlotCapacity: capacity snapshot returned by compute_slot_capacity - SlotTransitionRecord: append-only transition record - EXPECTED_SLOT_FIELDS: canonical `Slot` column contract (guarded by test_contract) -- SlotService: main domain service (preallocate, transit, capacity, entry count) +- SlotService: main domain service (transit, sell, thresholds, capacity) - SlotRepository: abstract storage protocol - SlotTransitionError / SlotStateMismatchError / SlotNotFoundError: domain errors """ @@ -34,7 +34,6 @@ from trex_core.domain.slot.state import ( SLOT_ADVISORY_LOCK_ID, SlotState, is_allowed_transition, - normalize_state, slot_advisory_lock_id, ) @@ -53,6 +52,5 @@ __all__ = [ "SlotTransitionError", "SlotTransitionRecord", "is_allowed_transition", - "normalize_state", "slot_advisory_lock_id", ] diff --git a/trex_core/domain/slot/in_memory_repository.py b/trex_core/domain/slot/in_memory_repository.py index 8971cb0..f6db5bb 100644 --- a/trex_core/domain/slot/in_memory_repository.py +++ b/trex_core/domain/slot/in_memory_repository.py @@ -1,4 +1,4 @@ -"""In-memory SlotRepository — for unit tests and current trex-core runtime. +"""In-memory SlotRepository — for unit tests. Simulates advisory lock via threading.Lock so contention tests are still meaningful even without a real PostgreSQL instance. @@ -8,7 +8,7 @@ from __future__ import annotations import threading from contextlib import contextmanager from dataclasses import replace -from datetime import date, datetime, timezone +from datetime import datetime, timezone from typing import Iterator, Optional from uuid import UUID, uuid4 @@ -23,7 +23,6 @@ class InMemorySlotRepository: def __init__(self) -> None: self._slots: dict[UUID, Slot] = {} self._transitions: list[SlotTransitionRecord] = [] - self._entries: dict[tuple[date, str], tuple[int, datetime]] = {} # Per-lock-id threading.Lock; mirrors pg_advisory_xact_lock semantics # at process level. lock_time and contention can be observed. self._lock_table: dict[int, threading.Lock] = {} @@ -48,53 +47,16 @@ class InMemorySlotRepository: return lock # --- daily_slot CRUD --- - def pre_allocate_slots(self, trading_date: date, count: int) -> int: - existing = [s for s in self._slots.values() if s.trading_date == trading_date] - if existing: - return 0 - now = datetime.now(tz=timezone.utc) - for _ in range(count): - slot_id = uuid4() - self._slots[slot_id] = Slot( - slot_id=slot_id, - trading_date=trading_date, - status=SlotState.IDLE, - created_at=now, - updated_at=now, - ) - return count + def add_slot(self, slot: Slot) -> None: + """Test seeding helper — insert a slot row directly.""" + self._slots[slot.slot_id] = slot - def list_slots(self, trading_date: date) -> list[Slot]: - return [s for s in self._slots.values() if s.trading_date == trading_date] + def list_slots(self, instance_key: str) -> list[Slot]: + return [s for s in self._slots.values() if s.instance_key == instance_key] def fetch_slot(self, slot_id: UUID) -> Optional[Slot]: return self._slots.get(slot_id) - def find_idle_slot(self, trading_date: date) -> Optional[Slot]: - for s in self._slots.values(): - if s.trading_date == trading_date and s.status is SlotState.IDLE: - return s - return None - - def find_allocatable_slot(self, trading_date: date) -> Optional[Slot]: - # dcy-019 / REQ-006: idle ∪ closed candidates, ordered by entry_count asc - # → updated_at asc → slot_id (deterministic, OQ-2). - candidates = [ - s for s in self._slots.values() - if s.trading_date == trading_date - and s.status in (SlotState.IDLE, SlotState.CLOSED) - ] - if not candidates: - return None - return min( - candidates, - key=lambda s: ( - s.entry_count, - s.updated_at or datetime.min.replace(tzinfo=timezone.utc), - str(s.slot_id), - ), - ) - def update_slot(self, slot: Slot) -> None: if slot.slot_id not in self._slots: raise SlotNotFoundError(f"slot not found: {slot.slot_id}") @@ -122,7 +84,7 @@ class InMemorySlotRepository: ] def reconcile_universe( - self, instance_key: str, trading_date: date, universe_portfolio_ids: set[str], + self, instance_key: str, universe_portfolio_ids: set[str], *, allow_retire: bool = True, ) -> dict[str, int]: existing = { @@ -136,7 +98,7 @@ class InMemorySlotRepository: if pid not in existing: sid = uuid4() self._slots[sid] = Slot( - slot_id=sid, trading_date=trading_date, status=SlotState.IDLE, + slot_id=sid, status=SlotState.INACTIVE, instance_key=instance_key, portfolio_id=pid, created_at=now, updated_at=now, ) @@ -165,27 +127,23 @@ class InMemorySlotRepository: def all_transition_log(self) -> list[SlotTransitionRecord]: return list(self._transitions) - # --- daily_symbol_entries --- - def increment_symbol_entry(self, trading_date: date, symbol: str) -> int: - key = (trading_date, symbol) - count, _ = self._entries.get(key, (0, datetime.min)) - new_count = count + 1 - self._entries[key] = (new_count, datetime.now(tz=timezone.utc)) - return new_count - - def get_symbol_entry_count(self, trading_date: date, symbol: str) -> int: - count, _ = self._entries.get((trading_date, symbol), (0, datetime.min)) - return count - # --- order execution queue (BAT-004 / REQ-013, option A) --- _PENDING = (SlotState.PENDING_BUY, SlotState.PENDING_SELL) - def find_pending_for_dispatch(self) -> Optional[Slot]: - """A pending slot not yet dispatched (broker_order_id IS NULL).""" - for s in self._slots.values(): - if s.status in self._PENDING and s.broker_order_id is None: - return s - return None + def list_undispatched_pending( + self, instance_key: str = "default", limit: int = 64, + ) -> list[Slot]: + """Pending slots not yet dispatched (broker_order_id IS NULL). + + dcy-020/REQ-056 (BAT-030): instance-scoped so two daemons sharing a DB + never dispatch each other's orders. List-based (vs single-row claim) so + one skipped sell (broker flat) cannot head-block the dispatch queue.""" + out = [ + s for s in self._slots.values() + if (s.instance_key == instance_key and s.status in self._PENDING + and s.broker_order_id is None) + ] + return out[:limit] def mark_dispatched(self, slot_id: UUID, broker_order_id: str) -> bool: """Atomically set broker_order_id iff still undispatched (idempotency guard).""" @@ -197,11 +155,12 @@ class InMemorySlotRepository: ) return True - def list_dispatched_pending(self) -> list[Slot]: + def list_dispatched_pending(self, instance_key: str = "default") -> list[Slot]: """Pending slots already dispatched (broker_order_id NOT NULL) — poll targets.""" return [ s for s in self._slots.values() - if s.status in self._PENDING and s.broker_order_id is not None + if (s.instance_key == instance_key and s.status in self._PENDING + and s.broker_order_id is not None) ] def clear_dispatch(self, slot_id: UUID) -> None: @@ -213,10 +172,10 @@ class InMemorySlotRepository: ) # --- bulk counts --- - def count_slots_by_status(self, trading_date: date) -> dict[SlotState, int]: + def count_slots_by_status(self, instance_key: str) -> dict[SlotState, int]: result: dict[SlotState, int] = {} for s in self._slots.values(): - if s.trading_date != trading_date: + if s.instance_key != instance_key: continue result[s.status] = result.get(s.status, 0) + 1 return result diff --git a/trex_core/domain/slot/models.py b/trex_core/domain/slot/models.py index 1581273..30e97a7 100644 --- a/trex_core/domain/slot/models.py +++ b/trex_core/domain/slot/models.py @@ -1,8 +1,8 @@ -"""Slot domain data classes (REQ-001, REQ-002).""" +"""Slot domain data classes (REQ-001, REQ-002; dcy-020/REQ-056 asset-centric).""" from __future__ import annotations from dataclasses import dataclass, field -from datetime import date, datetime +from datetime import datetime from decimal import Decimal from typing import Optional from uuid import UUID @@ -14,43 +14,37 @@ from trex_core.domain.slot.state import SlotState class Slot: """Single daily_slot row. - Matches the slot column contract recorded in dcy-001 부록 A and - docs/data-model.md (trex-core stores this in-memory for now; a future - persistence layer must satisfy the same contract). + dcy-020/REQ-056 (BAT-030): the slot is a **permanent asset slot** — one row + per (instance_key, portfolio_id) universe asset, living across trading days. + Pool-model columns `trading_date` and `entry_count` were dropped at the + cutover (migration 011); time attributes live on events/ledger rows, not on + the slot. Matches docs/data-model.md. """ slot_id: UUID - trading_date: date status: SlotState symbol: Optional[str] = None qty: Optional[float] = None avg_price: Optional[float] = None # dcy-012 / REQ-002: side-agnostic "current pending order limit price" # (renamed from entry_price). Set on pending entry (buy/sell limit), cleared - # on pending exit (→HOLDING, →CLOSED) so a buy limit never leaks into a sell - # order (issue #10). None => market order at dispatch. + # on pending exit (→HOLDING, →INACTIVE) so a buy limit never leaks into a + # sell order (issue #10). None => market order at dispatch. limit_price: Optional[float] = None allocated_capital: Optional[float] = None order_id: Optional[str] = None strategy_id: Optional[str] = None broker_order_id: Optional[str] = None # BAT-003 / REQ-011 (trex-orbit lineage) - portfolio_id: Optional[str] = None # BAT-006 / REQ-025 (trex-orbit lineage) + portfolio_id: Optional[str] = None # BAT-006 / REQ-025; asset binding (REQ-056) sell_strategy_id: Optional[str] = None # BAT-008 / REQ-047 (trex-orbit lineage) forced_close_at: Optional[datetime] = None # BAT-014 / REQ-066 (trex-orbit lineage) stop_loss_price: Optional[Decimal] = None # REQ-035 / BAT-010 take_profit_price: Optional[Decimal] = None # REQ-035 / BAT-010 - # dcy-019 / REQ-002 (BAT-025): slot-scoped cumulative entry count. +1 on each - # allocation transition (idle/closed → pending_buy); drives admission slot - # selection (lowest entry_count first). Distinct from the symbol-scoped - # daily_symbol_entries counter (REQ-002 AC-3) — different key/purpose. - entry_count: int = 0 created_at: Optional[datetime] = None updated_at: Optional[datetime] = None - # dcy-020 / REQ-056 (BAT-027): asset-centric permanent slot foundation (안 B). - # instance_key scopes slots per daemon instance in a shared DB - # (UNIQUE(instance_key, portfolio_id)); advisory lock is also instance-scoped. - # retired_at marks a soft-retired / universe-detached slot (OQ-2). Both added - # additively here; entry_count/trading_date removal is deferred to cutover (BAT-δ). + # dcy-020 / REQ-056: instance_key scopes slots per daemon instance in a + # shared DB (UNIQUE(instance_key, portfolio_id)); advisory lock is also + # instance-scoped. retired_at marks a soft-retired / universe-detached slot. instance_key: str = "default" retired_at: Optional[datetime] = None @@ -61,7 +55,6 @@ class Slot: # / docs/data-model.md), not here — names/presence only. EXPECTED_SLOT_FIELDS: frozenset[str] = frozenset({ "slot_id", - "trading_date", "status", "symbol", "qty", @@ -76,7 +69,6 @@ EXPECTED_SLOT_FIELDS: frozenset[str] = frozenset({ "forced_close_at", "stop_loss_price", # REQ-035 "take_profit_price", # REQ-035 - "entry_count", # dcy-019 / REQ-002 (BAT-025) "created_at", "updated_at", "instance_key", # dcy-020 / REQ-056 (BAT-027) @@ -86,11 +78,20 @@ EXPECTED_SLOT_FIELDS: frozenset[str] = frozenset({ @dataclass(frozen=True) class SlotCapacity: - """Snapshot of slot capacity returned by compute_slot_capacity (REQ-002 AC-6).""" + """Capacity snapshot returned by compute_slot_capacity (REQ-002 AC-6; + dcy-020/REQ-059 asset-centric redefinition, BAT-030). - idle_count: int + active_count = holding + pending (position-occupying). available_count is + the remaining admission headroom: max(0, M − active) under a max_active_slots + cap M, else the inactive (admittable) slot count. total_slots = M when set, + else the instance's slot count. scarcity_ratio = 1 − available/total. + """ + + inactive_count: int holding_count: int pending_count: int + active_count: int + available_count: int total_slots: int scarcity_ratio: float diff --git a/trex_core/domain/slot/repository.py b/trex_core/domain/slot/repository.py index e67dfdf..758ed73 100644 --- a/trex_core/domain/slot/repository.py +++ b/trex_core/domain/slot/repository.py @@ -1,13 +1,12 @@ -"""Abstract slot repository protocol (REQ-002). +"""Abstract slot repository protocol (REQ-002; dcy-020 asset-centric). Concrete implementations: -- InMemorySlotRepository — unit tests / current trex-core runtime -- (future) PostgreSQL-backed repository — added in the persistence iteration +- InMemorySlotRepository — unit tests +- PostgreSQLSlotRepository — production persistence """ from __future__ import annotations from contextlib import AbstractContextManager -from datetime import date from typing import Optional, Protocol from uuid import UUID @@ -22,6 +21,11 @@ class SlotRepository(Protocol): - `transactional_advisory_lock()` runs the with-block inside a single transaction protected by an advisory lock. External I/O MUST NOT be performed inside the block. - `append_transition_log()` is append-only (no UPDATE/DELETE). + + dcy-020 (BAT-030): slots are permanent asset slots keyed by + (instance_key, portfolio_id); all listing/counting is instance-scoped. + The pool-model surface (pre_allocate_slots / find_idle_slot / + find_allocatable_slot / daily_symbol_entries) was removed at the cutover. """ # --- lock + transaction --- @@ -33,32 +37,14 @@ class SlotRepository(Protocol): ... # --- daily_slot CRUD --- - def pre_allocate_slots(self, trading_date: date, count: int) -> int: - """Insert `count` idle slots for trading_date if none exist; return inserted count. - - Idempotent — if slots for trading_date already exist, returns 0 (REQ-002 AC-4). - """ - ... - - def list_slots(self, trading_date: date) -> list[Slot]: - """Return all slots for the trading day.""" + def list_slots(self, instance_key: str) -> list[Slot]: + """Return all slots owned by this instance.""" ... def fetch_slot(self, slot_id: UUID) -> Optional[Slot]: """Return single slot row or None.""" ... - def find_idle_slot(self, trading_date: date) -> Optional[Slot]: - """Return any one idle slot (None if exhausted).""" - ... - - def find_allocatable_slot(self, trading_date: date) -> Optional[Slot]: - """Return the best buy-admission candidate among idle ∪ closed slots - (dcy-019 / REQ-006), ordered by `entry_count` asc → `updated_at` asc → - `slot_id`. None if neither idle nor closed slots remain. Concurrency on - the last candidate is handled by the caller (advisory lock + retry).""" - ... - def update_slot(self, slot: Slot) -> None: """Persist slot row (UPDATE by slot_id).""" ... @@ -66,8 +52,7 @@ class SlotRepository(Protocol): # --- asset-centric (dcy-020 / BAT-028) --- def find_slot_by_portfolio(self, instance_key: str, portfolio_id: str) -> Optional[Slot]: """Return the (single, permanent) slot bound to `portfolio_id` for this - instance (dcy-020/REQ-059), or None. Keyed by (instance_key, portfolio_id) - — independent of trading_date.""" + instance (dcy-020/REQ-059), or None.""" ... def count_active(self, instance_key: str) -> int: @@ -77,11 +62,11 @@ class SlotRepository(Protocol): def list_holding_slots(self, instance_key: str) -> list[Slot]: """Return holding slots for this instance (dcy-020/REQ-062 divergence - reconcile).""" + reconcile; safety-sell / kill-switch sweeps).""" ... def reconcile_universe( - self, instance_key: str, trading_date: date, universe_portfolio_ids: set[str], + self, instance_key: str, universe_portfolio_ids: set[str], *, allow_retire: bool = True, ) -> dict[str, int]: """Reconcile the slot set to the portfolio-hub universe (dcy-020/REQ-058). @@ -101,19 +86,7 @@ class SlotRepository(Protocol): """Append one row to slot_transition_log (no UPDATE/DELETE allowed).""" ... - # --- daily_symbol_entries --- - def increment_symbol_entry(self, trading_date: date, symbol: str) -> int: - """Upsert (trading_date, symbol) with entry_count += 1, last_entry_at = now. - - Returns the resulting entry_count (REQ-002 AC-3). - """ - ... - - def get_symbol_entry_count(self, trading_date: date, symbol: str) -> int: - """Return today's entry_count for symbol, or 0 if no row.""" - ... - # --- bulk counts for capacity --- - def count_slots_by_status(self, trading_date: date) -> dict[SlotState, int]: - """Return dict mapping SlotState -> count for trading_date.""" + def count_slots_by_status(self, instance_key: str) -> dict[SlotState, int]: + """Return dict mapping SlotState -> count for this instance.""" ... diff --git a/trex_core/domain/slot/service.py b/trex_core/domain/slot/service.py index 0ed0839..422beac 100644 --- a/trex_core/domain/slot/service.py +++ b/trex_core/domain/slot/service.py @@ -1,17 +1,19 @@ -"""Slot domain service — 4 main APIs (REQ-002). +"""Slot domain service (REQ-002; dcy-020 asset-centric). -- preallocate_daily_slots(trading_date, count) - transit_slot(slot_id, from_state, to_state, reason, actor, **fields) -- get_today_entry_count(trading_date, symbol) -- compute_slot_capacity(trading_date, total_slots) +- sell(slot_id, actor, reason, limit_price=None) +- update_thresholds(slot_id, ...) +- compute_slot_capacity(max_active_slots=None) All state-mutating APIs run inside repository.transactional_advisory_lock() to serialize Buy/Sell decision threads + operator CLI (REQ-002 AC-2). +dcy-020 (BAT-030): the pool-model surface (daily preallocation, symbol entry +counters) was removed at the cutover; slots are permanent asset slots. """ from __future__ import annotations from dataclasses import replace -from datetime import date, datetime, timezone +from datetime import datetime, timezone from decimal import Decimal from typing import Any, Optional from uuid import UUID @@ -44,21 +46,9 @@ class SlotService: self._instance_key = instance_key self._lock_id = slot_advisory_lock_id(instance_key) - # ------------------------------------------------------------ - # preallocate_daily_slots - # ------------------------------------------------------------ - def preallocate_daily_slots(self, trading_date: date, count: int) -> int: - """Insert `count` idle slots for trading_date if none exist. - - Idempotent: second+ call on the same trading_date returns 0. - Returns number of slots inserted. - - REQ-002 AC-4, REQ-003 AC-5 (count must be > 0). - """ - if count <= 0: - raise ValueError(f"slot count must be > 0, got {count}") - with self._repo.transactional_advisory_lock(self._lock_id): - return self._repo.pre_allocate_slots(trading_date, count) + @property + def instance_key(self) -> str: + return self._instance_key # ------------------------------------------------------------ # transit_slot @@ -94,7 +84,6 @@ class SlotService: 3. verify (from_state, to_state) is in the allowed set 4. update slot row (fields per to_state semantics) 5. append transition_log row - 6. on closed->idle, increment daily_symbol_entries (REQ-002 AC-3) External I/O (broker, kis-data-hub, portfolio-hub) MUST NOT be performed inside this function — caller does I/O outside the lock (REQ-002 AC-5). @@ -135,14 +124,6 @@ class SlotService: ) self._repo.update_slot(updated) - # closed -> idle recovery: bump daily_symbol_entries atomically - # (REQ-002 AC-3, REQ-001 split-by-purpose). - if from_state is SlotState.CLOSED and to_state is SlotState.IDLE: - # Use the symbol carried by the slot before reset. - symbol_for_entry = slot.symbol - if symbol_for_entry: - self._repo.increment_symbol_entry(slot.trading_date, symbol_for_entry) - self._repo.append_transition_log( SlotTransitionRecord( slot_id=slot_id, @@ -177,23 +158,19 @@ class SlotService: ) -> Slot: """Compute the post-transition Slot row. - Special: closed -> idle resets occupancy fields (FR-2 #7). - Special: pending_buy -> idle resets occupancy (REQ-011 AC-6 rollback). + dcy-020/REQ-057 (BAT-030): entering INACTIVE deactivates the permanent + asset slot — all position/occupancy fields are cleared while the asset + identity (instance_key, portfolio_id, symbol, retired_at) is preserved. + Covers buy rollback (pending_buy→inactive), sell fill + (pending_sell→inactive) and operator error resolve (error→inactive). dcy-012 / REQ-002: limit_price is set only while a slot is in a pending - state (the live order's limit); entering any non-pending state - (IDLE/HOLDING/CLOSED) clears it so a buy limit never leaks into the next - sell order (issue #10 root cause). + state (the live order's limit); entering any non-pending state clears it + so a buy limit never leaks into the next sell order (issue #10). """ - if to_state is SlotState.IDLE and slot.status in { - SlotState.CLOSED, - SlotState.PENDING_BUY, - }: - # Recovery / rollback: clear occupancy + broker handle. - # transition_log retains audit trail. + if to_state is SlotState.INACTIVE: return replace( slot, - status=SlotState.IDLE, - symbol=None, + status=SlotState.INACTIVE, qty=None, avg_price=None, limit_price=None, @@ -201,7 +178,6 @@ class SlotService: order_id=None, strategy_id=None, broker_order_id=None, - portfolio_id=None, sell_strategy_id=None, forced_close_at=None, stop_loss_price=None, @@ -209,30 +185,13 @@ class SlotService: updated_at=datetime.now(tz=timezone.utc), ) - # dcy-019 / REQ-002 (BAT-025): closed-slot direct reallocation. A closed - # slot still carries the prior cycle's occupancy; start from a clean - # slate so a previous position/broker handle never leaks into the new - # entry. entry_count is intentionally preserved (slot-scoped cumulative). - if to_state is SlotState.PENDING_BUY and slot.status is SlotState.CLOSED: - slot = replace( - slot, - symbol=None, qty=None, avg_price=None, limit_price=None, - allocated_capital=None, order_id=None, strategy_id=None, - broker_order_id=None, portfolio_id=None, sell_strategy_id=None, - forced_close_at=None, stop_loss_price=None, take_profit_price=None, - ) - # limit_price lifecycle (dcy-012 FR-1): set only on entering a pending - # state (the order's limit); cleared on entering HOLDING/CLOSED. + # state (the order's limit); cleared on entering HOLDING/ERROR. if to_state in {SlotState.PENDING_BUY, SlotState.PENDING_SELL}: new_limit_price = limit_price if limit_price is not None else slot.limit_price - else: # HOLDING / CLOSED — pending order limit no longer applies. + else: # HOLDING / ERROR — pending order limit no longer applies. new_limit_price = None - # dcy-019 / REQ-002: +1 on each allocation transition (→pending_buy), - # for both idle→pending_buy and closed→pending_buy (FR-4). - new_entry_count = slot.entry_count + (1 if to_state is SlotState.PENDING_BUY else 0) - # Generic update: caller-supplied fields override; None means "keep". return replace( slot, @@ -266,7 +225,6 @@ class SlotService: take_profit_price=( take_profit_price if take_profit_price is not None else slot.take_profit_price ), - entry_count=new_entry_count, # dcy-019 / REQ-002 updated_at=datetime.now(tz=timezone.utc), ) @@ -353,37 +311,38 @@ class SlotService: self._repo.update_slot(updated) return "updated" - # ------------------------------------------------------------ - # get_today_entry_count - # ------------------------------------------------------------ - def get_today_entry_count(self, trading_date: date, symbol: str) -> int: - """Return today's entry_count for a symbol (0 if no entries yet). - - Used by buy decision state gate to enforce re-entry policy. - Reads daily_symbol_entries (decision input), NEVER queries - slot_transition_log (audit only). REQ-001, dcy-001 5.3.5.5. - """ - return self._repo.get_symbol_entry_count(trading_date, symbol) - # ------------------------------------------------------------ # compute_slot_capacity # ------------------------------------------------------------ - def compute_slot_capacity(self, trading_date: date, total_slots: int) -> SlotCapacity: - """Return idle/holding/pending counts + scarcity_ratio (REQ-002 AC-6). + def compute_slot_capacity(self, max_active_slots: Optional[int] = None) -> SlotCapacity: + """Return instance-scoped capacity counts (REQ-002 AC-6; dcy-020/REQ-059). - scarcity_ratio = 1 - idle_count / total_slots, clamped to [0.0, 1.0]. + active = holding + pending (position-occupying). Under a + `max_active_slots` cap M, available = max(0, M − active) and + total_slots = M; without a cap, available = inactive slot count and + total_slots = the instance's slot count. + scarcity_ratio = 1 − available/total, clamped to [0.0, 1.0]. """ - if total_slots <= 0: - raise ValueError(f"total_slots must be > 0, got {total_slots}") - counts = self._repo.count_slots_by_status(trading_date) - idle = counts.get(SlotState.IDLE, 0) + counts = self._repo.count_slots_by_status(self._instance_key) + inactive = counts.get(SlotState.INACTIVE, 0) holding = counts.get(SlotState.HOLDING, 0) pending = counts.get(SlotState.PENDING_BUY, 0) + counts.get(SlotState.PENDING_SELL, 0) - scarcity = max(0.0, min(1.0, 1.0 - (idle / total_slots))) + active = holding + pending + if max_active_slots is not None and max_active_slots > 0: + total = max_active_slots + available = max(0, max_active_slots - active) + else: + total = sum(counts.values()) + available = inactive + scarcity = ( + max(0.0, min(1.0, 1.0 - (available / total))) if total > 0 else 0.0 + ) return SlotCapacity( - idle_count=idle, + inactive_count=inactive, holding_count=holding, pending_count=pending, - total_slots=total_slots, + active_count=active, + available_count=available, + total_slots=total, scarcity_ratio=scarcity, ) diff --git a/trex_core/domain/slot/state.py b/trex_core/domain/slot/state.py index dcea6b1..337e1f1 100644 --- a/trex_core/domain/slot/state.py +++ b/trex_core/domain/slot/state.py @@ -1,12 +1,10 @@ -"""Slot state machine (REQ-001, REQ-002; dcy-020/REQ-057 asset-centric redefinition). +"""Slot state machine (REQ-001, REQ-002; dcy-020/REQ-057 asset-centric). -dcy-020 (안 B): the asset-centric permanent slot collapses the pool-model `idle` -(never-bought) and `closed` (sold, now flat) into a single `inactive` (no open -position) state, and removes the `closed -> idle` recovery transition. `idle` and -`closed` are retained as **backward-compatible aliases** that normalize to -`inactive` for transition checks, so existing rows/runtime stay valid until the -cutover (BAT-δ) drops the aliases. The canonical transition set is expressed in -terms of `inactive`. +The asset-centric permanent slot has five states. `inactive` means "no open +position" — it replaced the pool-model `idle` (never-bought) and `closed` +(sold, now flat) states, whose aliases were removed at the cutover (BAT-030); +migration 010 rewrites any remaining `idle`/`closed` rows to `inactive` and +swaps the status CHECK constraint accordingly. """ from __future__ import annotations @@ -30,34 +28,18 @@ def slot_advisory_lock_id(instance_key: str) -> int: class SlotState(str, Enum): - """Slot lifecycle states. `inactive` is the asset-centric standard - (dcy-020/REQ-057); `idle`/`closed` are retained as aliases normalizing to - `inactive` (removed at cutover, BAT-δ).""" + """Slot lifecycle states (dcy-020/REQ-057 asset-centric, 5 states).""" - INACTIVE = "inactive" # dcy-020: no open position (replaces idle/closed) - IDLE = "idle" # alias -> inactive (pool-model lineage; drop at BAT-δ) + INACTIVE = "inactive" # no open position (permanent slot at rest) PENDING_BUY = "pending_buy" HOLDING = "holding" PENDING_SELL = "pending_sell" - CLOSED = "closed" # alias -> inactive (pool-model lineage; drop at BAT-δ) ERROR = "error" -# idle/closed are pool-model aliases of the asset-centric `inactive` state. -_INACTIVE_ALIASES: frozenset[SlotState] = frozenset( - {SlotState.IDLE, SlotState.CLOSED, SlotState.INACTIVE} -) - - -def normalize_state(state: SlotState) -> SlotState: - """Map pool-model aliases (idle/closed) to the canonical `inactive`.""" - return SlotState.INACTIVE if state in _INACTIVE_ALIASES else state - - -# Canonical base transitions in terms of `inactive` (dcy-020/REQ-057). -# `closed -> idle` recovery is REMOVED (it normalizes to inactive->inactive, a -# disallowed self-loop). `inactive -> pending_buy` covers both the old -# idle->pending_buy and closed->pending_buy (reallocation) families. +# Canonical transitions (dcy-020/REQ-057). There is no inactive self-loop and +# no closed->idle recovery family — a permanent slot simply returns to +# `inactive` and may activate again via inactive->pending_buy. _BASE_TRANSITIONS: frozenset[tuple[SlotState, SlotState]] = frozenset( { (SlotState.INACTIVE, SlotState.PENDING_BUY), # 1. buy decision (activate) @@ -85,15 +67,11 @@ _FROM_ERROR_TRANSITIONS: frozenset[tuple[SlotState, SlotState]] = frozenset( ) -# Canonical allowed transitions (inactive-based). Use `is_allowed_transition` -# which normalizes aliases before lookup. ALLOWED_TRANSITIONS: frozenset[tuple[SlotState, SlotState]] = ( _BASE_TRANSITIONS | _TO_ERROR_TRANSITIONS | _FROM_ERROR_TRANSITIONS ) def is_allowed_transition(from_state: SlotState, to_state: SlotState) -> bool: - """Return True iff (from_state, to_state) — after normalizing idle/closed to - inactive — is an allowed transition family. `closed->idle` and any - inactive self-loop are rejected (dcy-020/REQ-057).""" - return (normalize_state(from_state), normalize_state(to_state)) in ALLOWED_TRANSITIONS + """Return True iff (from_state, to_state) is an allowed transition.""" + return (from_state, to_state) in ALLOWED_TRANSITIONS diff --git a/trex_core/runtime/cli.py b/trex_core/runtime/cli.py index c1045a1..9d3d277 100644 --- a/trex_core/runtime/cli.py +++ b/trex_core/runtime/cli.py @@ -135,7 +135,9 @@ def _cmd_slots(args, cfg, rpc) -> int: if status in ("holding", "held"): slots = [s for s in slots if s.get("status") == "holding"] elif status == "buyable": - slots = [s for s in slots if s.get("status") == "idle"] + # dcy-020 (BAT-030): admittable = inactive, not universe-detached. + slots = [s for s in slots + if s.get("status") == "inactive" and not s.get("universe_detached")] elif status == "pending": slots = [s for s in slots if s.get("status") in ("pending_buy", "pending_sell")] # dcy-017: holding-slot watchdog state (strategy-absent ownership). @@ -203,7 +205,7 @@ def _cmd_enter(args, cfg, rpc, confirm) -> int: return short payload = { "intent_type": "buy", "intent_id": str(uuid.uuid4()), - "trading_date": _today_iso(cfg), "portfolio_id": args.portfolio_id, + "portfolio_id": args.portfolio_id, "qty": args.qty, "allocated_capital": args.capital, "entry_price": args.entry_price, "strategy_id": args.strategy_id, "sell_strategy_id": args.sell_strategy_id, diff --git a/trex_core/runtime/config.py b/trex_core/runtime/config.py index 3980771..222e609 100644 --- a/trex_core/runtime/config.py +++ b/trex_core/runtime/config.py @@ -139,13 +139,11 @@ class DaemonInstanceConfig: instance_key: str portfolio_hub_token: str = "" - # dcy-014 / REQ-048: trading_date timezone + daily preallocation trigger. - # Optional — absent keys fall back to KST / midnight defaults. + # dcy-014 / REQ-048: operator-local timezone (CLI date display etc.). trading_timezone: str = "Asia/Seoul" - daily_preallocation_time: str = "00:00" - # dcy-020 / REQ-060 (BAT-028): max concurrent active (holding) slots, M. - # Presence is the asset-centric mode gate: set -> universe reconcile + FCFS/M - # admission; absent -> legacy pool model (preallocation + find_allocatable). + # dcy-020 / REQ-060: max concurrent active (holding) slots, M — the FCFS + # admission capacity cap. BAT-030: asset-centric is the only model; absent + # means no cap (operators are expected to set it in production). max_active_slots: Optional[int] = None @staticmethod @@ -169,7 +167,6 @@ class DaemonInstanceConfig: instance_key=str(instance_key), portfolio_hub_token=str(data.get("portfolio_hub_token") or ""), trading_timezone=str(data.get("trading_timezone") or "Asia/Seoul"), - daily_preallocation_time=str(data.get("daily_preallocation_time") or "00:00"), max_active_slots=max_active, ) @@ -177,14 +174,13 @@ class DaemonInstanceConfig: @dataclass(frozen=True) class DaemonConfig: dsn: str - total_slots: int broker: str # 'paper' | 'kis' execution_interval_sec: float # BAT-013 / REQ-049: instance identity for multi-instance queue naming. # "default" keeps legacy literal queue names (backward-compat). instance_key: str = "default" - # dcy-020 / REQ-060 (BAT-028): M (max concurrent active slots) + asset-centric - # mode gate. None -> legacy pool model. + # dcy-020 / REQ-060: M (max concurrent active slots) — FCFS admission cap. + # BAT-030: asset-centric is the only model; None = no cap. max_active_slots: Optional[int] = None kis: Optional[KISConfig] = None # REQ-011: paper broker tuning (only consulted when broker == 'paper'). @@ -204,11 +200,9 @@ class DaemonConfig: # threshold in seconds. 0 disables (default). When >0, dispatched-pending # slots older than this are surfaced (WARN + counter) without state change. pending_stale_threshold_sec: int = 0 - # dcy-014 / REQ-005 (BAT-018): trading_date timezone (default KST) + daily - # preallocation trigger time (HH:MM, TZ-local). Operative surface is the - # per-instance TOML (REQ-048); these are the env-only defaults. + # dcy-014 / REQ-048: operator-local timezone (CLI date display etc.). The + # daily preallocation trigger was removed with the pool model (BAT-030). trading_timezone: str = "Asia/Seoul" - daily_preallocation_time: str = "00:00" # dcy-015 / REQ-019 (BAT-019): ledger outbox retry backoff. base*2^(n-1) # capped at cap (+jitter); after max_attempts a retryable row dead-letters. ledger_retry_base_sec: float = 1.0 @@ -283,7 +277,6 @@ class DaemonConfig: ) return DaemonConfig( dsn=dsn, - total_slots=int(os.environ.get("TREX_TOTAL_SLOTS", "10")), broker=broker, execution_interval_sec=float(os.environ.get("TREX_EXECUTION_INTERVAL_SEC", "1.0")), kis=kis, @@ -323,12 +316,10 @@ class DaemonConfig: pf = base.portfolio_hub if inst.portfolio_hub_token and pf is not None: pf = replace(pf, token=inst.portfolio_hub_token) - # dcy-014 / REQ-048: trading_date TZ + daily trigger come from TOML. return replace( base, instance_key=inst.instance_key, portfolio_hub=pf, trading_timezone=inst.trading_timezone, - daily_preallocation_time=inst.daily_preallocation_time, max_active_slots=inst.max_active_slots, ) diff --git a/trex_core/runtime/cutover.py b/trex_core/runtime/cutover.py new file mode 100644 index 0000000..d9fd37e --- /dev/null +++ b/trex_core/runtime/cutover.py @@ -0,0 +1,235 @@ +"""Asset-centric cutover tool (dcy-020 / REQ-064, BAT-030). + +Full rebuild (design D1): wipe this instance's daily_slot rows and recreate +them from the portfolio-hub universe + the broker balance, with the broker as +the single source of truth for carried-over holdings (consistent with D3). + +Procedure (one transaction): + 1. preflight — abort if any pending_buy/pending_sell rows exist for the + instance (in-flight orders must converge first; run outside the trading + session). + 2. DELETE all of the instance's slot rows. + 3. one permanent slot per universe asset, status `inactive`. + 4. each broker holding (symbol, qty) is mapped symbol→portfolio_id via the + universe and its slot is set to `holding` (qty = broker quantity; + avg_price is left NULL — the balance API does not carry it — WARN). + 5. a broker holding whose symbol is NOT in the universe aborts the cutover + (operator decision; `--allow-unmapped` downgrades to WARN+skip). + 6. audit rows in slot_transition_log (reason `cutover_rebuild`). + 7. optional `--promote-unique`: portfolio_id SET NOT NULL (only after ALL + instances sharing the DB have been cut over). + +The real cutover is OPERATOR-RUN (design D4): `trex-core-cutover --config + --yes` after a rehearsal against a test DB. Rollback = restore the +pre-cutover backup + previous release (BAT-030 design §4). +""" +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, Iterable, Optional +from uuid import uuid4 + +_log = logging.getLogger(__name__) + + +class CutoverAbort(RuntimeError): + """Preflight/mapping condition that must stop the cutover.""" + + +@dataclass +class CutoverReport: + instance_key: str + deleted: int = 0 + created: int = 0 + holdings: int = 0 + unmapped: list[str] = field(default_factory=list) + promoted_unique: bool = False + dry_run: bool = False + + def summary(self) -> str: + return ( + f"cutover[{self.instance_key}] deleted={self.deleted} " + f"created={self.created} holdings={self.holdings} " + f"unmapped={self.unmapped or '[]'} " + f"promote_unique={self.promoted_unique} dry_run={self.dry_run}" + ) + + +def run_cutover( + dsn: str, + instance_key: str, + universe: Iterable[tuple[str, str]], + balances: Iterable[tuple[str, int]], + *, + allow_unmapped: bool = False, + promote_unique: bool = False, + dry_run: bool = False, + psycopg_module: Any = None, +) -> CutoverReport: + """Rebuild the instance's slots from `universe` + `balances` atomically. + + universe: iterable of (portfolio_id, symbol). + balances: iterable of (symbol, hldg_qty) — broker ground truth. + Raises CutoverAbort on in-flight pending rows or unmapped broker holdings + (unless allow_unmapped). dry_run rolls the transaction back at the end. + """ + if psycopg_module is None: + import psycopg # type: ignore[import-not-found] + psycopg_module = psycopg + + uni = [(str(pid), str(sym)) for pid, sym in universe if pid] + by_symbol = {sym: pid for pid, sym in uni} + held = [(str(sym), int(qty)) for sym, qty in balances if int(qty) > 0] + report = CutoverReport(instance_key=instance_key, dry_run=dry_run) + + # mapping check before touching the DB (step 5) + unmapped = [sym for sym, _ in held if sym not in by_symbol] + if unmapped: + if not allow_unmapped: + raise CutoverAbort( + f"broker holds symbols outside the universe: {unmapped} " + "(resolve in portfolio-hub or pass --allow-unmapped to skip them)" + ) + report.unmapped = unmapped + for sym in unmapped: + _log.warning( + "cutover: broker holding %s has no universe portfolio — SKIPPED " + "(unmanaged position; operator attention)", sym, + ) + + now = datetime.now(tz=timezone.utc) + with psycopg_module.connect(dsn, autocommit=False) as conn: + try: + with conn.cursor() as cur: + # 1. preflight: no in-flight pending rows for this instance + cur.execute( + "SELECT count(*) FROM daily_slot WHERE instance_key = %s " + "AND status IN ('pending_buy','pending_sell')", + (instance_key,), + ) + pending = int(cur.fetchone()[0]) + if pending: + raise CutoverAbort( + f"{pending} in-flight pending slot(s) for instance " + f"{instance_key!r} — wait for convergence (run outside " + "the trading session) and retry" + ) + + # 2. wipe the instance's rows (full rebuild, D1) + cur.execute( + "DELETE FROM daily_slot WHERE instance_key = %s", (instance_key,) + ) + report.deleted = cur.rowcount + + # 3+4. recreate: one permanent slot per universe asset + holding_pids = {} + for sym, qty in held: + pid = by_symbol.get(sym) + if pid is not None: + holding_pids[pid] = (sym, qty) + for pid, sym in uni: + sid = uuid4() + if pid in holding_pids: + h_sym, h_qty = holding_pids[pid] + cur.execute( + "INSERT INTO daily_slot (slot_id, status, symbol, qty, " + "instance_key, portfolio_id, created_at, updated_at) " + "VALUES (%s, 'holding', %s, %s, %s, %s, %s, %s)", + (sid, h_sym, h_qty, instance_key, pid, now, now), + ) + report.holdings += 1 + _log.warning( + "cutover: %s carried over as holding qty=%d " + "avg_price=NULL (balance API has no cost basis)", + pid, h_qty, + ) + else: + cur.execute( + "INSERT INTO daily_slot (slot_id, status, symbol, " + "instance_key, portfolio_id, created_at, updated_at) " + "VALUES (%s, 'inactive', %s, %s, %s, %s, %s)", + (sid, sym, instance_key, pid, now, now), + ) + report.created += 1 + # 6. audit trail + cur.execute( + "INSERT INTO slot_transition_log " + "(slot_id, from_state, to_state, ts, reason, actor) " + "VALUES (%s, 'inactive', %s, %s, 'cutover_rebuild', 'cutover')", + (sid, "holding" if pid in holding_pids else "inactive", now), + ) + + # 7. optional promotion (all instances cut over) + if promote_unique: + cur.execute( + "ALTER TABLE daily_slot ALTER COLUMN portfolio_id SET NOT NULL" + ) + report.promoted_unique = True + + if dry_run: + conn.rollback() + else: + conn.commit() + except BaseException: + conn.rollback() + raise + return report + + +def main(argv: Optional[list[str]] = None) -> int: # pragma: no cover - CLI shell + import argparse + + from trex_core.adapters.persistence.migrate import apply_migrations + from trex_core.runtime.config import DaemonConfig, resolve_config_toml + from trex_core.runtime.daemon import _build_broker, _build_portfolio_read + + logging.basicConfig(level=logging.INFO) + parser = argparse.ArgumentParser( + prog="trex-core-cutover", + description="dcy-020/REQ-064: pool→asset-centric full-rebuild cutover " + "(operator-run; rehearse on a test DB first).", + ) + parser.add_argument("--config", required=True, help="instance TOML name/path") + parser.add_argument("--config-path", default=None) + parser.add_argument("--env-file", default=None) + parser.add_argument("--yes", action="store_true", + help="actually apply (otherwise dry-run + rollback)") + parser.add_argument("--allow-unmapped", action="store_true", + help="skip (WARN) broker holdings outside the universe " + "instead of aborting") + parser.add_argument("--promote-unique", action="store_true", + help="portfolio_id SET NOT NULL (only after ALL " + "instances sharing this DB are cut over)") + args = parser.parse_args(argv) + + toml_path = resolve_config_toml(args.config, args.config_path) + config = DaemonConfig.from_config(toml_path, env_file=args.env_file) + apply_migrations(config.dsn) + + read = _build_portfolio_read(config) + daemon_id = read.whoami() + items = read.get_portfolios(daemon_id) + universe = [(it.portfolio_id, it.symbol) for it in items] + broker = _build_broker(config) + balances = [(b.symbol, b.hldg_qty) for b in broker.get_balance()] + + try: + report = run_cutover( + config.dsn, config.instance_key, universe, balances, + allow_unmapped=args.allow_unmapped, + promote_unique=args.promote_unique and args.yes, + dry_run=not args.yes, + ) + except CutoverAbort as exc: + _log.error("cutover ABORTED: %s", exc) + return 2 + _log.info("%s", report.summary()) + if not args.yes: + _log.info("dry-run only — re-run with --yes to apply") + return 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/trex_core/runtime/daemon.py b/trex_core/runtime/daemon.py index 4b8c8f6..5ead00c 100644 --- a/trex_core/runtime/daemon.py +++ b/trex_core/runtime/daemon.py @@ -9,8 +9,8 @@ Threads (started in `run()`): - control RPC consumer — `trex.control.req` (pause/resume/list + kill_switch/resume_kill_switch, REQ-032/043) - execution loop — broker dispatch + poll + slot transitions + event emit - ledger writer — ledger outbox drain (REQ-019) - - snapshot scheduler — daily portfolio-hub snapshot refresh (REQ-030) - - preallocation scheduler — daily slot preallocation in trading TZ (REQ-005, dcy-014) + - snapshot scheduler — daily portfolio-hub snapshot refresh + universe + slot reconcile (REQ-030, dcy-020/REQ-058) - safety-sell worker — forced_close / stop-loss·take-profit / market-close (REQ-037/038/039) Each consumer/publisher uses its OWN RabbitMQTransport instance because @@ -22,9 +22,7 @@ from __future__ import annotations import logging import signal import threading -from datetime import datetime from datetime import time as time_cls -from zoneinfo import ZoneInfo from trex_core.adapters.broker.paper import PaperBroker from trex_core.adapters.ledger import InMemoryLedger, PortfolioHubLedger @@ -63,7 +61,6 @@ from trex_core.application.portfolio import ( SnapshotStore, ) from trex_core.application.safety_sell import SafetySellService -from trex_core.application.preallocation_scheduler import DailyPreallocationScheduler from trex_core.application.snapshot_scheduler import SnapshotScheduler from trex_core.application.watchdog import WatchdogStore from trex_core.domain.slot import SlotService @@ -169,6 +166,7 @@ class Daemon: slot_repo=self.slot_repo, slot_service=self.slot_service, killswitch_store=self.killswitch_store, + instance_key=config.instance_key, ) self.portfolio_read = _build_portfolio_read(config) self.snapshot_scheduler = SnapshotScheduler( @@ -176,22 +174,12 @@ class Daemon: store=self.snapshot_store, broker=self.broker, schedule_kst=_parse_kst_time(config.snapshot_schedule.kst_time), - # dcy-020 / REQ-058·062 (BAT-028/029): asset-centric slot reconcile + - # core-holding divergence reconcile when M set. + # dcy-020 / REQ-058·062 (BAT-030): universe slot reconcile (creates/ + # retires the permanent asset slots) + core-holding divergence + # reconcile — always on (asset-centric is the only model). slot_repo=self.slot_repo, slot_service=self.slot_service, instance_key=config.instance_key, - reconcile_enabled=config.max_active_slots is not None, - trading_date_fn=(lambda: datetime.now(ZoneInfo(config.trading_timezone)).date()), - ) - # dcy-014 / REQ-005: daily slot preallocation in the configured - # trading timezone (default KST), so trading_date matches the local - # calendar and the next day's slots appear without a restart. - self.preallocation_scheduler = DailyPreallocationScheduler( - slot_service=self.slot_service, - total_slots=config.total_slots, - tz=ZoneInfo(config.trading_timezone), - schedule_time=_parse_kst_time(config.daily_preallocation_time), ) # Event publisher transport — one dedicated instance for the @@ -204,6 +192,7 @@ class Daemon: ledger_outbox=self.outbox, event_emitter=self._make_event_emitter(), stale_pending_threshold_sec=config.pending_stale_threshold_sec, + instance_key=config.instance_key, ) # BAT-011: safety-sell trigger service (forced_close / SL-TP / market-close). @@ -278,9 +267,10 @@ class Daemon: pass handler = make_snapshot_rpc_handler( self.slot_repo, self.slot_service, + instance_key=self.config.instance_key, snapshot_store=self.snapshot_store, pause_store=self.pause_store, - default_total_slots=self.config.total_slots, + max_active_slots=self.config.max_active_slots, watchdog_store=self.watchdog, watchdog_timeout_sec=self.config.safety_sell.watchdog_timeout_sec, killswitch_store=self.killswitch_store, @@ -319,19 +309,12 @@ class Daemon: except Exception: # noqa: BLE001 _log.exception("snapshot scheduler loop crashed") - def _preallocation_loop(self) -> None: - try: - self.preallocation_scheduler.run_forever(self.stop, tick_sec=60.0) - except Exception: # noqa: BLE001 - _log.exception("preallocation scheduler loop crashed") - # ------------------------------------------------------------ lifecycle def setup(self) -> None: apply_migrations(self.config.dsn) - # dcy-014 / REQ-005: preallocate against the configured trading timezone - # (default KST), not the system UTC date. Boot-time run_once ensures the - # current TZ-day's slots exist immediately (idempotent). - self.preallocation_scheduler.run_once() + # dcy-020 (BAT-030): the daily pool preallocation is gone — permanent + # asset slots are created/retired by the snapshot scheduler's universe + # reconcile (boot fetch runs immediately in its thread). self.execution.reconcile() # REQ-014 restart recovery try: self.event_publisher.declare_topology(self.config.instance_key) @@ -346,7 +329,6 @@ class Daemon: ("trex-snapshot-rpc", self._snapshot_rpc_loop), ("trex-control-rpc", self._control_rpc_loop), ("trex-snapshot-scheduler", self._snapshot_scheduler_loop), - ("trex-preallocation", self._preallocation_loop), ("trex-safety-sell", self._safety_sell_loop), ] for name, target in workers: