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