feat(slot): BAT-030 — 자산 중심 cutover 완성 (dcy-020 BAT-δ, REQ-056·057·062·064·065)

M1 literal inactive 전면화: SlotState 5-state(idle/closed 별칭 제거),
마이그레이션 010(CHECK 교체+행 rewrite), ops_checks 를 transition_log 기반 재작성.
M2 집행 매도 수량 브로커 정본(D3): dispatch 매도 qty=broker hldg_qty,
미러 fallback 없음, 브로커 0 → skip+WARN, 리스트 기반 dispatch(head-block 방지).
M3 컬럼 drop + legacy pool 제거: 마이그레이션 011(trading_date·entry_count DROP),
repo 계약 instance 스코프 개편(집행 큐 포함), preallocation 폐기(데몬 7스레드),
admission 자산 중심 단일 경로(slot_id 정본), capacity 재정의(available=M−active),
daily_symbol_entries 코드 경로 제거.
M4 cutover 도구(D1·D4): trex-core-cutover(전체 rebuild, preflight/브로커 carry-over/
--allow-unmapped/--promote-unique/dry-run) + PG 리허설 테스트 8건.
M5 baseline 마감: data-model/runtime-flows/project-structure/interface-contract
자산 중심 최종 갱신 + run-trex-core-daemon 스킬 정합.

