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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 15:16:15 +09:00
co-authored by Claude Opus 4.8
parent cec34be5f4
commit 7d727824dc
67 changed files with 1584 additions and 1862 deletions
+45 -32
View File
@@ -19,7 +19,7 @@ The daemon needs three live services. Verify before launching:
.venv/bin/python - <<'PY'
import os, psycopg, pika
from dotenv import load_dotenv
load_dotenv(".env")
load_dotenv("config/.env") # BAT-026: .env lives in the config dir
psycopg.connect(os.environ["TREX_POSTGRESQL_DSN"], connect_timeout=3).close()
print("postgres OK")
p = pika.URLParameters(os.environ["TREX_RABBITMQ_URL"]); p.socket_timeout = 3
@@ -32,28 +32,34 @@ If either fails, fix the infra first — the daemon will not boot without
`TREX_POSTGRESQL_DSN` and will boot silently degraded if `TREX_RABBITMQ_URL`
is missing (stub `InMemoryTransport` — strategy modules can't reach it).
### Required env (`.env`, gitignored)
### Required env (`config/.env`, gitignored) + per-instance TOML
| Variable | Required | Notes |
|---|---|---|
| `TREX_POSTGRESQL_DSN` | yes | psycopg DSN |
| `TREX_RABBITMQ_URL` | yes for real use | e.g. `amqp://admin:admin@localhost:5672/` |
| `TREX_BROKER` | no | `paper` (default) or `kis` |
| `TREX_TOTAL_SLOTS` | no | default 10 |
| `TREX_EXECUTION_INTERVAL_SEC` | no | default 1.0 |
| `TREX_LEDGER_INTERVAL_SEC` | no | default 1.0 |
| `TREX_PORTFOLIO_HUB_BASE_URL` | no | if set, real portfolio-hub ledger; else in-memory stub |
| `TREX_PORTFOLIO_HUB_BASE_URL` | no | if set, real portfolio-hub universe+ledger; else stub universe (pf-005930/pf-000660) + in-memory ledger |
The daemon preallocates slots for **UTC today** (`datetime.now(tz=timezone.utc).date()`).
KST users near midnight: check `date -u` before driving the daemon — the
trading_date you query must match what was preallocated.
`--config <name>` (required since BAT-013) resolves `config/<name>.toml`:
`instance_key` (queue names: `trex.intent.req.<key>` etc.; `default` keeps
the literal names) and `max_active_slots` (M cap; unset = no cap).
**BAT-030 (asset-centric)**: there is NO daily preallocation. Permanent asset
slots (one per universe portfolio, status `inactive`) are created by the
snapshot scheduler's universe reconcile right after boot. Slot states are
`inactive / pending_buy / holding / pending_sell / error` — `idle`/`closed`
no longer exist.
## Launch
Background, redirect log:
Background, redirect log (smoke against the test DB: point
TREX_POSTGRESQL_DSN at `trex_core_test` via `--env-file`, never the live DB):
```bash
.venv/bin/trex-core-daemon &> /tmp/trex-core.log &
.venv/bin/trex-core-daemon --config <instance> &> /tmp/trex-core.log &
DAEMON_PID=$!
```
@@ -92,35 +98,40 @@ worked.
The canonical end-to-end smoke is in `smoke.py` — copy/adapt it. It exercises:
1. **snapshot RPC** (view=all) — confirms the daemon answers.
2. **buy intent RPC** with a fresh `correlation_id` — admitted, slot →
`pending_buy`.
3. **duplicate buy** with the *same* `correlation_id` — identical response,
1. **snapshot RPC** (view=all) — daemon answers; `slots[]` holds the permanent
asset slots from the universe reconcile (status `inactive`).
2. **buy intent RPC keyed by `slot_id`** (REQ-061 canonical; `portfolio_id`
dual-accept) — admitted, slot → `pending_buy`. Rejections: `unknown_slot`,
`already_active`, `capacity_full` (M cap).
3. **duplicate buy** with the *same* `intent_id` — identical response,
no double transition (intent_log dedup gate, REQ-023 AC-1).
4. **wait one execution loop tick** — paper broker fills, slot → `holding`,
`slot_filled_buy.{strategy_id}.{symbol}` event published.
5. **sell intent RPC** on the holding slot — slot → `closed`,
`slot_filled_sell.{strategy_id}.{symbol}` event published.
4. **wait one execution loop tick** — paper broker fills, slot → `holding`.
5. **sell intent RPC** on the holding slot — fill returns the slot to
`inactive` (occupancy cleared, asset binding kept — NOT `closed`).
Sell dispatch quantity is the **broker balance** (REQ-062); broker flat →
the sell is skipped with a WARN.
Run it:
Run it (instance key optional, default `default`):
```bash
.venv/bin/python .claude/skills/run-trex-core-daemon/smoke.py
.venv/bin/python .claude/skills/run-trex-core-daemon/smoke.py [instance_key]
```
Sample output (paper broker, `strategy_id=STRAT-A`):
Sample output (paper broker, stub universe):
```
=== 1) snapshot RPC ===
slots=10 capacity={idle=10, holding=0, pending=0}
=== 2) buy intent ===
admitted, slot_id=b2d60fa3-…
=== 3) duplicate buy ===
assets=1 slots=2 available_slots=1
target slot_id=8a160254-… portfolio_id=pf-005930
=== 2) buy by slot_id ===
-> admitted reason=None
=== 3) duplicate buy (same intent_id) ===
identical: True
=== 4) wait for fill ===
slot status=holding qty=10 avg_price=60000.0
slot status=holding qty=10.0 avg_price=1000.0
=== 5) sell intent ===
admitted; event rk=slot_filled_sell.STRAT-A.005930 status=closed qty=10
-> admitted
slot status=inactive qty=None portfolio_id=pf-005930 (asset binding kept)
DONE
```
@@ -153,12 +164,12 @@ The daemon's source of truth is PostgreSQL `daily_slot`:
.venv/bin/python - <<'PY'
import os, psycopg
from dotenv import load_dotenv
load_dotenv(".env")
load_dotenv("config/.env")
with psycopg.connect(os.environ["TREX_POSTGRESQL_DSN"]) as c:
with c.cursor() as cur:
cur.execute("""
SELECT trading_date, status, COUNT(*)
FROM daily_slot GROUP BY trading_date, status ORDER BY trading_date DESC, status
SELECT instance_key, status, COUNT(*)
FROM daily_slot GROUP BY instance_key, status ORDER BY 1, 2
""")
for r in cur.fetchall(): print(r)
PY
@@ -170,7 +181,7 @@ Ledger outbox state (BAT-006):
.venv/bin/python - <<'PY'
import os, psycopg
from dotenv import load_dotenv
load_dotenv(".env")
load_dotenv("config/.env")
with psycopg.connect(os.environ["TREX_POSTGRESQL_DSN"]) as c:
with c.cursor() as cur:
cur.execute("SELECT status, last_outcome, COUNT(*) FROM ledger_outbox GROUP BY 1,2 ORDER BY 1,2")
@@ -182,8 +193,10 @@ PY
| Symptom | Cause | Fix |
|---|---|---|
| `slots returned: 0` from snapshot | querying wrong `trading_date` | use UTC today, see `date -u` |
| intent gets `rejected: no_idle_slot` | no preallocated slots for that day OR all 10 in flight | check `daily_slot` rows; restart daemon to repreallocate next UTC day |
| `slots returned: 0` from snapshot | universe reconcile hasn't run (portfolio-hub down / boot race) | check the scheduler log line `slot reconcile: {'created': N, ...}`; with no hub config the stub universe gives 2 slots |
| intent gets `rejected: unknown_slot` / `unknown_portfolio` | slot_id/portfolio_id not in this instance's slot set | read `slots[]` from a snapshot RPC first and reuse its `slot_id` |
| intent gets `rejected: capacity_full` | `max_active_slots` (M) reached — FCFS, no eviction | sell something first or raise M in the instance TOML |
| sell never dispatches, WARN `sell dispatch skipped` | broker balance 0 for the symbol (REQ-062 divergence) | reconcile the divergence (operator); no mirror fallback by design |
| `RpcTimeoutError` on the client | daemon not up, or consumer thread crashed | check `/tmp/trex-core.log` for exception trace |
| `TransportUnavailableError` | RabbitMQ down | the daemon does NOT buffer locally (REQ-024) — fix the broker, then re-issue |
| fill event missed by ad-hoc subscriber | queue bound *after* publish | bind queue + sleep ≥0.5s before triggering the intent |