feat: add portfolio snapshot read layer

This commit is contained in:
2026-06-06 09:33:00 +09:00
parent 56e977a7ad
commit ca757009f5
51 changed files with 3565 additions and 51 deletions
@@ -0,0 +1,196 @@
---
name: run-trex-core-daemon
description: "Launch the trex-core daemon against the real PostgreSQL + RabbitMQ infrastructure and drive it via the messaging surfaces (snapshot RPC, intent admission RPC, slot events). Use when asked to run trex-core, smoke-test the daemon, confirm a change works end-to-end, or reproduce the BAT-007 transport lifecycle."
---
# Run trex-core (sync + thread daemon over RabbitMQ)
The daemon (BAT-007) has **no HTTP surface**. All strategy-facing traffic goes
over RabbitMQ — snapshot RPC on `trex.snapshot.req`, intent admission RPC on
`trex.intent.req`, slot lifecycle events on the `trex.events` topic exchange.
"Run" means: boot the daemon in the background, drive it with messages, look
at the responses + the events.
## Prerequisites
The daemon needs three live services. Verify before launching:
```bash
.venv/bin/python - <<'PY'
import os, psycopg, pika
from dotenv import load_dotenv
load_dotenv(".env")
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
pika.BlockingConnection(p).close()
print("rabbitmq OK")
PY
```
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)
| 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 |
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.
## Launch
Background, redirect log:
```bash
.venv/bin/trex-core-daemon &> /tmp/trex-core.log &
DAEMON_PID=$!
```
The daemon does not bind any TCP port; "ready" means **four worker threads
running and all RabbitMQ topology declared**. Poll for the topology to
appear:
```bash
for i in {1..15}; do
.venv/bin/python - <<'PY' && break
import pika, sys
p = pika.URLParameters("amqp://admin:admin@localhost:5672/"); p.socket_timeout = 2
c = pika.BlockingConnection(p)
try:
ch = c.channel()
# passive=True: succeeds only if the daemon already declared it
ch.queue_declare("trex.intent.req", durable=True, passive=True)
ch.queue_declare("trex.snapshot.req", durable=True, passive=True)
ch.exchange_declare("trex.events", exchange_type="topic", durable=True, passive=True)
print("daemon ready")
sys.exit(0)
except Exception as exc:
print(f"not ready: {exc}"); sys.exit(1)
finally:
c.close()
PY
sleep 0.5
done
```
`/tmp/trex-core.log` has the pika INFO chatter — first connection lines mean
boot succeeded; absence of `ERROR`/exception traceback means topology declare
worked.
## Drive
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,
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.
Run it:
```bash
.venv/bin/python .claude/skills/run-trex-core-daemon/smoke.py
```
Sample output (paper broker, `strategy_id=STRAT-A`):
```
=== 1) snapshot RPC ===
slots=10 capacity={idle=10, holding=0, pending=0}
=== 2) buy intent ===
admitted, slot_id=b2d60fa3-…
=== 3) duplicate buy ===
identical: True
=== 4) wait for fill ===
slot status=holding qty=10 avg_price=60000.0
=== 5) sell intent ===
admitted; event rk=slot_filled_sell.STRAT-A.005930 status=closed qty=10
DONE
```
### Subscribing to events (timing caveat)
If you subscribe to `trex.events` *after* the publish happens, the message
is gone — topic exchanges drop unroutable publishes. Production strategy
modules have their per-strategy queue bound from boot, so they don't miss
anything; ad-hoc subscribers need to declare + bind their queue **before**
triggering the intent. `smoke.py` does this correctly (declares queue, then
sleeps 0.6s, then runs the RPC).
## Stop
```bash
kill -TERM $DAEMON_PID
# or if you've lost the PID:
pkill -f trex-core-daemon
```
The daemon installs SIGINT/SIGTERM handlers that set the stop event,
join all worker threads, and `close()` every RabbitMQ connection — last
log line should be `Normal shutdown` from pika. Exit code 0.
## Inspecting state
The daemon's source of truth is PostgreSQL `daily_slot`:
```bash
.venv/bin/python - <<'PY'
import os, psycopg
from dotenv import load_dotenv
load_dotenv(".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
""")
for r in cur.fetchall(): print(r)
PY
```
Ledger outbox state (BAT-006):
```bash
.venv/bin/python - <<'PY'
import os, psycopg
from dotenv import load_dotenv
load_dotenv(".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")
for r in cur.fetchall(): print(r)
PY
```
## Troubleshooting
| 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 |
| `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 |
| import-time `ModuleNotFoundError: pika` | dev deps not installed | `.venv/bin/pip install -e ".[dev]"` |
## Related docs
- `docs/interface-contract.md` — full RabbitMQ topology + message payloads
- `docs/runtime-flows.md` — flow diagrams (snapshot RPC, intent RPC, events, degrade branch)
- `docs/batches/bat-007_20260605_strategy-module-rabbitmq-transport/` — the batch that introduced this runtime shape
@@ -0,0 +1,186 @@
#!/usr/bin/env python3
"""End-to-end smoke for trex-core (BAT-007).
Assumes the daemon is already running (`.venv/bin/trex-core-daemon &`).
Drives it through every RabbitMQ surface:
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
Trading_date defaults to UTC today (matches what the daemon preallocates).
"""
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
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']}")
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
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,
)
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
# 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)")
# 4) wait for fill + event -----------------------------------------
print(f"\n=== 4) wait for execution tick + fill event (paper broker) ===")
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
resp = client.call_rpc(
topology.SNAPSHOT_REQ_QUEUE,
{"view": "open", "trading_date": TODAY.isoformat()},
correlation_id=f"snap-{uuid.uuid4().hex[:8]}", timeout=5.0,
)
holdings = [s for s in resp["slots"] if s["status"] == "holding"]
if holdings:
_print_slots(holdings)
break
time.sleep(0.5)
else:
print("\n!! no holding slot appeared in 10s — execution loop stuck?"); return 4
print(f"buy events captured so far: "
f"{[rk for rk, _ in ev_received if rk.startswith('slot_filled_buy.')]}")
# 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")
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())