검증: 단위 367 passed + ruff clean; PG-gated 29 passed(010/011 round-trip·드리프트
가드·cutover 리허설); RabbitMQ 21 passed; run-the-app e2e(test DB+실 MQ, M=1):
reconcile 슬롯 생성→slot_id 매수→dedup→capacity_full→fill→already_active→
매도→inactive(자산 바인딩 보존)→영구 슬롯 재활성 전 구간 green.
실 cutover 는 운영자 실행(REL 단계, D4).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 15:16:15 +09:00
co-authored by Claude Opus 4.8
parent cec34be5f4
commit 7d727824dc
67 changed files with 1584 additions and 1862 deletions
+45 -32
View File
@@ -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 <name>` (required since BAT-013) resolves `config/<name>.toml`:
`instance_key` (queue names: `trex.intent.req.<key>` 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 <instance> &> /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 |
+73 -152
View File
@@ -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 <toml>` (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
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("=== 3) duplicate buy (same intent_id) ===")
d2 = intent({"intent_type": "buy", "intent_id": iid, "slot_id": sid, "qty": 10})
print(f"identical: {d2 == d}")
print("=== 4) wait for fill ===")
for _ in range(20):
time.sleep(0.5)
else:
print("\n!! no holding slot appeared in 10s — execution loop stuck?"); return 4
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']}")
print(f"buy events captured so far: "
f"{[rk for rk, _ in ev_received if rk.startswith('slot_filled_buy.')]}")
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)")
# 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)}")
# 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(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")
mq.close()
print("DONE")
return 0
finally:
ev_stop.set()
try:
client.close()
except Exception:
pass
ev_thread.join(timeout=2.0)
_cleanup_queue(ev_queue)
if __name__ == "__main__":
sys.exit(main())
raise SystemExit(main())
+5 -5
View File
@@ -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
@@ -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 <instance> --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).
@@ -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 마감)
+1 -1
View File
@@ -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/) |
+29 -25
View File
@@ -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 <instance> --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).
+32 -29
View File
@@ -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": "<correlation_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 <ISO8601>`(절대시간)로 설정하며(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": "<SlotState 값>", "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).
+16 -13
View File
@@ -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 <path>.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 <path>.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).
+29 -36
View File
@@ -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 <path>.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 <path>.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).
+1
View File
@@ -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"]
@@ -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()
+42 -13
View File
@@ -1,34 +1,46 @@
"""Ledger-gap operational check tests (REQ-055 / dcy-011 FR-5, issue #11).
PostgreSQL-gated (TREX_POSTGRESQL_TEST_DSN). Inserts closed slots + outbox rows
directly and asserts find_ledger_gap_slots detects only the gaps.
PostgreSQL-gated (TREX_POSTGRESQL_TEST_DSN). dcy-020 (BAT-030): the detector
keys off slot_transition_log sell fills (the `closed` status no longer
exists). Inserts sold slots + outbox rows directly and asserts
find_ledger_gap_slots detects only the gaps.
"""
from __future__ import annotations
import uuid
from datetime import date
from datetime import date, datetime, timezone
from trex_core.adapters.persistence.ops_checks import find_ledger_gap_slots
_DAY = date(2026, 6, 8)
_DAY = date(2026, 6, 10)
_TS = datetime(2026, 6, 10, 6, 0, tzinfo=timezone.utc)
def _insert_slot(conn, *, status, portfolio_id, symbol="005930"):
sid = str(uuid.uuid4())
with conn.cursor() as cur:
cur.execute(
"INSERT INTO daily_slot (slot_id, trading_date, status, symbol, portfolio_id) "
"VALUES (%s, %s, %s, %s, %s)",
(sid, _DAY, status, symbol, portfolio_id),
"INSERT INTO daily_slot (slot_id, status, symbol, portfolio_id) "
"VALUES (%s, %s, %s, %s)",
(sid, status, symbol, portfolio_id),
)
return sid
def _insert_sell_fill(conn, slot_id, *, reason="sell_filled", ts=_TS):
with conn.cursor() as cur:
cur.execute(
"INSERT INTO slot_transition_log (slot_id, from_state, to_state, ts, reason, actor) "
"VALUES (%s, 'pending_sell', 'inactive', %s, %s, 'execution_loop')",
(slot_id, ts, reason),
)
def _insert_outbox(conn, *, asset_id, order_id="O1", execution_id="E1"):
with conn.cursor() as cur:
cur.execute(
"INSERT INTO ledger_outbox (correlation_id, asset_id, order_id, execution_id, "
"side, qty, price) VALUES (%s, %s, %s, %s, 'buy', 1, 1000)",
"side, qty, price) VALUES (%s, %s, %s, %s, 'sell', 1, 1000)",
(f"{asset_id}:{order_id}:{execution_id}", asset_id, order_id, execution_id),
)
@@ -37,23 +49,40 @@ def test_detects_only_gap_slots(pg_clean):
import psycopg
dsn = pg_clean
with psycopg.connect(dsn, autocommit=True) as conn:
gap_null = _insert_slot(conn, status="closed", portfolio_id=None) # gap (no pf)
gap_no_outbox = _insert_slot(conn, status="closed", portfolio_id="PF-GAP") # gap (no outbox row)
ok = _insert_slot(conn, status="closed", portfolio_id="PF-OK") # not a gap
gap_null = _insert_slot(conn, status="inactive", portfolio_id=None) # gap (no pf)
_insert_sell_fill(conn, gap_null)
gap_no_outbox = _insert_slot(conn, status="inactive", portfolio_id="PF-GAP")
_insert_sell_fill(conn, gap_no_outbox) # gap (no outbox row)
ok = _insert_slot(conn, status="inactive", portfolio_id="PF-OK") # not a gap
_insert_sell_fill(conn, ok)
_insert_outbox(conn, asset_id="PF-OK")
_insert_slot(conn, status="holding", portfolio_id=None) # not closed → ignored
never_sold = _insert_slot(conn, status="holding", portfolio_id=None) # no sell fill → ignored
gaps = find_ledger_gap_slots(dsn, trading_date=_DAY)
gap_ids = {str(g["slot_id"]) for g in gaps}
assert gap_null in gap_ids
assert gap_no_outbox in gap_ids
assert ok not in gap_ids
assert never_sold not in gap_ids
def test_no_gaps_when_all_recorded(pg_clean):
import psycopg
dsn = pg_clean
with psycopg.connect(dsn, autocommit=True) as conn:
_insert_slot(conn, status="closed", portfolio_id="PF-1")
sid = _insert_slot(conn, status="inactive", portfolio_id="PF-1")
_insert_sell_fill(conn, sid)
_insert_outbox(conn, asset_id="PF-1")
assert find_ledger_gap_slots(dsn, trading_date=_DAY) == []
def test_date_scope_filters_by_sell_fill_day(pg_clean):
import psycopg
dsn = pg_clean
other_day_ts = datetime(2026, 6, 9, 6, 0, tzinfo=timezone.utc)
with psycopg.connect(dsn, autocommit=True) as conn:
sid = _insert_slot(conn, status="inactive", portfolio_id="PF-OLD")
_insert_sell_fill(conn, sid, ts=other_day_ts)
assert find_ledger_gap_slots(dsn, trading_date=_DAY) == []
gaps = find_ledger_gap_slots(dsn, trading_date=date(2026, 6, 9))
assert {str(g["slot_id"]) for g in gaps} == {sid}
@@ -1,10 +1,7 @@
"""PostgreSQL integration tests (REQ-006). Skipped without TREX_POSTGRESQL_TEST_DSN."""
"""PostgreSQL integration tests (REQ-006; BAT-030 asset-centric).
Skipped without TREX_POSTGRESQL_TEST_DSN."""
from __future__ import annotations
from datetime import date
import pytest
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore # noqa: F401
from trex_core.adapters.persistence.postgresql import (
PostgreSQLIntentStore,
@@ -12,15 +9,17 @@ from trex_core.adapters.persistence.postgresql import (
)
from trex_core.application.intents import BuyIntent, IntentService, SellIntent
from trex_core.domain.slot import SlotService, SlotState
from trex_core.domain.slot.errors import SlotTransitionError
DAY = date(2026, 6, 3)
INST = "default"
_SYMS = {"PF-1": "005930", "PF-2": "000660", "PF-3": "035720"}
def _wire(dsn):
def _wire(dsn, max_active=None):
repo = PostgreSQLSlotRepository(dsn)
svc = SlotService(repo)
intents = IntentService(svc, repo, PostgreSQLIntentStore(dsn))
svc = SlotService(repo, instance_key=INST)
intents = IntentService(svc, repo, PostgreSQLIntentStore(dsn),
resolve_symbol=_SYMS.get,
instance_key=INST, max_active_slots=max_active)
return repo, svc, intents
@@ -30,30 +29,33 @@ def test_universe_reconcile_pg(pg_clean):
# partial-index upsert + find_slot_by_portfolio/count_active.
repo = PostgreSQLSlotRepository(pg_clean)
inst = "inst-pg"
r1 = repo.reconcile_universe(inst, DAY, {"pf-a", "pf-b"})
r1 = repo.reconcile_universe(inst, {"pf-a", "pf-b"})
assert r1["created"] == 2
r2 = repo.reconcile_universe(inst, DAY, {"pf-a", "pf-b"}) # idempotent (ON CONFLICT)
r2 = repo.reconcile_universe(inst, {"pf-a", "pf-b"}) # idempotent (ON CONFLICT)
assert r2["created"] == 0
assert repo.find_slot_by_portfolio(inst, "pf-a") is not None
slot_a = repo.find_slot_by_portfolio(inst, "pf-a")
assert slot_a is not None and slot_a.status is SlotState.INACTIVE
assert repo.count_active(inst) == 0
# pf-b leaves universe while inactive -> soft-retire
r3 = repo.reconcile_universe(inst, DAY, {"pf-a"})
r3 = repo.reconcile_universe(inst, {"pf-a"})
assert r3["retired"] == 1
assert repo.find_slot_by_portfolio(inst, "pf-b").retired_at is not None
def test_preallocate_and_capacity(pg_clean):
def test_capacity_instance_scoped(pg_clean):
repo, svc, _ = _wire(pg_clean)
assert svc.preallocate_daily_slots(DAY, 10) == 10
assert svc.preallocate_daily_slots(DAY, 10) == 0 # idempotent
cap = svc.compute_slot_capacity(DAY, 10)
assert cap.idle_count == 10 and cap.scarcity_ratio == 0.0
repo.reconcile_universe(INST, set(_SYMS))
cap = svc.compute_slot_capacity()
assert cap.inactive_count == 3 and cap.available_count == 3
cap_m = svc.compute_slot_capacity(max_active_slots=2)
assert cap_m.total_slots == 2 and cap_m.available_count == 2
assert cap_m.scarcity_ratio == 0.0
def test_buy_intent_persists_transition(pg_clean):
repo, svc, intents = _wire(pg_clean)
svc.preallocate_daily_slots(DAY, 3)
d = intents.submit_buy(BuyIntent(intent_id="pg-buy-1", trading_date=DAY, symbol="005930", qty=10, portfolio_id="PF-1"))
repo.reconcile_universe(INST, set(_SYMS))
d = intents.submit_buy(BuyIntent(intent_id="pg-buy-1", qty=10, portfolio_id="PF-1"))
assert d.admitted
slot = repo.fetch_slot(d.slot_id)
assert slot.status is SlotState.PENDING_BUY and slot.symbol == "005930"
@@ -61,29 +63,57 @@ def test_buy_intent_persists_transition(pg_clean):
def test_buy_intent_idempotent_on_pg(pg_clean):
repo, svc, intents = _wire(pg_clean)
svc.preallocate_daily_slots(DAY, 3)
d1 = intents.submit_buy(BuyIntent(intent_id="pg-dup", trading_date=DAY, symbol="005930", portfolio_id="PF-1"))
d2 = intents.submit_buy(BuyIntent(intent_id="pg-dup", trading_date=DAY, symbol="005930", portfolio_id="PF-1"))
repo.reconcile_universe(INST, set(_SYMS))
d1 = intents.submit_buy(BuyIntent(intent_id="pg-dup", portfolio_id="PF-1"))
d2 = intents.submit_buy(BuyIntent(intent_id="pg-dup", portfolio_id="PF-1"))
assert d1.slot_id == d2.slot_id and d2.status == "admitted"
pending = [s for s in repo.list_slots(DAY) if s.status is SlotState.PENDING_BUY]
pending = [s for s in repo.list_slots(INST) if s.status is SlotState.PENDING_BUY]
assert len(pending) == 1 # no double transition
def test_full_lifecycle_then_closed_reallocation_bumps_entry_count(pg_clean):
# dcy-020: closed→idle recovery removed. Reuse a closed slot via
# closed→pending_buy reallocation (dcy-019); entry_count is cumulative.
def test_buy_by_slot_id_contract_pg(pg_clean):
# dcy-020/REQ-061: slot_id is the canonical buy key against real PG.
repo, svc, intents = _wire(pg_clean)
svc.preallocate_daily_slots(DAY, 1)
d = intents.submit_buy(BuyIntent(intent_id="pg-life", trading_date=DAY, symbol="005930", portfolio_id="PF-1"))
repo.reconcile_universe(INST, set(_SYMS))
slot = repo.find_slot_by_portfolio(INST, "PF-2")
d = intents.submit_buy(BuyIntent(intent_id="pg-sid", slot_id=slot.slot_id))
assert d.admitted and d.slot_id == slot.slot_id
assert repo.fetch_slot(slot.slot_id).symbol == "000660"
def test_full_lifecycle_returns_to_inactive_and_reuses(pg_clean):
# dcy-020 (BAT-030): sell fill -> inactive (occupancy cleared, asset
# binding kept); the same permanent slot activates again.
repo, svc, intents = _wire(pg_clean)
repo.reconcile_universe(INST, set(_SYMS))
d = intents.submit_buy(BuyIntent(intent_id="pg-life", portfolio_id="PF-1"))
sid = d.slot_id
svc.transit_slot(sid, SlotState.PENDING_BUY, SlotState.HOLDING, reason="filled", actor="t")
svc.transit_slot(sid, SlotState.PENDING_BUY, SlotState.HOLDING,
reason="filled", actor="t", qty=10.0, avg_price=1000.0)
intents.submit_sell(SellIntent(intent_id="pg-sell", slot_id=sid))
svc.transit_slot(sid, SlotState.PENDING_SELL, SlotState.CLOSED, reason="filled", actor="t")
assert repo.fetch_slot(sid).entry_count == 1
# closed→idle is now rejected
with pytest.raises(SlotTransitionError):
svc.transit_slot(sid, SlotState.CLOSED, SlotState.IDLE, reason="recovery", actor="t")
# reuse via reallocation
realloc = svc.transit_slot(sid, SlotState.CLOSED, SlotState.PENDING_BUY,
reason="reallocate", actor="t", symbol="000660")
assert realloc.entry_count == 2 and realloc.symbol == "000660"
svc.transit_slot(sid, SlotState.PENDING_SELL, SlotState.INACTIVE,
reason="sell_filled", actor="t")
flat = repo.fetch_slot(sid)
assert flat.status is SlotState.INACTIVE
assert flat.qty is None and flat.portfolio_id == "PF-1"
# permanent reuse
again = svc.transit_slot(sid, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="re-entry", actor="t", symbol="005930")
assert again.status is SlotState.PENDING_BUY
def test_dispatch_queue_instance_scoped(pg_clean):
# dcy-020/REQ-056 (BAT-030): list_undispatched_pending / list_dispatched_pending
# never cross instance boundaries in a shared DB.
repo_a = PostgreSQLSlotRepository(pg_clean)
repo_a.reconcile_universe("inst-a", {"pf-x"})
repo_a.reconcile_universe("inst-b", {"pf-x"})
svc_a = SlotService(repo_a, instance_key="inst-a")
slot_a = repo_a.find_slot_by_portfolio("inst-a", "pf-x")
svc_a.transit_slot(slot_a.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="r", actor="t", symbol="005930")
assert [s.slot_id for s in repo_a.list_undispatched_pending("inst-a")] == [slot_a.slot_id]
assert repo_a.list_undispatched_pending("inst-b") == []
repo_a.mark_dispatched(slot_a.slot_id, "ord-1")
assert [s.slot_id for s in repo_a.list_dispatched_pending("inst-a")] == [slot_a.slot_id]
assert repo_a.list_dispatched_pending("inst-b") == []
@@ -1,14 +1,11 @@
"""dcy-020 / REQ-059 (BAT-028): asset-centric buy admission (FCFS·M캡·over-cap)."""
"""dcy-020 / REQ-059 (BAT-028; BAT-030 단일 경로): asset-centric buy admission (FCFS·M캡·over-cap)."""
from __future__ import annotations
from datetime import date
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
from trex_core.application.intents import BuyIntent, IntentService
from trex_core.domain.slot import SlotService, SlotState
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
DAY = date(2026, 6, 10)
INST = "inst-1"
# portfolio_id -> symbol (universe resolver)
_SYMS = {"pf-a": "005930", "pf-b": "000660", "pf-c": "035720"}
@@ -27,8 +24,8 @@ def _wire(max_active):
def test_activates_asset_slot():
repo, svc, intents = _wire(max_active=2)
repo.reconcile_universe(INST, DAY, {"pf-a", "pf-b"})
d = intents.submit_buy(BuyIntent(intent_id="b1", trading_date=DAY, portfolio_id="pf-a"))
repo.reconcile_universe(INST, {"pf-a", "pf-b"})
d = intents.submit_buy(BuyIntent(intent_id="b1", portfolio_id="pf-a"))
assert d.status == "admitted"
slot = repo.find_slot_by_portfolio(INST, "pf-a")
assert slot.status is SlotState.PENDING_BUY and slot.symbol == "005930"
@@ -37,9 +34,9 @@ def test_activates_asset_slot():
def test_activates_by_slot_id():
# dcy-020/REQ-061: slot_id is the canonical buy contract key.
repo, svc, intents = _wire(max_active=2)
repo.reconcile_universe(INST, DAY, {"pf-a"})
repo.reconcile_universe(INST, {"pf-a"})
slot = repo.find_slot_by_portfolio(INST, "pf-a")
d = intents.submit_buy(BuyIntent(intent_id="b1", trading_date=DAY, slot_id=slot.slot_id))
d = intents.submit_buy(BuyIntent(intent_id="b1", slot_id=slot.slot_id))
assert d.status == "admitted" and d.slot_id == slot.slot_id
got = repo.fetch_slot(slot.slot_id)
assert got.status is SlotState.PENDING_BUY and got.symbol == "005930" # resolved from slot.portfolio_id
@@ -48,25 +45,25 @@ def test_activates_by_slot_id():
def test_unknown_slot_for_bad_slot_id():
from uuid import uuid4
repo, svc, intents = _wire(max_active=2)
repo.reconcile_universe(INST, DAY, {"pf-a"})
d = intents.submit_buy(BuyIntent(intent_id="b1", trading_date=DAY, slot_id=uuid4()))
repo.reconcile_universe(INST, {"pf-a"})
d = intents.submit_buy(BuyIntent(intent_id="b1", slot_id=uuid4()))
assert d.status == "rejected" and d.reason == "unknown_slot"
def test_already_active_is_rejected():
repo, svc, intents = _wire(max_active=2)
repo.reconcile_universe(INST, DAY, {"pf-a", "pf-b"})
intents.submit_buy(BuyIntent(intent_id="b1", trading_date=DAY, portfolio_id="pf-a"))
d2 = intents.submit_buy(BuyIntent(intent_id="b2", trading_date=DAY, portfolio_id="pf-a"))
repo.reconcile_universe(INST, {"pf-a", "pf-b"})
intents.submit_buy(BuyIntent(intent_id="b1", portfolio_id="pf-a"))
d2 = intents.submit_buy(BuyIntent(intent_id="b2", portfolio_id="pf-a"))
assert d2.status == "rejected" and d2.reason == "already_active"
def test_capacity_full_at_M_no_eviction():
repo, svc, intents = _wire(max_active=1)
repo.reconcile_universe(INST, DAY, {"pf-a", "pf-b"})
d1 = intents.submit_buy(BuyIntent(intent_id="b1", trading_date=DAY, portfolio_id="pf-a"))
repo.reconcile_universe(INST, {"pf-a", "pf-b"})
d1 = intents.submit_buy(BuyIntent(intent_id="b1", portfolio_id="pf-a"))
assert d1.status == "admitted"
d2 = intents.submit_buy(BuyIntent(intent_id="b2", trading_date=DAY, portfolio_id="pf-b"))
d2 = intents.submit_buy(BuyIntent(intent_id="b2", portfolio_id="pf-b"))
assert d2.status == "rejected" and d2.reason == "capacity_full"
# existing holding untouched (no eviction)
assert repo.find_slot_by_portfolio(INST, "pf-a").status is SlotState.PENDING_BUY
@@ -74,32 +71,31 @@ def test_capacity_full_at_M_no_eviction():
def test_unknown_portfolio_when_no_asset_slot():
repo, svc, intents = _wire(max_active=2)
repo.reconcile_universe(INST, DAY, {"pf-a"})
repo.reconcile_universe(INST, {"pf-a"})
# pf-c is resolvable to a symbol but has no reconciled asset slot
d = intents.submit_buy(BuyIntent(intent_id="b1", trading_date=DAY, portfolio_id="pf-c"))
d = intents.submit_buy(BuyIntent(intent_id="b1", portfolio_id="pf-c"))
assert d.status == "rejected" and d.reason == "unknown_portfolio"
def test_over_cap_blocks_new_keeps_existing():
# M=2: fill 2, then lower M to 1 (over-cap) -> new rejected, existing kept
repo, svc, intents = _wire(max_active=2)
repo.reconcile_universe(INST, DAY, {"pf-a", "pf-b", "pf-c"})
intents.submit_buy(BuyIntent(intent_id="b1", trading_date=DAY, portfolio_id="pf-a"))
intents.submit_buy(BuyIntent(intent_id="b2", trading_date=DAY, portfolio_id="pf-b"))
repo.reconcile_universe(INST, {"pf-a", "pf-b", "pf-c"})
intents.submit_buy(BuyIntent(intent_id="b1", portfolio_id="pf-a"))
intents.submit_buy(BuyIntent(intent_id="b2", portfolio_id="pf-b"))
assert repo.count_active(INST) == 2
intents._max_active_slots = 1 # operator lowered M (over-cap)
d = intents.submit_buy(BuyIntent(intent_id="b3", trading_date=DAY, portfolio_id="pf-c"))
d = intents.submit_buy(BuyIntent(intent_id="b3", portfolio_id="pf-c"))
assert d.status == "rejected" and d.reason == "capacity_full"
# both existing positions kept
assert repo.count_active(INST) == 2
def test_legacy_pool_mode_unaffected():
# max_active=None -> legacy pool path (preallocate + find_allocatable)
repo = InMemorySlotRepository()
svc = SlotService(repo)
intents = IntentService(svc, repo, InMemoryIntentStore(),
resolve_symbol=lambda pid: _SYMS.get(pid))
svc.preallocate_daily_slots(DAY, 1)
d = intents.submit_buy(BuyIntent(intent_id="b1", trading_date=DAY, portfolio_id="pf-a"))
def test_no_cap_admits_without_capacity_check():
# BAT-030: max_active_slots=None -> no capacity cap (asset-centric only path)
repo, svc, intents = _wire(max_active=None)
repo.reconcile_universe(INST, {"pf-a", "pf-b", "pf-c"})
for i, pid in enumerate(sorted(_SYMS)):
d = intents.submit_buy(BuyIntent(intent_id=f"b{i}", portfolio_id=pid))
assert d.status == "admitted"
assert repo.count_active(INST) == 3
@@ -1,85 +0,0 @@
"""dcy-019 / BAT-025: buy admission reallocates closed slots (REQ-007).
When no idle slot remains, _admit_buy must admit into a CLOSED slot via the new
closed→pending_buy transition, ordered by entry_count (least-entered first).
"""
from __future__ import annotations
from datetime import date
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
from trex_core.application.intents import BuyIntent, IntentService
from trex_core.domain.slot import SlotService, SlotState
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
DAY = date(2026, 6, 9)
def _cycle_to_closed(svc, slot_id):
svc.transit_slot(slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
reason="r", actor="a", symbol="005930")
svc.transit_slot(slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
reason="r", actor="a", avg_price=70_000)
svc.transit_slot(slot_id, SlotState.HOLDING, SlotState.PENDING_SELL,
reason="r", actor="a")
svc.transit_slot(slot_id, SlotState.PENDING_SELL, SlotState.CLOSED,
reason="r", actor="a")
def test_admits_into_closed_slot_when_no_idle_remains():
repo = InMemorySlotRepository()
svc = SlotService(repo)
intents = IntentService(svc, repo, InMemoryIntentStore())
svc.preallocate_daily_slots(DAY, 1)
slot = repo.find_idle_slot(DAY)
_cycle_to_closed(svc, slot.slot_id)
assert repo.find_idle_slot(DAY) is None # nothing idle
d = intents.submit_buy(BuyIntent(
intent_id="buy-realloc", trading_date=DAY,
symbol="000660", qty=3, portfolio_id="PF-1",
))
assert d.status == "admitted"
realloc = repo.fetch_slot(d.slot_id)
assert realloc.status is SlotState.PENDING_BUY
assert realloc.symbol == "000660"
# second entry into the same physical slot
assert realloc.entry_count == 2
def test_rejects_when_no_idle_and_no_closed():
repo = InMemorySlotRepository()
svc = SlotService(repo)
intents = IntentService(svc, repo, InMemoryIntentStore())
svc.preallocate_daily_slots(DAY, 1)
slot = repo.find_idle_slot(DAY)
# take it to holding (not closed, not idle → not allocatable)
svc.transit_slot(slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
reason="r", actor="a", symbol="005930")
svc.transit_slot(slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
reason="r", actor="a", avg_price=70_000)
d = intents.submit_buy(BuyIntent(
intent_id="buy-none", trading_date=DAY,
symbol="000660", portfolio_id="PF-1",
))
assert d.status == "rejected"
assert d.reason == "no_idle_slot"
def test_prefers_idle_over_closed():
repo = InMemorySlotRepository()
svc = SlotService(repo)
intents = IntentService(svc, repo, InMemoryIntentStore())
svc.preallocate_daily_slots(DAY, 2)
slots = repo.list_slots(DAY)
_cycle_to_closed(svc, slots[0].slot_id) # one closed (entry_count=1)
d = intents.submit_buy(BuyIntent(
intent_id="buy-prefer-idle", trading_date=DAY,
symbol="000660", portfolio_id="PF-1",
))
assert d.status == "admitted"
chosen = repo.fetch_slot(d.slot_id)
# the fresh idle slot (entry_count 0→1) is preferred over the closed one
assert chosen.entry_count == 1
+82 -23
View File
@@ -1,11 +1,9 @@
"""Order execution loop tests via paper broker (REQ-013, REQ-014).
"""Order execution loop tests via paper broker (REQ-013, REQ-014; BAT-030 asset-centric).
In-memory adapters; deterministic paper double drives the full async path.
"""
from __future__ import annotations
from datetime import date
from trex_core.adapters.broker.paper import PaperBroker
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
from trex_core.application.execution import ExecutionService
@@ -13,20 +11,27 @@ from trex_core.application.intents import BuyIntent, IntentService, SellIntent
from trex_core.domain.slot import SlotService, SlotState
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
DAY = date(2026, 6, 4)
INST = "default"
SYMBOLS = {f"PF-{i}": f"00593{i}" for i in range(5)}
def _wire(broker):
def _wire(broker, max_active_slots=None):
repo = InMemorySlotRepository()
svc = SlotService(repo)
intents = IntentService(svc, repo, InMemoryIntentStore())
execution = ExecutionService(svc, repo, broker)
svc.preallocate_daily_slots(DAY, 5)
svc = SlotService(repo, instance_key=INST)
intents = IntentService(
svc, repo, InMemoryIntentStore(),
resolve_symbol=SYMBOLS.get,
instance_key=INST, max_active_slots=max_active_slots,
)
execution = ExecutionService(svc, repo, broker, instance_key=INST)
repo.reconcile_universe(INST, set(SYMBOLS)) # permanent asset slots
return repo, svc, intents, execution
def _buy(intents, iid="b1", symbol="005930", qty=10):
return intents.submit_buy(BuyIntent(intent_id=iid, trading_date=DAY, symbol=symbol, qty=qty, entry_price=1000, portfolio_id="PF-1"))
def _buy(intents, iid="b1", pid="PF-1", qty=10):
return intents.submit_buy(
BuyIntent(intent_id=iid, portfolio_id=pid, qty=qty, entry_price=1000)
)
def test_buy_lifecycle_to_holding():
@@ -50,24 +55,74 @@ def test_buy_dispatch_is_idempotent_no_double_order():
assert bid1 == bid2 and len(broker._orders) == 1 # exactly one broker order
def test_buy_reject_rolls_back_to_idle():
repo, svc, intents, execution = _wire(PaperBroker(reject_symbols={"005930"}))
def test_buy_reject_rolls_back_to_inactive():
repo, svc, intents, execution = _wire(PaperBroker(reject_symbols={"005931"}))
sid = _buy(intents).slot_id
execution.run_once() # dispatch + poll(reject)
slot = repo.fetch_slot(sid)
assert slot.status is SlotState.IDLE and slot.symbol is None and slot.broker_order_id is None
assert slot.status is SlotState.INACTIVE and slot.broker_order_id is None
assert slot.portfolio_id == "PF-1" # permanent slot keeps its asset binding
def test_sell_lifecycle_to_closed_bumps_entry_count():
def test_sell_lifecycle_returns_to_inactive():
repo, svc, intents, execution = _wire(PaperBroker(fill_after_polls=0))
sid = _buy(intents).slot_id
execution.run_once() # -> holding
assert repo.fetch_slot(sid).status is SlotState.HOLDING
intents.submit_sell(SellIntent(intent_id="s1", slot_id=sid))
assert repo.fetch_slot(sid).status is SlotState.PENDING_SELL
execution.run_once() # dispatch sell + fill -> closed
assert repo.fetch_slot(sid).status is SlotState.CLOSED
assert svc.get_today_entry_count(DAY, "005930") == 0 # +1 happens on closed->idle recovery
execution.run_once() # dispatch sell + fill -> inactive (asset slot at rest)
slot = repo.fetch_slot(sid)
assert slot.status is SlotState.INACTIVE
assert slot.qty is None and slot.portfolio_id == "PF-1"
def test_sell_qty_is_broker_balance_not_slot_mirror():
"""dcy-020/REQ-062 (D3): sell dispatch quantity = broker hldg_qty even when
the slot mirror qty is stale."""
broker = PaperBroker(fill_after_polls=0)
repo, svc, intents, execution = _wire(broker)
sid = _buy(intents, qty=10).slot_id
execution.run_once() # holding, broker balance = 10
# corrupt the mirror: slot says 7, broker says 10
svc.transit_slot(sid, SlotState.HOLDING, SlotState.PENDING_SELL,
reason="r", actor="t", qty=7.0)
execution.run_once() # dispatch sell with broker qty
sell_orders = [o for o in broker._orders.values() if o.side == "sell"]
assert len(sell_orders) == 1 and sell_orders[0].qty == 10
def test_sell_skipped_when_broker_flat():
"""dcy-020/REQ-062 (D3): broker flat -> sell skipped (no mirror fallback),
surfaced via the sell_skipped_no_balance counter; no broker order is sent."""
broker = PaperBroker(fill_after_polls=0)
repo, svc, intents, execution = _wire(broker)
sid = _buy(intents).slot_id
execution.run_once() # holding (broker balance 10)
broker._orders.clear() # simulate divergence: broker is actually flat
svc.transit_slot(sid, SlotState.HOLDING, SlotState.PENDING_SELL,
reason="r", actor="t")
r = execution.run_once()
assert r.sell_skipped_no_balance == 1
assert r.dispatched == 0
assert repo.fetch_slot(sid).status is SlotState.PENDING_SELL # untouched
assert not [o for o in broker._orders.values() if o.side == "sell"]
def test_skipped_sell_does_not_block_other_dispatches():
"""A broker-flat sell must not head-block the dispatch queue."""
broker = PaperBroker(fill_after_polls=0)
repo, svc, intents, execution = _wire(broker)
sid = _buy(intents, iid="b1", pid="PF-1").slot_id
execution.run_once() # PF-1 holding
broker._orders.clear() # broker flat for PF-1's symbol
svc.transit_slot(sid, SlotState.HOLDING, SlotState.PENDING_SELL,
reason="r", actor="t")
d2 = _buy(intents, iid="b2", pid="PF-2") # a buy behind the stuck sell
r = execution.run_once()
assert r.sell_skipped_no_balance == 1
assert r.dispatched >= 1 # the buy went out
assert repo.fetch_slot(d2.slot_id).status is SlotState.HOLDING
def test_reconcile_after_crash_completes_fill_without_redispatch():
@@ -83,8 +138,12 @@ def test_reconcile_after_crash_completes_fill_without_redispatch():
assert len(broker._orders) == orders_before # reconcile did not submit a new order
def test_no_idle_slots_blocks_further_buys():
repo, svc, intents, execution = _wire(PaperBroker(fill_after_polls=0))
for i in range(5):
assert _buy(intents, iid=f"b{i}").status == "admitted"
assert _buy(intents, iid="overflow").status == "rejected"
def test_capacity_full_blocks_further_buys():
"""dcy-020/REQ-059: at M active slots further buys reject capacity_full."""
repo, svc, intents, execution = _wire(
PaperBroker(fill_after_polls=0), max_active_slots=2,
)
assert _buy(intents, iid="b0", pid="PF-0").status == "admitted"
assert _buy(intents, iid="b1", pid="PF-1").status == "admitted"
over = _buy(intents, iid="b2", pid="PF-2")
assert over.status == "rejected" and over.reason == "capacity_full"
+9 -8
View File
@@ -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
+16 -12
View File
@@ -6,8 +6,6 @@ critical path while the ledger write is eventually-consistent.
"""
from __future__ import annotations
from datetime import date
from trex_core.adapters.broker.paper import PaperBroker
from trex_core.adapters.ledger.in_memory import InMemoryLedger
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
@@ -22,26 +20,26 @@ from trex_core.application.ledger_outbox import (
from trex_core.domain.slot import SlotService, SlotState
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
DAY = date(2026, 6, 4)
def _wire(broker, *, ledger=None, clock=None):
repo = InMemorySlotRepository()
svc = SlotService(repo)
intents = IntentService(svc, repo, InMemoryIntentStore())
intents = IntentService(svc, repo, InMemoryIntentStore(),
resolve_symbol=lambda pid: "005930")
outbox = InMemoryLedgerOutbox(clock=clock) if clock else InMemoryLedgerOutbox()
execution = ExecutionService(svc, repo, broker, ledger_outbox=outbox)
writer = (
LedgerWriter(outbox, ledger or InMemoryLedger(), clock=clock)
if clock else LedgerWriter(outbox, ledger or InMemoryLedger())
)
svc.preallocate_daily_slots(DAY, 5)
repo.reconcile_universe("default", {"PF-1"})
return repo, svc, intents, execution, outbox, writer
def _buy(intents, *, iid="b1", portfolio_id="PF-1"):
return intents.submit_buy(BuyIntent(
intent_id=iid, trading_date=DAY, symbol="005930", qty=10, entry_price=1000,
intent_id=iid, qty=10, entry_price=1000,
portfolio_id=portfolio_id,
))
@@ -73,7 +71,7 @@ def test_sell_fill_enqueues_sell_ledger_item():
intents.submit_sell(SellIntent(intent_id="s1", slot_id=sid))
r = execution.run_once() # dispatch sell + fill -> closed + sell ledger item
assert r.ledger_enqueued == 1
assert repo.fetch_slot(sid).status is SlotState.CLOSED
assert repo.fetch_slot(sid).status is SlotState.INACTIVE
writer.run_once()
sides = [t.side for t in ledger.trades()]
assert sides == ["buy", "sell"]
@@ -86,10 +84,13 @@ def test_portfolio_id_absent_skips_ledger_but_transitions_slot():
# dcy-011: buy admission now requires portfolio_id, so create the
# pending_buy slot directly (bypass admission) to exercise the
# execution-loop ledger skip + visibility (REQ-018 FR-4).
idle = repo.find_idle_slot(DAY)
svc.transit_slot(idle.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
from trex_core.domain.slot.models import Slot
from uuid import uuid4
anon = Slot(slot_id=uuid4(), status=SlotState.INACTIVE)
repo.add_slot(anon)
svc.transit_slot(anon.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="test", symbol="005930", portfolio_id=None)
sid = idle.slot_id
sid = anon.slot_id
r = execution.run_once()
assert r.filled == 1 and r.ledger_enqueued == 0 and r.ledger_skipped == 1
assert repo.fetch_slot(sid).status is SlotState.HOLDING
@@ -103,8 +104,11 @@ def test_ledger_skip_emits_warning(caplog):
ledger = InMemoryLedger()
repo, svc, intents, execution, outbox, writer = _wire(PaperBroker(fill_after_polls=0),
ledger=ledger)
idle = repo.find_idle_slot(DAY)
svc.transit_slot(idle.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
from trex_core.domain.slot.models import Slot
from uuid import uuid4
anon = Slot(slot_id=uuid4(), status=SlotState.INACTIVE)
repo.add_slot(anon)
svc.transit_slot(anon.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="test", symbol="005930", portfolio_id=None)
with caplog.at_level(logging.WARNING, logger="trex_core.application.execution"):
execution.run_once()
+1 -4
View File
@@ -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)
+45 -22
View File
@@ -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)
+7 -10
View File
@@ -22,9 +22,7 @@ def repo():
@pytest.fixture
def service(repo):
svc = SlotService(repo)
svc.preallocate_daily_slots(_DAY, 10)
return svc
return SlotService(repo)
@pytest.fixture
@@ -38,10 +36,11 @@ def ks(repo, service, store):
def _holding(service, repo, *, portfolio_id, symbol="005930"):
idle = repo.find_idle_slot(_DAY)
repo.reconcile_universe("default", {portfolio_id})
slot = repo.find_slot_by_portfolio("default", portfolio_id)
pending = service.transit_slot(
idle.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
reason="buy", actor="test", symbol=symbol, portfolio_id=portfolio_id,
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="test", symbol=symbol,
)
return service.transit_slot(
pending.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
@@ -61,14 +60,12 @@ def test_global_liquidates_all(ks, repo, service, store):
def test_portfolio_scope_limits(ks, repo, service, store):
"""AC-2: portfolio scope only sells that portfolio's slots."""
"""AC-2: portfolio scope only sells that portfolio's slot (1:1 asset slot)."""
a1 = _holding(service, repo, portfolio_id="pf-A", symbol="A1")
a2 = _holding(service, repo, portfolio_id="pf-A", symbol="A2")
b = _holding(service, repo, portfolio_id="pf-B")
n = ks.fire(scope="portfolio", portfolio_id="pf-A", now=_NOW)
assert n == 2
assert n == 1
assert repo.fetch_slot(a1.slot_id).status is SlotState.PENDING_SELL
assert repo.fetch_slot(a2.slot_id).status is SlotState.PENDING_SELL
assert repo.fetch_slot(b.slot_id).status is SlotState.HOLDING
assert store.list_excluded(_DAY) == {"pf-A"}
@@ -3,8 +3,6 @@ from __future__ import annotations
import threading
import time
from datetime import date
from trex_core.adapters.broker.paper import PaperBroker
from trex_core.adapters.ledger.in_memory import InMemoryLedger
from trex_core.adapters.messaging import topology
@@ -17,17 +15,17 @@ from trex_core.application.messaging_handlers import slot_transition_event_paylo
from trex_core.domain.slot import SlotService, SlotState
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
DAY = date(2026, 6, 5)
def _wire(transport):
repo = InMemorySlotRepository()
svc = SlotService(repo)
intents = IntentService(svc, repo, InMemoryIntentStore())
intents = IntentService(svc, repo, InMemoryIntentStore(),
resolve_symbol=lambda pid: "005930")
outbox = InMemoryLedgerOutbox()
ledger = InMemoryLedger()
writer = LedgerWriter(outbox, ledger)
svc.preallocate_daily_slots(DAY, 3)
repo.reconcile_universe("default", {"PF-1"})
def emitter(event_type: str, slot) -> None:
rk = topology.event_routing_key(event_type, slot.strategy_id or "_", slot.symbol or "_")
@@ -57,8 +55,7 @@ def test_fill_emits_slot_filled_event_with_routing_key_and_state():
repo, _, intents, execution, _ = _wire(transport)
intents.submit_buy(BuyIntent(
intent_id="b1", trading_date=DAY, symbol="005930", qty=10,
strategy_id="A", portfolio_id="PF-1",
intent_id="b1", qty=10, strategy_id="A", portfolio_id="PF-1",
))
execution.run_once()
time.sleep(0.05)
@@ -100,8 +97,7 @@ def test_event_for_strategy_a_does_not_reach_strategy_b_queue():
_, _, intents, execution, _ = _wire(transport)
intents.submit_buy(BuyIntent(
intent_id="b1", trading_date=DAY, symbol="005930", qty=10,
strategy_id="A", portfolio_id="PF-1",
intent_id="b1", qty=10, strategy_id="A", portfolio_id="PF-1",
))
execution.run_once()
time.sleep(0.05)
@@ -120,12 +116,11 @@ def test_publish_failure_does_not_break_execution_loop():
repo, _, intents, execution, _ = _wire(transport)
intents.submit_buy(BuyIntent(
intent_id="b1", trading_date=DAY, symbol="005930", qty=10,
strategy_id="A", portfolio_id="PF-1",
intent_id="b1", qty=10, strategy_id="A", portfolio_id="PF-1",
))
r = execution.run_once()
# Slot still transitioned; ledger still enqueued.
assert r.filled == 1
assert r.ledger_enqueued == 1
pending = [s for s in repo.list_slots(DAY) if s.status is SlotState.HOLDING]
pending = [s for s in repo.list_slots("default") if s.status is SlotState.HOLDING]
assert len(pending) == 1
@@ -6,9 +6,6 @@ trigger exactly ONE slot transition (REQ-023 AC-1).
"""
from __future__ import annotations
from datetime import date
from trex_core.adapters.persistence.in_memory_intent_store import InMemoryIntentStore
from trex_core.application.intents import IntentService
from trex_core.application.messaging import RpcRequest
@@ -16,16 +13,16 @@ from trex_core.application.messaging_handlers import make_intent_rpc_handler
from trex_core.domain.slot import SlotService, SlotState
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
DAY = date(2026, 6, 5)
def _wire():
def _wire(max_active=3):
repo = InMemorySlotRepository()
svc = SlotService(repo)
# dcy-011 / REQ-007: buy resolves symbol from the universe via resolver.
intents = IntentService(svc, repo, InMemoryIntentStore(),
resolve_symbol=lambda pid: "005930")
svc.preallocate_daily_slots(DAY, 3)
resolve_symbol=lambda pid: "005930",
max_active_slots=max_active)
repo.reconcile_universe("default", {"PF-1", "PF-2", "PF-3", "PF-9"})
return repo, svc, intents
@@ -35,7 +32,6 @@ def _buy_req(intent_id: str, portfolio_id: str = "PF-1") -> RpcRequest:
payload={
"intent_type": "buy",
"intent_id": intent_id,
"trading_date": DAY.isoformat(),
"portfolio_id": portfolio_id,
"qty": 10,
},
@@ -61,18 +57,18 @@ def test_duplicate_correlation_id_dedups_via_intent_log():
r1 = handler(_buy_req("buy-dup"))
r2 = handler(_buy_req("buy-dup"))
assert r1.payload == r2.payload
pending = [s for s in repo.list_slots(DAY) if s.status is SlotState.PENDING_BUY]
pending = [s for s in repo.list_slots("default") if s.status is SlotState.PENDING_BUY]
assert len(pending) == 1
def test_buy_rejected_when_no_idle_slot():
repo, _, intents = _wire()
def test_buy_rejected_when_capacity_full():
repo, _, intents = _wire(max_active=3)
handler = make_intent_rpc_handler(intents)
for i in range(3):
assert handler(_buy_req(f"buy-{i}")).payload["status"] == "admitted"
resp = handler(_buy_req("overflow"))
for i, pid in enumerate(("PF-1", "PF-2", "PF-3")):
assert handler(_buy_req(f"buy-{i}", portfolio_id=pid)).payload["status"] == "admitted"
resp = handler(_buy_req("overflow", portfolio_id="PF-9"))
assert resp.payload["status"] == "rejected"
assert resp.payload["reason"] == "no_idle_slot"
assert resp.payload["reason"] == "capacity_full"
def test_sell_admission_from_holding():
@@ -121,7 +117,6 @@ def test_correlation_id_is_used_as_intent_id_when_missing_in_payload():
req = RpcRequest(
payload={
"intent_type": "buy",
"trading_date": DAY.isoformat(),
"portfolio_id": "PF-1",
"qty": 5,
},
@@ -131,33 +126,28 @@ def test_correlation_id_is_used_as_intent_id_when_missing_in_payload():
r2 = handler(req)
assert r1.payload["status"] == "admitted"
assert r1.payload == r2.payload
pending = [s for s in repo.list_slots(DAY) if s.status is SlotState.PENDING_BUY]
pending = [s for s in repo.list_slots("default") if s.status is SlotState.PENDING_BUY]
assert len(pending) == 1
# ---- dcy-011 / REQ-007/021: portfolio_id required + symbol resolve ----------
def test_buy_without_portfolio_id_rejected():
"""FR-1 / S-1: missing portfolio_id → portfolio_id_required reject."""
def test_buy_without_key_rejected():
"""dcy-020/REQ-061: missing slot_id AND portfolio_id → reject."""
repo, _, intents = _wire()
handler = make_intent_rpc_handler(intents)
req = RpcRequest(
payload={"intent_type": "buy", "intent_id": "no-pf",
"trading_date": DAY.isoformat(), "qty": 5},
payload={"intent_type": "buy", "intent_id": "no-pf", "qty": 5},
correlation_id="no-pf",
)
resp = handler(req)
assert resp.payload["status"] == "rejected"
assert resp.payload["reason"] == "portfolio_id_required"
assert resp.payload["reason"] == "slot_id_or_portfolio_required"
def test_buy_unknown_portfolio_rejected():
"""FR-3 / S-1: portfolio_id absent from universe → unknown_portfolio reject."""
repo = InMemorySlotRepository()
svc = SlotService(repo)
intents = IntentService(svc, repo, InMemoryIntentStore(),
resolve_symbol=lambda pid: None) # universe empty
svc.preallocate_daily_slots(DAY, 3)
"""FR-3 / S-1: portfolio_id with no asset slot → unknown_portfolio reject."""
repo, _, intents = _wire()
handler = make_intent_rpc_handler(intents)
resp = handler(_buy_req("u1", portfolio_id="PF-X"))
assert resp.payload["status"] == "rejected"
@@ -16,8 +16,9 @@ DAY = date(2026, 6, 5)
def _wire():
repo = InMemorySlotRepository()
svc = SlotService(repo)
intents = IntentService(svc, repo, InMemoryIntentStore())
svc.preallocate_daily_slots(DAY, 3)
intents = IntentService(svc, repo, InMemoryIntentStore(),
resolve_symbol={"PF-1": "005930", "PF-2": "000660"}.get)
repo.reconcile_universe("default", {"PF-1", "PF-2", "PF-3"})
return repo, svc, intents
@@ -35,40 +36,36 @@ def _req(view="all", strategy_id=None):
def test_snapshot_returns_all_slots_and_capacity():
repo, svc, intents = _wire()
intents.submit_buy(BuyIntent(
intent_id="b1", trading_date=DAY, symbol="005930", qty=10,
strategy_id="A", portfolio_id="PF-1",
intent_id="b1", qty=10, strategy_id="A", portfolio_id="PF-1",
))
handler = make_snapshot_rpc_handler(repo, svc)
resp = handler(_req())
assert resp.payload["trading_date"] == DAY.isoformat()
assert resp.payload["view"] == "all"
assert len(resp.payload["slots"]) == 3 # all 3 slots returned
cap = resp.payload["capacity"]
assert cap["pending_count"] == 1 and cap["idle_count"] == 2
assert cap["pending_count"] == 1 and cap["inactive_count"] == 2
assert cap["active_count"] == 1 and cap["available_count"] == 2
def test_snapshot_open_view_filters_to_active_slots():
repo, svc, intents = _wire()
intents.submit_buy(BuyIntent(
intent_id="b1", trading_date=DAY, symbol="005930", qty=10, strategy_id="A",
portfolio_id="PF-1",
intent_id="b1", qty=10, strategy_id="A", portfolio_id="PF-1",
))
handler = make_snapshot_rpc_handler(repo, svc)
resp = handler(_req(view="open"))
statuses = {s["status"] for s in resp.payload["slots"]}
assert "idle" not in statuses
assert "inactive" not in statuses
assert statuses == {"pending_buy"}
def test_snapshot_strategy_id_filter():
repo, svc, intents = _wire()
intents.submit_buy(BuyIntent(
intent_id="b1", trading_date=DAY, symbol="005930", qty=10, strategy_id="A",
portfolio_id="PF-1",
intent_id="b1", qty=10, strategy_id="A", portfolio_id="PF-1",
))
intents.submit_buy(BuyIntent(
intent_id="b2", trading_date=DAY, symbol="000660", qty=5, strategy_id="B",
portfolio_id="PF-2",
intent_id="b2", qty=5, strategy_id="B", portfolio_id="PF-2",
))
handler = make_snapshot_rpc_handler(repo, svc)
resp = handler(_req(view="open", strategy_id="A"))
@@ -88,9 +85,9 @@ class _RecWatchdog:
def _holding_slot(repo, svc):
from trex_core.domain.slot import SlotState
slot = next(s for s in repo.list_slots(DAY) if s.status is SlotState.IDLE)
svc.transit_slot(slot.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
reason="t", actor="t", symbol="005930", portfolio_id="PF-1", qty=10)
slot = repo.find_slot_by_portfolio("default", "PF-1")
svc.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="t", actor="t", symbol="005930", qty=10)
svc.transit_slot(slot.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
reason="t", actor="t", avg_price=1000)
return str(slot.slot_id)
@@ -1,109 +0,0 @@
"""Daily preallocation scheduler tests (dcy-014 / REQ-005, issue #14).
trading_date is derived in a configurable timezone (default KST) with an
injected clock; timestamps stay UTC. Covers KST/UTC boundary, cross-day
auto-create + idempotency, configurable TZ, and trigger-time gating.
"""
from __future__ import annotations
from datetime import date, datetime, time, timedelta, timezone
from zoneinfo import ZoneInfo
from trex_core.application.preallocation_scheduler import DailyPreallocationScheduler
from trex_core.domain.slot import SlotService, SlotState
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
KST = ZoneInfo("Asia/Seoul")
UTC = timezone.utc
def _wire():
repo = InMemorySlotRepository()
svc = SlotService(repo)
return repo, svc
def _dates(repo):
return sorted({s.trading_date for s in repo._slots.values()})
def _count(repo, d):
return sum(1 for s in repo._slots.values() if s.trading_date == d)
def test_trading_date_uses_kst_not_utc_at_midnight_boundary():
"""S-1: 2026-06-08 15:30Z is already 2026-06-09 in KST."""
repo, svc = _wire()
clock = lambda: datetime(2026, 6, 8, 15, 30, tzinfo=UTC) # noqa: E731
sched = DailyPreallocationScheduler(
slot_service=svc, total_slots=10, tz=KST, clock=clock,
)
td = sched.run_once()
assert td == date(2026, 6, 9) # KST date, not UTC 6/8
assert _dates(repo) == [date(2026, 6, 9)]
assert _count(repo, date(2026, 6, 9)) == 10
def test_timestamps_stay_utc():
"""S-2: slot created_at/updated_at remain UTC even with KST trading_date."""
repo, svc = _wire()
clock = lambda: datetime(2026, 6, 8, 15, 30, tzinfo=UTC) # noqa: E731
DailyPreallocationScheduler(
slot_service=svc, total_slots=1, tz=KST, clock=clock,
).run_once()
slot = next(iter(repo._slots.values()))
assert slot.updated_at.utcoffset() == timedelta(0) # UTC
def test_cross_day_boundary_auto_creates_next_day_idempotently():
"""S-3: a day boundary creates next-day slots once; re-ticks add nothing."""
repo, svc = _wire()
now = {"t": datetime(2026, 6, 7, 16, 0, tzinfo=UTC)} # 2026-06-08 01:00 KST
sched = DailyPreallocationScheduler(
slot_service=svc, total_slots=10, tz=KST, clock=lambda: now["t"],
)
sched.run_once() # boot -> 6/8
assert _dates(repo) == [date(2026, 6, 8)]
now["t"] = datetime(2026, 6, 8, 15, 30, tzinfo=UTC) # 2026-06-09 00:30 KST
sched._maybe_tick() # boundary -> 6/9
assert _dates(repo) == [date(2026, 6, 8), date(2026, 6, 9)]
now["t"] = datetime(2026, 6, 8, 16, 30, tzinfo=UTC) # still 6/9 KST
sched._maybe_tick() # already fired today
assert _count(repo, date(2026, 6, 9)) == 10 # no duplicates
def test_configurable_timezone_changes_trading_date():
"""S-4: same instant yields a different trading_date under a different TZ."""
repo, svc = _wire()
clock = lambda: datetime(2026, 6, 8, 15, 30, tzinfo=UTC) # noqa: E731
DailyPreallocationScheduler(
slot_service=svc, total_slots=1, tz=UTC, clock=clock,
).run_once()
assert _dates(repo) == [date(2026, 6, 8)] # UTC date, not KST 6/9
def test_trigger_time_gates_next_day_creation():
"""A configured trigger time defers next-day creation until reached."""
repo, svc = _wire()
now = {"t": datetime(2026, 6, 7, 16, 0, tzinfo=UTC)} # 6/8 01:00 KST
sched = DailyPreallocationScheduler(
slot_service=svc, total_slots=5, tz=KST,
schedule_time=time(hour=7), clock=lambda: now["t"],
)
sched.run_once() # boot -> 6/8
now["t"] = datetime(2026, 6, 8, 18, 0, tzinfo=UTC) # 6/9 03:00 KST (< 07:00)
sched._maybe_tick()
assert date(2026, 6, 9) not in _dates(repo) # not yet
now["t"] = datetime(2026, 6, 8, 22, 30, tzinfo=UTC) # 6/9 07:30 KST (>= 07:00)
sched._maybe_tick()
assert date(2026, 6, 9) in _dates(repo) # created after trigger
def test_idle_slots_created_for_trading_date():
"""Created slots are idle (active only on buy admission)."""
repo, svc = _wire()
clock = lambda: datetime(2026, 6, 8, 1, 0, tzinfo=UTC) # noqa: E731
DailyPreallocationScheduler(
slot_service=svc, total_slots=3, tz=KST, clock=clock,
).run_once()
assert all(s.status is SlotState.IDLE for s in repo._slots.values())
@@ -2,7 +2,7 @@
from __future__ import annotations
import logging
from datetime import date, datetime, timedelta, timezone
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from trex_core.application.safety_sell import SafetySellService
@@ -11,7 +11,6 @@ from trex_core.domain.slot import SlotService, SlotState
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
from trex_core.runtime.config import SafetySellConfig
_DAY = date(2026, 6, 7)
_NOW = datetime(2026, 6, 7, 6, 0, 0, tzinfo=timezone.utc)
@@ -37,8 +36,7 @@ def _cfg(**over):
def _wire(**cfg_over):
repo = InMemorySlotRepository()
svc = SlotService(repo)
svc.preallocate_daily_slots(_DAY, 5)
svc = SlotService(repo, instance_key="inst-1")
broker = _FakeBroker()
watchdog = WatchdogStore()
service = SafetySellService(
@@ -49,9 +47,12 @@ def _wire(**cfg_over):
def _holding(svc, repo, *, symbol="005930", **fields):
idle = repo.find_idle_slot(_DAY)
p = svc.transit_slot(idle.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
reason="buy", actor="t", symbol=symbol, portfolio_id="PF-1", **fields)
from uuid import uuid4
pid = f"pf-{uuid4().hex[:8]}"
repo.reconcile_universe("inst-1", {pid})
slot = repo.find_slot_by_portfolio("inst-1", pid)
p = svc.transit_slot(slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="t", symbol=symbol, **fields)
return svc.transit_slot(p.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
reason="filled", actor="t", avg_price=50000.0, qty=1.0)
+7 -7
View File
@@ -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,
+7 -9
View File
@@ -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
)
)
+3 -3
View File
@@ -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",
+12 -11
View File
@@ -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(
+4 -5
View File
@@ -37,20 +37,19 @@ def _wire(*, slots_setup=None):
pause = InMemoryPauseStore()
repo = InMemorySlotRepository()
svc = SlotService(repo)
svc.preallocate_daily_slots(DAY, 5)
repo.reconcile_universe("default", {"pf-A", "pf-B", "pf-C"})
if slots_setup:
slots_setup(svc, repo)
return store, pause, repo, svc
def _take_slot(svc, repo, *, portfolio_id, symbol, strategy_id, status):
s = repo.find_idle_slot(DAY)
s = repo.find_slot_by_portfolio("default", portfolio_id)
assert s is not None
svc.transit_slot(
s.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
s.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy_intent", actor="t",
symbol=symbol, portfolio_id=portfolio_id,
strategy_id=strategy_id, qty=10,
symbol=symbol, strategy_id=strategy_id, qty=10,
)
if status is SlotState.PENDING_BUY:
return s.slot_id
@@ -1,7 +1,7 @@
"""update_thresholds intent RPC handler tests (REQ-040 / BAT-011)."""
from __future__ import annotations
from datetime import date, datetime
from datetime import datetime
from decimal import Decimal
import pytest
@@ -13,7 +13,6 @@ from trex_core.application.watchdog import WatchdogStore
from trex_core.domain.slot import SlotService, SlotState
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
_DAY = date(2026, 6, 7)
@pytest.fixture
@@ -23,9 +22,7 @@ def repo():
@pytest.fixture
def service(repo):
svc = SlotService(repo)
svc.preallocate_daily_slots(_DAY, 3)
return svc
return SlotService(repo)
@pytest.fixture
@@ -46,9 +43,12 @@ class _StubIntentStore:
def _holding(service, repo, *, symbol="005930", stop_loss_price=None,
take_profit_price=None, forced_close_at=None):
idle = repo.find_idle_slot(_DAY)
from uuid import uuid4
pid = f"pf-{uuid4().hex[:8]}"
repo.reconcile_universe("default", {pid})
slot = repo.find_slot_by_portfolio("default", pid)
pending = service.transit_slot(
idle.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="test", symbol=symbol,
stop_loss_price=stop_loss_price, take_profit_price=take_profit_price,
forced_close_at=forced_close_at,
@@ -111,12 +111,12 @@ def test_explicit_null_clears(handler, service, repo):
def test_non_holding_ignored(handler, service, repo, watchdog):
"""AC-6: non-holding slot → ignored + not_holding reason, no watchdog reset."""
service.preallocate_daily_slots(_DAY, 3)
idle = repo.find_idle_slot(_DAY)
resp = handler(_req(slot_id=str(idle.slot_id), stop_loss_price=50000))
repo.reconcile_universe("default", {"pf-inactive"})
inactive = repo.find_slot_by_portfolio("default", "pf-inactive")
resp = handler(_req(slot_id=str(inactive.slot_id), stop_loss_price=50000))
assert resp.payload["status"] == "ignored"
assert resp.payload["reason"] == "not_holding"
assert str(idle.slot_id) not in watchdog._store
assert str(inactive.slot_id) not in watchdog._store
def test_reply_updated_status(handler, service, repo):
@@ -1,7 +1,6 @@
"""Price threshold field tests (REQ-035 / BAT-010)."""
"""Price threshold field tests (REQ-035 / BAT-010; asset-centric BAT-030)."""
from __future__ import annotations
from datetime import date
from decimal import Decimal
import pytest
@@ -11,10 +10,7 @@ import dataclasses
from trex_core.domain.slot import EXPECTED_SLOT_FIELDS, Slot, SlotService, SlotState
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
@pytest.fixture
def trading_day():
return date(2026, 6, 7)
INST = "default"
@pytest.fixture
@@ -24,14 +20,19 @@ def repo():
@pytest.fixture
def service(repo):
return SlotService(repo)
return SlotService(repo, instance_key=INST)
def _inactive_slot(repo):
repo.reconcile_universe(INST, {"pf-1"})
return repo.find_slot_by_portfolio(INST, "pf-1")
class TestSlotModelPriceFields:
def test_price_fields_default_to_none(self):
"""AC-1: stop_loss_price and take_profit_price default to None."""
from uuid import uuid4
slot = Slot(slot_id=uuid4(), trading_date=date(2026, 6, 7), status=SlotState.IDLE)
slot = Slot(slot_id=uuid4(), status=SlotState.INACTIVE)
assert slot.stop_loss_price is None
assert slot.take_profit_price is None
@@ -48,12 +49,11 @@ class TestSlotModelPriceFields:
class TestTransitSlotPreservesPriceFields:
def test_price_fields_set_on_pending_buy(self, service, repo, trading_day):
"""AC-2/AC-4: price threshold fields are stored in the slot on IDLE→PENDING_BUY."""
service.preallocate_daily_slots(trading_day, 1)
idle = repo.find_idle_slot(trading_day)
def test_price_fields_set_on_pending_buy(self, service, repo):
"""AC-2/AC-4: price threshold fields are stored on INACTIVE→PENDING_BUY."""
slot = _inactive_slot(repo)
pending = service.transit_slot(
idle.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="test", symbol="005930",
stop_loss_price=Decimal("48000"),
take_profit_price=Decimal("62000"),
@@ -61,12 +61,11 @@ class TestTransitSlotPreservesPriceFields:
assert pending.stop_loss_price == Decimal("48000")
assert pending.take_profit_price == Decimal("62000")
def test_price_fields_preserved_through_holding(self, service, repo, trading_day):
def test_price_fields_preserved_through_holding(self, service, repo):
"""Price fields survive PENDING_BUY→HOLDING fill transition."""
service.preallocate_daily_slots(trading_day, 1)
idle = repo.find_idle_slot(trading_day)
slot = _inactive_slot(repo)
pending = service.transit_slot(
idle.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="test", symbol="005930",
stop_loss_price=Decimal("48000"),
take_profit_price=Decimal("62000"),
@@ -78,29 +77,27 @@ class TestTransitSlotPreservesPriceFields:
assert holding.stop_loss_price == Decimal("48000")
assert holding.take_profit_price == Decimal("62000")
def test_idle_reset_clears_price_fields(self, service, repo, trading_day):
"""PENDING_BUY→IDLE rollback clears stop_loss_price and take_profit_price."""
service.preallocate_daily_slots(trading_day, 1)
idle = repo.find_idle_slot(trading_day)
def test_inactive_reset_clears_price_fields(self, service, repo):
"""PENDING_BUY→INACTIVE rollback clears stop_loss_price and take_profit_price."""
slot = _inactive_slot(repo)
pending = service.transit_slot(
idle.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="test", symbol="005930",
stop_loss_price=Decimal("48000"),
take_profit_price=Decimal("62000"),
)
rolled_back = service.transit_slot(
pending.slot_id, SlotState.PENDING_BUY, SlotState.IDLE,
pending.slot_id, SlotState.PENDING_BUY, SlotState.INACTIVE,
reason="rejected", actor="test",
)
assert rolled_back.stop_loss_price is None
assert rolled_back.take_profit_price is None
def test_no_price_fields_sets_none(self, service, repo, trading_day):
def test_no_price_fields_sets_none(self, service, repo):
"""AC-5: transit without price fields leaves them as None."""
service.preallocate_daily_slots(trading_day, 1)
idle = repo.find_idle_slot(trading_day)
slot = _inactive_slot(repo)
pending = service.transit_slot(
idle.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
slot.slot_id, SlotState.INACTIVE, SlotState.PENDING_BUY,
reason="buy", actor="test", symbol="005930",
)
assert pending.stop_loss_price is None
+106 -151
View File
@@ -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
+26 -29
View File
@@ -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"),
-166
View File
@@ -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
+16 -29
View File
@@ -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
+13 -16
View File
@@ -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
+4 -4
View File
@@ -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",
+5 -5
View File
@@ -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):
+124
View File
@@ -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
+3 -4
View File
@@ -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():
+17 -13
View File
@@ -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:
+54 -121
View File
@@ -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],
)
@@ -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 (
@@ -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;
@@ -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')
);
@@ -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;
+39 -10
View File
@@ -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,11 +102,31 @@ 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"
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):
@@ -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,
+29 -80
View File
@@ -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,
+5 -3
View File
@@ -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]
+21 -21
View File
@@ -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,8 +206,7 @@ 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:
for s in slot_repo.list_holding_slots(instance_key):
watchdog_store.update(str(s.slot_id))
# --- BAT-008 envelope path (snapshot store wired) ---
@@ -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(),
}
+5 -4
View File
@@ -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] = {}
@@ -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
+4 -7
View File
@@ -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)
+5 -12
View File
@@ -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()):
+2 -4
View File
@@ -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",
]
+28 -69
View File
@@ -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
+24 -23
View File
@@ -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
+15 -42
View File
@@ -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."""
...
+44 -85
View File
@@ -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,
)
+13 -35
View File
@@ -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
+4 -2
View File
@@ -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,
+8 -17
View File
@@ -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,
)
+235
View File
@@ -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
<instance> --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())
+12 -30
View File
@@ -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: