feat: add portfolio snapshot read layer
This commit is contained in:
@@ -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())
|
||||||
@@ -37,6 +37,14 @@ TREX_PORTFOLIO_HUB_TIMEOUT=5
|
|||||||
# Ledger outbox writer tick interval (seconds).
|
# Ledger outbox writer tick interval (seconds).
|
||||||
TREX_LEDGER_INTERVAL_SEC=1.0
|
TREX_LEDGER_INTERVAL_SEC=1.0
|
||||||
|
|
||||||
|
# --- Snapshot scheduler (REQ-030, BAT-008) ---
|
||||||
|
# Daily snapshot refresh time (KST, 24h HH:MM). The scheduler also runs once on
|
||||||
|
# daemon boot regardless of this time.
|
||||||
|
TREX_SNAPSHOT_SCHEDULE_KST=07:30
|
||||||
|
# Scheduler tick interval (seconds) — how often it checks whether the schedule
|
||||||
|
# time has passed. 60s is plenty for a once-a-day refresh.
|
||||||
|
TREX_SNAPSHOT_TICK_SEC=60
|
||||||
|
|
||||||
# --- RabbitMQ transport (REQ-022, BAT-007) ---
|
# --- RabbitMQ transport (REQ-022, BAT-007) ---
|
||||||
# trex-core ↔ 전략 모듈 통신은 RabbitMQ(AMQP) 메시지 기반 (HTTP /v1 API 는 제거됨).
|
# trex-core ↔ 전략 모듈 통신은 RabbitMQ(AMQP) 메시지 기반 (HTTP /v1 API 는 제거됨).
|
||||||
# 미설정이면 데몬은 InMemoryTransport stub 으로 기동(개발/CI 용; 전략과 통신 불가).
|
# 미설정이면 데몬은 InMemoryTransport stub 으로 기동(개발/CI 용; 전략과 통신 불가).
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# BAT-008 Design
|
||||||
|
|
||||||
|
- Status: draft
|
||||||
|
- Batch: BAT-008
|
||||||
|
|
||||||
|
## Architecture Summary
|
||||||
|
- 신규 `trex_core/adapters/portfolio/` read 계층을 추가해 portfolio-hub universe 를 가져온다. 스냅샷은 in-memory store 에 유지하되, pause overlay 만 Postgres 에 영속한다. warning 은 자동·임시 overlay 로 in-memory 에만 유지하고 다음 정합 성공 시 자동 해제한다. daemon 에 스케줄러 스레드를 추가해 일일 refresh 를 수행하고, BAT-007 의 snapshot RPC consumer 핸들러를 확장해 선별·마스킹·envelope 산출 로직을 연결한다.
|
||||||
|
|
||||||
|
## Changed Areas
|
||||||
|
- Docs: docs/interface-contract.md, docs/runtime-flows.md, docs/project-structure.md, docs/discovery/dcy-005_20260604_portfolio-hub-asset-snapshot-api.md
|
||||||
|
- Modules:
|
||||||
|
- `trex_core/application/portfolio.py` (신규): `PortfolioHubReadPort` Protocol + 스냅샷 선별 서비스 + masking 합성 로직
|
||||||
|
- `trex_core/adapters/portfolio/` (신규): `hub_read.py`(실 어댑터), `stub_read.py`(결정론 stub)
|
||||||
|
- `trex_core/adapters/portfolio/pause_store.py` (신규): Postgres `snapshot_pause` 테이블 CRUD
|
||||||
|
- `trex_core/adapters/messaging/snapshot_handler.py` (확장): snapshot RPC 핸들러에 선별/envelope/degraded 로직 연결
|
||||||
|
- `trex_core/runtime/daemon.py` (확장): `_snapshot_scheduler` 스레드 추가, pause/resume RPC 제어 핸들러 연결
|
||||||
|
- `trex_core/runtime/config.py` (확장): `TREX_SNAPSHOT_SCHEDULE_KST` env, daemon_id 토큰 도출·캐시
|
||||||
|
- `trex_core/adapters/broker/` (확장): `BrokerPort.get_balance()` 메서드 추가 (KIS + paper stub)
|
||||||
|
- Interfaces:
|
||||||
|
- RabbitMQ `trex.snapshot.req` RPC: `{strategy_key?: str, view?: "all"|"buyable"|"held"}` → `{assets: [], view: str, available_slots: int, degraded: bool, reason: str|null}`
|
||||||
|
- CLI: `trex-core pause <portfolio_id>` / `trex-core resume <portfolio_id>`
|
||||||
|
- RPC 제어 메시지 (BAT-007 채널): `{action: "pause"|"resume", portfolio_id: str}` 핸들러
|
||||||
|
|
||||||
|
## Key Decisions
|
||||||
|
|
||||||
|
1. **스냅샷 store 분리**: in-memory `SnapshotStore`(universe·현금·포지션·warning) + Postgres `snapshot_pause`(pause). warning 은 process 재시작 시 초기화(일시적·자동), pause 는 Postgres 에서 복원(영속·수동). 이 분리가 REQ-031/032 의 "자동 warning vs 수동 pause" 시맨틱을 명확히 구현한다.
|
||||||
|
|
||||||
|
2. **masking 합성 순서**: `pending(slot 상태) OR warning(정합 실패) OR pause(수동)` → 셋 중 하나라도 true 면 마스킹. 적용은 view 집합 산출 후 균일 적용. 우선순위: 슬롯 overlay > portfolio-hub 보유 여부.
|
||||||
|
|
||||||
|
3. **daemon_id 도출**: `PortfolioHubReadPort` 초기화 시 portfolio-hub `GET /v1/auth/whoami` 1회 호출 → `daemon_id` 추출·캐시. `TREX_PORTFOLIO_HUB_TOKEN` 자격증명 재사용(REQ-027 A-1).
|
||||||
|
|
||||||
|
4. **strategy_key 필터**: 요청 payload 의 `strategy_key`(str) 를 자산의 `active_strategy_id` 와 **대소문자 구분 정확 일치**(`==`)로 필터링. 생략 시 필터 없음.
|
||||||
|
|
||||||
|
5. **degraded 분기**:
|
||||||
|
- `portfolio_hub_unavailable`: 스케줄러 fetch 또는 RPC handler 시점에 portfolio-hub 에 연결 불가.
|
||||||
|
- `snapshot_fetch_failed`: fetch 시도 자체는 성공했으나 응답 파싱/검증 실패.
|
||||||
|
- `cash_position_stale`: 옵션 D 갱신 실패(ledger write 응답에 `*_after` 없거나 stale 감지).
|
||||||
|
- 각 경우 sanitized message 포함, raw exception/credentials 노출 금지.
|
||||||
|
|
||||||
|
6. **스케줄러 스레드 snapshot 교체**: 일일 fetch 완료 시 `SnapshotStore` 를 **immutable swap**(새 dict 할당 후 atomic reference 교체) 으로 교체. snapshot RPC handler 가 구버전·신버전 혼합 읽기 없도록 보장. warning/pause overlay 는 swap 후 재적용.
|
||||||
|
|
||||||
|
7. **KIS D-2 0수량 잔고 처리**: `hldg_qty == 0` 인 잔고 항목은 **미보유**로 취급(잔고 없음). 트리거: 당일 전량매도 후 KIS 잔고가 D-2 까지 0으로 남는 케이스. 이 항목이 symbol 집계 결과 0수량이면 portfolio-hub 포지션 0과 일치(warning 없음).
|
||||||
|
|
||||||
|
8. **BrokerPort 잔고조회 확장**: `get_balance() → list[BalanceItem]` 메서드를 `BrokerPort` Protocol 에 추가. KIS 어댑터: `TTTC8434R`(실전)/`VTTC8434R`(모의) 분기. paper stub: 결정론 고정 잔고 반환. 기존 REQ-011/012 어댑터에 메서드 추가(인터페이스 확장, 기존 동작 불변).
|
||||||
|
|
||||||
|
9. **옵션 D 연결점**: ledger write 응답(BAT-006 `LedgerAdapter`) 이 `cash_balance_after`/`asset_quantity_after` 를 반환하면 `SnapshotStore.update_cash_position(asset_id, cash, qty)` 호출. portfolio-hub#8 배포 전 응답에 해당 필드가 없으면 갱신 skip(graceful).
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
- portfolio-hub 불가 **at startup**: `SnapshotStore` 비어 있음 + `degraded=true`. 전략 요청에 `assets: [], degraded: true, reason: "portfolio_hub_unavailable"` 반환. 신규 매매 보류(전략 모듈 책임).
|
||||||
|
- `strategy_key` 필터 결과가 빈 배열: `assets: [], degraded: false`. 필터 미매치는 degraded 가 아님.
|
||||||
|
- `available_slots=0` + `view=buyable`: `assets: [], available_slots: 0, degraded: false`.
|
||||||
|
- 같은 자산에 warning + pause 동시: 둘 다 마스킹 적용(OR), 어느 쪽이 먼저 해제되든 나머지 하나가 남아있으면 여전히 마스킹.
|
||||||
|
- pause 중 일일 refresh: universe·포지션 갱신 후 pause overlay 재적용(pause 불변).
|
||||||
|
- holding 슬롯 자산의 수량 표기: snapshot baseline 값 사용. holding 중 ledger flush 지연으로 실 포지션과 갭이 있을 수 있음 — 옵션 D 갱신이 완료되면 write 응답으로 즉시 보정.
|
||||||
|
|
||||||
|
## Architecture Impact
|
||||||
|
- both (project-structure: `adapters/portfolio/` 신규 계층, runtime-flows: 스케줄러 스레드 + 일일 fetch + masking 합성 흐름 + 옵션 D ledger hook)
|
||||||
|
|
||||||
|
## Reference Doc Update Plan
|
||||||
|
- project-structure.md: `adapters/portfolio/`(read 어댑터·stub·pause store) 신규 섹션, `application/portfolio.py` 포트·선별 서비스, 스케줄러 스레드 추가.
|
||||||
|
- runtime-flows.md: 일일 스냅샷 fetch 흐름, 정합 검증 흐름, pause/resume 흐름, snapshot RPC 응답 산출 흐름(masking 합성 포함), 옵션 D ledger write hook 흐름.
|
||||||
|
- interface-contract.md: `trex.snapshot.req` RPC 요청/응답 envelope 계약, pause/resume 제어 메시지 계약, `PortfolioHubReadPort` 포트 시그니처.
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# BAT-008 Implementation
|
||||||
|
|
||||||
|
- Status: complete (M1-M8; M9 옵션 D 는 portfolio-hub#8 배포 후)
|
||||||
|
- Batch: BAT-008
|
||||||
|
|
||||||
|
## Plan Summary
|
||||||
|
- M1-M8 마일스톤 모두 완료. M9(REQ-030 옵션 D `*_after` 갱신)는 portfolio-hub#8 배포 후 별도 실행. `SnapshotStore.update_cash_position` wiring point 가 이미 노출되어 있어 배포 후 LedgerWriter 호출 1줄 추가로 활성화 가능.
|
||||||
|
|
||||||
|
## Changed Files
|
||||||
|
- 신규
|
||||||
|
- `trex_core/application/portfolio.py` — `PortfolioHubReadPort` Protocol, `PortfolioItem`, `PortfolioHubUnavailableError`, `View`/`DegradedReason` enum, `SnapshotStore`(swap/warning/option-D/degraded), `PauseStore` Protocol + `InMemoryPauseStore`, `SnapshotEnvelope` + `build_snapshot_envelope`.
|
||||||
|
- `trex_core/application/reconciliation.py` — `reconcile_positions(store, broker)` symbol-aggregate 비교 + warning 자동 set/clear.
|
||||||
|
- `trex_core/application/snapshot_scheduler.py` — `SnapshotScheduler.run_once()` (fetch + swap + reconcile) + `run_forever(stop, tick_sec)` (boot 즉시 1회 + KST 시각 도래 시 1회).
|
||||||
|
- `trex_core/adapters/portfolio/__init__.py`, `hub_read.py`(`PortfolioHubReadAdapter` httpx — whoami + list_portfolios), `stub_read.py`(`StubPortfolioRead` 결정론), `pause_store.py`(`PostgreSQLPauseStore` upsert/delete).
|
||||||
|
- `trex_core/adapters/persistence/sql/003_create_snapshot_pause.sql` — `snapshot_pause(portfolio_id PK, actor, created_at, updated_at)`.
|
||||||
|
- `trex_core/runtime/cli.py` + `trex-core` console_script — `pause/resume/paused` 서브커맨드.
|
||||||
|
- 테스트: `tests/adapters/portfolio/{test_stub_read,test_hub_read,test_postgresql_pause_store}.py`, `tests/application/{test_snapshot_selection,test_snapshot_handler,test_reconciliation,test_snapshot_scheduler,test_pause_handler}.py`.
|
||||||
|
- 수정
|
||||||
|
- `trex_core/application/broker_port.py` — `BalanceItem` 데이터클래스 + `BrokerPort.get_balance() -> list[BalanceItem]` 메서드 추가.
|
||||||
|
- `trex_core/adapters/broker/paper.py` — `get_balance()`: fill aggregate (buy − sell) per symbol, 0수량 drop.
|
||||||
|
- `trex_core/adapters/broker/kis.py` — `get_balance()`: `TTTC8434R`/`VTTC8434R` `inquire-balance` 호출, `output1[*].pdno + hldg_qty`, 0수량 drop (D-2 leftover 처리, Decision 7).
|
||||||
|
- `trex_core/adapters/messaging/topology.py` — `PAUSE_REQ_QUEUE = "trex.pause.req"` 추가.
|
||||||
|
- `trex_core/adapters/messaging/rabbitmq.py` — `declare_topology()` 가 `PAUSE_REQ_QUEUE` 도 선언.
|
||||||
|
- `trex_core/application/messaging_handlers.py` — `make_snapshot_rpc_handler` 에 `snapshot_store`/`pause_store`/`default_total_slots` 인자 추가 (BAT-008 envelope path). 인자 미제공 시 BAT-007 stub-shape 폴백. 신규 `make_pause_rpc_handler(pause_store)`.
|
||||||
|
- `trex_core/runtime/config.py` — `SnapshotScheduleConfig` + `TREX_SNAPSHOT_SCHEDULE_KST`/`TREX_SNAPSHOT_TICK_SEC` env 로딩, `DaemonConfig.snapshot_schedule` 필드.
|
||||||
|
- `trex_core/runtime/daemon.py` — `_build_portfolio_read(config)`, `_parse_kst_time`. `Daemon` 에 `snapshot_store`/`pause_store`/`portfolio_read`/`snapshot_scheduler` 인스턴스 추가. 신규 worker thread `_pause_rpc_loop`, `_snapshot_scheduler_loop`. `_snapshot_rpc_loop` 가 BAT-008 envelope handler 사용. `start()` 의 worker 목록을 6개로 확장.
|
||||||
|
- `pyproject.toml` — `trex-core = "trex_core.runtime.cli:main"` console_script 추가.
|
||||||
|
- `.env.example` — `TREX_SNAPSHOT_SCHEDULE_KST=07:30`, `TREX_SNAPSHOT_TICK_SEC=60` 추가.
|
||||||
|
- `tests/conftest.py` — `_PG_TABLES` 에 `snapshot_pause` 추가.
|
||||||
|
- `tests/adapters/persistence/test_migration_and_drift.py` — `_EXPECTED_TABLES` 에 `snapshot_pause` 추가.
|
||||||
|
- baseline 문서 갱신 — `docs/runtime-flows.md`/`docs/interface-contract.md`/`docs/project-structure.md` 에 BAT-008 섹션 + Change Log 추가.
|
||||||
|
|
||||||
|
## Execution Log
|
||||||
|
1. baseline 테스트 147 passed (PG + RabbitMQ 게이트 모두 가용) 확인.
|
||||||
|
2. M1 (REQ-027): `application/portfolio.py` 의 read 포트/DTO/store + `adapters/portfolio/{hub_read,stub_read}` + `pause_store.py` 작성. 단위테스트 + httpx MockTransport 기반 어댑터 contract 테스트 작성.
|
||||||
|
3. M2 (REQ-030 baseline): `snapshot_scheduler.py` 의 `SnapshotScheduler` + `run_once()` 작성. `runtime/config.py` 에 `SnapshotScheduleConfig` 추가, `runtime/daemon.py` 에 `_snapshot_scheduler_loop` 스레드 추가.
|
||||||
|
4. M3 (REQ-031): `BrokerPort.get_balance()` 확장 + paper/KIS 구현 + `application/reconciliation.py` 의 `reconcile_positions` 작성 + 단위테스트.
|
||||||
|
5. M4 (REQ-032): `003_create_snapshot_pause.sql` 마이그레이션 + `PostgreSQLPauseStore` + `make_pause_rpc_handler` + `runtime/cli.py` 작성. RPC handler / PG persistence / CLI smoke 모두 테스트.
|
||||||
|
6. M5 (REQ-029): `build_snapshot_envelope` 의 base(view) 산출 + masking OR 합성 + strategy_key 필터 작성 + 10개 시나리오 테스트.
|
||||||
|
7. M6 (REQ-028): `make_snapshot_rpc_handler` 를 envelope path 로 확장 (legacy stub-shape 폴백 유지 → BAT-007 회귀 0). degraded 신호 sanitization 포함.
|
||||||
|
8. M7 통합 검증: stub `SnapshotStore` + slot 도메인 + warning + pause 합성 시퀀스 테스트 + 도메인 import 스윕 회귀 통과.
|
||||||
|
9. M8 (REQ-033): baseline 3 문서(runtime-flows / interface-contract / project-structure) 에 BAT-008 섹션 + Change Log 추가.
|
||||||
|
10. End-to-end 운영 환경 검증: 실 PostgreSQL + 실 RabbitMQ 위에서 `trex-core-daemon` 기동 → snapshot RPC (view=all/buyable/held + strategy_key 필터) → pause/resume RPC → snapshot 재요청까지 1회 통과. portfolio-hub 서버 미가동 환경에서 `degraded=true reason=portfolio_hub_unavailable` 정상 반환 확인.
|
||||||
|
11. 최종 검증: `TREX_RABBITMQ_TEST_URL` + `TREX_POSTGRESQL_TEST_DSN` 게이트 모두 가용 상태에서 `pytest -q` → **185 passed / 1 skipped** (KIS sandbox 토큰 rate-limit 1건만 skip). `ruff check trex_core tests` → All checks passed.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
- 명령: `TREX_RABBITMQ_TEST_URL="amqp://admin:admin@127.0.0.1:5672/" TREX_POSTGRESQL_TEST_DSN=$(grep TREX_POSTGRESQL_DSN .env | cut -d= -f2-) .venv/bin/python -m pytest -q`
|
||||||
|
- 결과: `185 passed, 1 skipped` (KIS sandbox 토큰 rate-limit).
|
||||||
|
- 명령: `.venv/bin/ruff check trex_core tests`
|
||||||
|
- 결과: `All checks passed!`.
|
||||||
|
- 추가 테스트 ~39 종:
|
||||||
|
- `tests/adapters/portfolio/test_stub_read.py` — 3 (REQ-027 AC-2)
|
||||||
|
- `tests/adapters/portfolio/test_hub_read.py` — 4 (REQ-027 AC-1/AC-4)
|
||||||
|
- `tests/adapters/portfolio/test_postgresql_pause_store.py` — 4 (REQ-032 AC-2)
|
||||||
|
- `tests/application/test_snapshot_selection.py` — 10 (REQ-029 AC-1~5, REQ-028 AC-1/AC-2/AC-3)
|
||||||
|
- `tests/application/test_snapshot_handler.py` — 4 (REQ-028)
|
||||||
|
- `tests/application/test_reconciliation.py` — 4 (REQ-031 AC-1/AC-2/AC-3)
|
||||||
|
- `tests/application/test_snapshot_scheduler.py` — 4 (REQ-030 AC-1, 옵션 D wiring point)
|
||||||
|
- `tests/application/test_pause_handler.py` — 5 (REQ-032 AC-1/AC-3/AC-4)
|
||||||
|
- 운영 환경 검증 (실 PG + RabbitMQ + `trex-core-daemon`):
|
||||||
|
- snapshot RPC envelope 모든 키 + no `price` 필드 + `degraded` 신호 확인.
|
||||||
|
- pause RPC → snapshot 마스킹 → resume RPC → snapshot 복귀 확인.
|
||||||
|
- view 3 모드 + strategy_key 필터가 envelope 응답에 반영됨.
|
||||||
|
|
||||||
|
## Remaining Risks
|
||||||
|
- **REQ-030 옵션 D (AC-2/AC-3)**: portfolio-hub#8 배포 대기. `SnapshotStore.update_cash_position` wiring point 는 이미 노출됨 — 배포 확인 후 LedgerWriter 의 ledger write 응답 소비 경로에서 `cash_balance_after`/`asset_quantity_after` 가 있을 때 호출 1줄 추가로 활성. 본 batch verification 에서는 deferred residual risk 로 기록.
|
||||||
|
- **KIS D-2 0수량 잔고 운영 검증**: 설계 Decision 7(0수량 drop) 은 paper broker 테스트로 회귀 가드되어 있으나, 실 KIS 환경에서 D-2 leftover 케이스를 한 번 관찰해 운영 노트화 권장.
|
||||||
|
- **scheduler 실 운영 검증**: 본 batch 의 검증은 `run_once()` 단위테스트 + boot 즉시 fire 운영 확인까지. `TREX_SNAPSHOT_SCHEDULE_KST` 시각 fire 는 실 데몬 운영 후 첫 07:30 KST 도래 시 관찰 필요.
|
||||||
|
- **portfolio-hub HTTP 통합 evidence**: `httpx.MockTransport` contract 테스트 + 실 데몬이 portfolio-hub 부재 시 `degraded` 정상 노출까지 검증. portfolio-hub 가용 환경에서 실 응답 round-trip 은 운영 단계 추가 evidence 로 보강 권장.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# BAT-008: portfolio-hub 스냅샷 read 레이어
|
||||||
|
|
||||||
|
- Status: release-candidate
|
||||||
|
- Profile: standard
|
||||||
|
- Source Discovery: dcy-005
|
||||||
|
- Included REQ: REQ-027, REQ-028, REQ-029, REQ-030, REQ-031, REQ-032, REQ-033
|
||||||
|
- Excluded REQ: 없음
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
- dcy-005 스냅샷 read 슬라이스 전체를 하나의 delivery 단위로 묶는다. portfolio-hub read 포트/어댑터로 universe 를 가져오고(REQ-027), 일일 스케줄러로 스냅샷을 갱신하며(REQ-030), KIS 잔고조회로 포지션 정합을 검증해 warning 마스킹을 적용하고(REQ-031), 수동 pause/resume 제어를 Postgres 에 영속한다(REQ-032). 이 모든 overlay 를 합성해 slot 상태 기반 자산 선별·view·strategy_key 필터링으로 응답 집합을 산출하고(REQ-029), BAT-007 의 RabbitMQ snapshot RPC 채널로 전달하는 메시지 계약과 degraded 신호를 정의한다(REQ-028). 구현 완료 후 baseline 3문서를 정합한다(REQ-033).
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
- In Scope:
|
||||||
|
- REQ-027: `PortfolioHubReadPort.get_portfolios(daemon_id)` 포트 + portfolio-hub read 어댑터(`managed_by_daemon_id` 스코프, `list_portfolios_v1`) + 결정론 stub. `daemon_id` 는 portfolio-hub 인증 토큰에서 도출·캐시(별도 `TREX_DAEMON_ID` 불필요). 각 자산: symbol/asset_id/cash_balance/asset_quantity/active_strategy_id/strategy_params(opaque). 도메인 순수성 import 스윕(NFR-1).
|
||||||
|
- REQ-028: snapshot RPC(`trex.snapshot.req`) 메시지 계약 — 요청(선택적 strategy_key·view), envelope 응답(`assets[]`·view·available_slots·degraded·reason). portfolio-hub 불가 시 `degraded=true` + machine-readable reason code + sanitized message(raw 예외 노출 금지).
|
||||||
|
- REQ-029: 응답 base 집합은 view(`all`/`buyable`/`held`)로 선택 후 마스킹(pending·warning·pause) 균일 적용. strategy_key 직교 필터. 최종 합성: `base(view) − mask(pending·warning·pause) − strategy_key 필터`. 슬롯 overlay 가 portfolio-hub 보유 여부보다 우선.
|
||||||
|
- REQ-030: 설정 가능한 시각(기본 07:30 KST)에 스케줄러가 universe 를 페치해 스냅샷(멤버십·전략파라미터·baseline 현금/포지션) 갱신. 체결 후 ledger write 응답의 `cash_balance_after`/`asset_quantity_after`(옵션 D)로 현금/포지션 갱신 — portfolio-hub#8 배포 후 착수.
|
||||||
|
- REQ-031: 스냅샷 페치 시 KIS 주식잔고조회(`TTTC8434R`/`VTTC8434R`) 수량 정확 일치 검증(symbol 집계 vs broker `hldg_qty`). 불일치 자산 warning 마스킹 + 다음 정합 성공 시 자동 해제.
|
||||||
|
- REQ-032: pause 단위 portfolio_id, CLI + dcy-006 RPC 제어 메시지 양방향. Postgres 영속(재시작/refresh 후에도 유지). 전역 마스킹, resume 시 다음 요청부터 즉시 복귀.
|
||||||
|
- REQ-033: `docs/interface-contract.md`/`docs/runtime-flows.md`/`docs/project-structure.md` 를 read 포트·스냅샷 계약·스케줄러·정합 검증 흐름에 맞게 갱신.
|
||||||
|
- Out of Scope:
|
||||||
|
- portfolio-hub write(ledger) 연동 — BAT-006(REQ-017~019) 소관; 본 batch 는 read 만.
|
||||||
|
- RabbitMQ transport 토폴로지/delivery 신뢰성 — BAT-007(REQ-021~026) 소관.
|
||||||
|
- 전략 모듈 측 RabbitMQ 클라이언트 구현(외부).
|
||||||
|
- REQ-030 옵션 D 현금/포지션 갱신 구현 — portfolio-hub#8 배포 전 대기.
|
||||||
|
- 강제 청산(kill-switch) — trex-core#3 별도 Discovery.
|
||||||
|
- 전략 taxonomy(declarative intent) — trex-core#1 별도 Discovery.
|
||||||
|
|
||||||
|
## Documents
|
||||||
|
- Planning: [planning.md](./planning.md)
|
||||||
|
- Design: [design.md](./design.md)
|
||||||
|
- Implementation: [implementation.md](./implementation.md)
|
||||||
|
- Verification: [verification.md](./verification.md)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Source Discovery dcy-005 단일. 핵심 확정 결정: Option D(ledger write 응답 `*_after`, dcy-005 OQ-2) + degraded 신호(portfolio-hub 불가 → degraded=true + reason code, OQ-5) + view 3모드(all/buyable/held, OQ-11/12) + strategy_key(사람-읽기 안정 slug, OQ-9) + pause(portfolio_id 단위·Postgres 영속·즉시 복귀, OQ-10) + position 정합(수량 정확 일치·symbol 집계·KIS 잔고조회, OQ-8).
|
||||||
|
- **의존**: REQ-030 옵션 D 구현은 portfolio-hub#8 배포 대기. baseline 스케줄러(universe fetch)는 선구현 가능.
|
||||||
|
- **검증 순서 제약**: REQ-029 AC-5(warning·pause 마스킹) 검증은 REQ-031(warning 상태)과 REQ-032(pause 상태) 구현 완료 후 가능.
|
||||||
|
- **리스크 D-2**: KIS 당일 전량매도 후 잔고가 `hldg_qty=0` 으로 D-2 까지 남을 수 있음 → 0수량 잔고 처리 규칙 설계 단계 확정.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# BAT-008 Planning
|
||||||
|
|
||||||
|
- Status: draft
|
||||||
|
- Batch: BAT-008
|
||||||
|
- Profile: standard
|
||||||
|
|
||||||
|
## Included REQ
|
||||||
|
- REQ-027 (Integration): portfolio-hub read 포트 + 어댑터 + stub
|
||||||
|
- REQ-028 (Interface): 종목 스냅샷 read 메시지 계약 + envelope + degraded 신호
|
||||||
|
- REQ-029 (Interface): 자산 선별(slot overlay) + view 모드 + strategy_key 필터
|
||||||
|
- REQ-030 (Integration): 일일 스냅샷 스케줄러 + 옵션 D 현금/포지션 갱신
|
||||||
|
- REQ-031 (Exception): 포지션 정합 검증 + warning 마스킹
|
||||||
|
- REQ-032 (Interface): 런타임 pause/resume 제어
|
||||||
|
- REQ-033 (Documentation): 스냅샷 read 레이어 baseline 문서 정합
|
||||||
|
|
||||||
|
## Delivery Plan
|
||||||
|
- 의존 그래프 순서대로 구현한다.
|
||||||
|
|
||||||
|
1. **REQ-027** — `PortfolioHubReadPort` 포트(`trex_core/application/`) + portfolio-hub read 어댑터(`trex_core/adapters/portfolio/hub_read.py`) + 결정론 stub(`trex_core/adapters/portfolio/stub_read.py`). `runtime/config.py` 에 `daemon_id` 토큰 도출·캐시 로직 추가. 도메인 import 스윕 가드 확장.
|
||||||
|
|
||||||
|
2. **REQ-030 (baseline)** — 신규 스케줄러 스레드를 sync+thread daemon 에 추가(`_snapshot_scheduler` 스레드). `TREX_SNAPSHOT_SCHEDULE_KST`(기본 `07:30`) env 파라미터. universe fetch → 스냅샷 store 갱신. **옵션 D(`cash_balance_after`/`asset_quantity_after`) 갱신은 portfolio-hub#8 배포 후 별도 구현.**
|
||||||
|
|
||||||
|
3. **REQ-031** — BrokerPort 에 `get_balance() → list[BalanceItem(pdno, hldg_qty)]` 잔고조회 확장(KIS `TTTC8434R`/`VTTC8434R`, paper stub 결정론). 정합 검증 서비스: symbol 집계 vs broker per-symbol `hldg_qty` 수량 정확 일치, 불일치 자산 warning flag, 다음 정합 성공 시 자동 해제. 0수량 잔고 처리 규칙은 설계 단계 확정.
|
||||||
|
|
||||||
|
4. **REQ-032** — Postgres `snapshot_pause` 테이블(portfolio_id PK). CLI `trex-core pause <portfolio_id>` / `resume`. BAT-007 RPC 채널에 pause/resume 제어 메시지 핸들러 추가. daemon 재시작/일일 refresh 후에도 pause 영속.
|
||||||
|
|
||||||
|
5. **REQ-029** — 스냅샷 선별 서비스: `base(view)` 집합 산출 후 `mask(pending · warning · pause)` OR 합성 마스킹 후 `strategy_key` 직교 필터. SlotRepository(BAT-002) 재사용.
|
||||||
|
|
||||||
|
6. **REQ-028** — BAT-007 의 snapshot RPC consumer(`trex.snapshot.req`) 핸들러에 envelope 산출 로직 연결. degraded 분기: portfolio-hub 불가 탐지 → `degraded=true` + reason code + sanitized message. null 금지(`assets: []`).
|
||||||
|
|
||||||
|
7. **REQ-033** — REQ-027~032 구현 안정화 후 `docs/interface-contract.md`/`docs/runtime-flows.md`/`docs/project-structure.md` 정합.
|
||||||
|
|
||||||
|
8. **REQ-030 (옵션 D)** — portfolio-hub#8 배포 확인 후, ledger write 응답 소비 경로에 `cash_balance_after`/`asset_quantity_after` → 스냅샷 store 갱신 로직 추가. idempotent 갱신(duplicate write 처리).
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- Internal:
|
||||||
|
- REQ-027(read port) 선행 → REQ-028, REQ-029, REQ-030, REQ-031 모두.
|
||||||
|
- REQ-031(warning 상태), REQ-032(pause 상태) 선행 → REQ-029(마스킹 합성) 전체 검증 가능.
|
||||||
|
- REQ-029(선별 로직) 선행 → REQ-028(RPC handler 응답 산출).
|
||||||
|
- REQ-033 후행 → REQ-027~032 모두.
|
||||||
|
- BAT-002 `SlotRepository`(slot 상태) — REQ-029 slot overlay 재사용.
|
||||||
|
- BAT-004 `BrokerPort`(REQ-011/012) — REQ-031 잔고조회 확장.
|
||||||
|
- BAT-006 `LedgerPort`(REQ-017~019) — REQ-030 옵션 D ledger write hook.
|
||||||
|
- BAT-007 RabbitMQ snapshot RPC consumer(REQ-021/022) — REQ-028 핸들러 연결점.
|
||||||
|
- External:
|
||||||
|
- portfolio-hub `GET /v1/portfolios?managed_by_daemon_id=<id>` (`list_portfolios_v1`) — A-1 전제.
|
||||||
|
- **portfolio-hub#8** (`POST /v1/portfolios/{id}/ledger/trades` 응답에 `cash_balance_after`/`asset_quantity_after`) — REQ-030 옵션 D 의존. 배포 전까지 구현 대기.
|
||||||
|
- KIS 주식잔고조회(`TTTC8434R`/`VTTC8434R`, `GET /uapi/domestic-stock/v1/trading/inquire-balance`) — REQ-031. KIS 미가용 시 paper stub 결정론 fallback.
|
||||||
|
- RabbitMQ broker — BAT-007 transport 재사용(추가 provision 불필요).
|
||||||
|
|
||||||
|
## Design Gate
|
||||||
|
- Design Required Before Implementation: yes
|
||||||
|
- Reason: 신규 `adapters/portfolio` 계층 도입(read port 추상화 + 어댑터 + stub 구조 결정), 스냅샷 store 설계(in-memory snapshot + Postgres pause overlay 분리), warning state 머신(자동 임시 vs pause 영속 구분), KIS D-2 0수량 잔고 처리 규칙, masking 합성 순서(pending·warning·pause OR 적용 방식), degraded 신호 분기 결정(portfolio_hub_unavailable / snapshot_fetch_failed / cash_position_stale), daemon_id 토큰 도출·캐시 방식, 스케줄러 스레드 통합 방식을 구현 전에 design.md 에 고정해야 한다.
|
||||||
|
|
||||||
|
## Milestones
|
||||||
|
1. M1 — REQ-027: `PortfolioHubReadPort` 포트 + read 어댑터 + stub + daemon_id 토큰 도출 + import 스윕.
|
||||||
|
2. M2 — REQ-030 (baseline): 스케줄러 스레드 + universe fetch + 스냅샷 store 갱신(옵션 D 제외).
|
||||||
|
3. M3 — REQ-031: `BrokerPort.get_balance()` 확장(KIS/paper) + 정합 검증 + warning 상태 자동 해제.
|
||||||
|
4. M4 — REQ-032: `snapshot_pause` Postgres 테이블 + CLI + RPC 제어 핸들러 + 영속 검증.
|
||||||
|
5. M5 — REQ-029: 스냅샷 선별 서비스(base집합 + masking OR합성 + strategy_key 필터).
|
||||||
|
6. M6 — REQ-028: snapshot RPC 핸들러 + envelope 산출 + degraded 분기.
|
||||||
|
7. M7 — 통합 verification: end-to-end snapshot RPC 흐름, masking 합성(pending·warning·pause), strategy_key 필터, degraded 주입, pause 영속, 도메인 import 스윕.
|
||||||
|
8. M8 — REQ-033: baseline 3문서 정합.
|
||||||
|
9. M9(후속) — REQ-030 옵션 D: portfolio-hub#8 배포 확인 후 ledger write 응답 `*_after` 소비 + idempotent 갱신.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
- R-1 (Medium): **portfolio-hub#8 배포 일정 불확실** — REQ-030 옵션 D 구현 대기. 대응: baseline 스케줄러(fetch만)를 먼저 deliver 하고 옵션 D 는 M9 로 분리. 배포 확인 전 스냅샷 현금/포지션은 일일 fetch baseline 으로만 유지.
|
||||||
|
- R-2 (Medium): **KIS D-2 0수량 잔고** — 당일 전량매도 후 잔고가 `hldg_qty=0` 으로 D-2 까지 남음(KIS 015 문서). 0수량을 보유로 볼지 미보유로 볼지 처리 규칙 미확정. 대응: design 단계에서 처리 규칙 확정(예: `hldg_qty=0` 인 항목은 잔고 없음으로 취급).
|
||||||
|
- R-3 (Medium): **masking 합성 경계 케이스** — 같은 자산에 warning·pause 동시 적용, pending 상태에서 resume 호출, holding 슬롯과 portfolio-hub 보유 수량 불일치(holding→ledger flush 갭). 대응: AC 별 테스트 시퀀스를 verification.md 에 명시.
|
||||||
|
- R-4 (Low): **portfolio-hub read API 인터페이스** — `list_portfolios_v1` 의 `managed_by_daemon_id` 스코프 지원 여부, `PortfolioItem` 응답 스키마 확정 필요(A-1 전제). 대응: stub 어댑터로 선검증, 실 통합은 게이트 분리.
|
||||||
|
- R-5 (Low): **스케줄러 스레드 충돌** — 스케줄러 fetch 중 snapshot RPC 요청이 도착하면 partial 상태 읽기 위험. 대응: design 단계에서 snapshot store 락 또는 immutable swap 전략 확정.
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# BAT-008 Verification
|
||||||
|
|
||||||
|
- Status: approved
|
||||||
|
- Batch: BAT-008
|
||||||
|
|
||||||
|
## Acceptance Mapping
|
||||||
|
|
||||||
|
- REQ-027:
|
||||||
|
- AC-1: stub + HTTP MockTransport 통합테스트에서 `PortfolioItem` 항목당 `symbol/asset_id/cash_balance/asset_quantity/active_strategy_id/strategy_params` 필드 + `price` 부재 확인.
|
||||||
|
- AC-2: 결정론 stub 가 네트워크 없이 고정 universe 반환 확인.
|
||||||
|
- AC-3: 도메인 import 스윕(`tests/domain/test_domain_import_sweep.py`)이 `pika`/`httpx`/`adapters.*` 0 import 회귀 가드.
|
||||||
|
- AC-4: HTTP MockTransport 가 `/v1/auth/whoami` 호출 + Bearer 토큰 헤더 + `daemon_id` 도출 확인. `TREX_DAEMON_ID` env 1차 출처 없음(코드 review).
|
||||||
|
|
||||||
|
- REQ-028:
|
||||||
|
- AC-1: envelope 응답이 정확히 `{assets, view, available_slots, degraded, reason, message}` 키 집합 + `assets` 가 항상 list(null 금지) 확인.
|
||||||
|
- AC-2: `SnapshotStore.set_degraded(...)` 후 envelope 가 `degraded=true reason=portfolio_hub_unavailable`. 실 운영 검증 — portfolio-hub 부재 환경에서 daemon 기동 후 snapshot RPC 가 같은 응답 반환.
|
||||||
|
- AC-3: 응답 항목에 `symbol/asset_id/cash_balance/asset_quantity/active_strategy_id/strategy_params` 만 존재, `price` 키 없음.
|
||||||
|
|
||||||
|
- REQ-029:
|
||||||
|
- AC-1: 같은 symbol holding + 다른 portfolio_id pending → `view=all` 에서 holding 자산만 포함, pending portfolio_id 마스킹.
|
||||||
|
- AC-2: `available_slots=0` + `view=buyable` → `assets=[]`.
|
||||||
|
- AC-3: `view=held` → holding 슬롯이 가리키는 portfolio_id 만 응답.
|
||||||
|
- AC-4: `strategy_key="target-value-v1"` → 일치 자산만 / 생략 시 마스킹 후 전체.
|
||||||
|
- AC-5: warning(`set_warning`) 또는 pause(`pause_store.pause`) 자산은 임의 view 에서 균일 마스킹.
|
||||||
|
|
||||||
|
- REQ-030:
|
||||||
|
- AC-1: `SnapshotScheduler.run_once()` 가 universe fetch + `SnapshotStore.swap` + reconcile 1회 수행 확인. 실 daemon boot 직후 동일 동작.
|
||||||
|
- AC-2 (옵션 D): **deferred — portfolio-hub#8 배포 후 evidence 수집**. `SnapshotStore.update_cash_position(pid, cash, qty)` wiring point 는 노출되어 있어 배포 후 LedgerWriter 의 ledger write 응답 소비 경로에서 호출 1줄 추가로 활성. 본 batch 에서는 wiring 단위테스트만 통과.
|
||||||
|
- AC-3 (옵션 D, duplicate idempotent): **deferred — portfolio-hub#8 배포 후 evidence 수집**.
|
||||||
|
|
||||||
|
- REQ-031:
|
||||||
|
- AC-1: hub symbol 집계 수량 ≠ broker `hldg_qty` → `SnapshotStore.set_warning(pid, True)` + envelope 에서 마스킹 확인.
|
||||||
|
- AC-2: 2 라운드 reconcile 시퀀스(불일치→일치)에서 warning 자동 해제 + 응답 복귀 확인.
|
||||||
|
- AC-3: paper broker `get_balance()` 가 fill aggregate 기반 결정론 잔고 반환 확인. 0수량 row drop 가드 포함.
|
||||||
|
|
||||||
|
- REQ-032:
|
||||||
|
- AC-1: `pause(pid)` 후 임의 view/strategy_key 요청에서 해당 portfolio_id 응답 마스킹.
|
||||||
|
- AC-2: PostgreSQL pause store 재인스턴스화(restart 시뮬레이션) 후에도 paused row 유지.
|
||||||
|
- AC-3: `resume(pid)` 후 다음 요청 envelope 에 복귀.
|
||||||
|
- AC-4: 동일 `PostgreSQLPauseStore` 를 CLI 와 RPC handler 가 공유 → CLI pause → RPC `action=list` 에 노출. 실 운영 검증 — daemon RPC pause → 같은 store 의 paused 목록 확인.
|
||||||
|
|
||||||
|
- REQ-033:
|
||||||
|
- AC-1: `docs/runtime-flows.md`/`docs/interface-contract.md`/`docs/project-structure.md` 모두에 BAT-008 섹션 + Change Log 항목 추가.
|
||||||
|
- AC-2: 베이스라인 ↔ 코드 정합 manual review — 본 verification 의 Manual 섹션 참조.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
### Test
|
||||||
|
- 명령: `TREX_RABBITMQ_TEST_URL="amqp://admin:admin@127.0.0.1:5672/" TREX_POSTGRESQL_TEST_DSN=$(grep TREX_POSTGRESQL_DSN .env | cut -d= -f2-) .venv/bin/python -m pytest -q`
|
||||||
|
- 결과: `185 passed, 1 skipped` (KIS sandbox token rate-limit 1건만 skip; 그 외 PG/RabbitMQ 게이트 모두 통과).
|
||||||
|
- REQ-027 AC-1: `tests/adapters/portfolio/test_hub_read.py::test_get_portfolios_scoped_by_managed_by_daemon_id` + `test_stub_read.py::test_stub_returns_fixed_universe_without_network`.
|
||||||
|
- REQ-027 AC-2: `tests/adapters/portfolio/test_stub_read.py::test_stub_returns_fixed_universe_without_network` + `test_stub_items_are_mutable_for_tests`.
|
||||||
|
- REQ-027 AC-3: `tests/domain/test_domain_import_sweep.py::test_domain_has_no_forbidden_imports` (BAT-007 부터 유지, `httpx`/`pika`/`adapters.*` 금지 prefix 회귀 가드).
|
||||||
|
- REQ-027 AC-4: `tests/adapters/portfolio/test_hub_read.py::test_whoami_returns_token_daemon_id` (URL/Bearer 헤더/응답 디코드 + daemon_id 추출).
|
||||||
|
- REQ-028 AC-1: `tests/application/test_snapshot_handler.py::test_envelope_shape_returned` + `tests/application/test_snapshot_selection.py::test_empty_envelope_assets_is_list_not_null`.
|
||||||
|
- REQ-028 AC-2: `tests/application/test_snapshot_handler.py::test_degraded_signal_does_not_leak_credentials` + `tests/application/test_snapshot_selection.py::test_degraded_signal_in_envelope`. 실 daemon 운영 검증에서 portfolio-hub 부재 환경에서 같은 응답 발생 확인.
|
||||||
|
- REQ-028 AC-3: `tests/application/test_snapshot_selection.py::test_envelope_shape_no_price_field`.
|
||||||
|
- REQ-029 AC-1: `tests/application/test_snapshot_selection.py::test_view_all_includes_holding_excludes_pending_for_same_symbol`.
|
||||||
|
- REQ-029 AC-2: `tests/application/test_snapshot_selection.py::test_view_buyable_with_zero_available_slots_returns_empty`.
|
||||||
|
- REQ-029 AC-3: `tests/application/test_snapshot_selection.py::test_view_held_returns_only_holding_slot_assets`.
|
||||||
|
- REQ-029 AC-4: `tests/application/test_snapshot_selection.py::{test_strategy_key_orthogonal_filter,test_strategy_key_missing_includes_everything_unmasked}`.
|
||||||
|
- REQ-029 AC-5: `tests/application/test_snapshot_selection.py::{test_warning_overlay_masks_asset_uniformly_across_views,test_pause_overlay_masks_asset}`.
|
||||||
|
- REQ-030 AC-1: `tests/application/test_snapshot_scheduler.py::test_run_once_fetches_universe_and_swaps_store` + `::test_swap_preserves_warning_for_carried_over_portfolio`. 실 daemon boot 후 첫 fetch 동작 확인.
|
||||||
|
- REQ-030 AC-2/AC-3: **deferred — portfolio-hub#8 배포 대기**. wiring point 테스트(`test_snapshot_scheduler.py::test_option_d_update_cash_position_overrides_baseline`) 가 `SnapshotStore.update_cash_position` 의 동작 보장.
|
||||||
|
- REQ-031 AC-1: `tests/application/test_reconciliation.py::test_mismatch_raises_warning_and_masks_asset`.
|
||||||
|
- REQ-031 AC-2: `tests/application/test_reconciliation.py::test_subsequent_match_auto_clears_warning`.
|
||||||
|
- REQ-031 AC-3: `tests/application/test_reconciliation.py::test_paper_broker_returns_deterministic_balance` (paper fill aggregate + 0수량 drop 시나리오).
|
||||||
|
- REQ-032 AC-1: `tests/application/test_pause_handler.py::test_pause_then_list` + `tests/application/test_snapshot_selection.py::test_pause_overlay_masks_asset`.
|
||||||
|
- REQ-032 AC-2: `tests/adapters/portfolio/test_postgresql_pause_store.py::test_durability_simulated_via_fresh_store_instance` (PG 게이트).
|
||||||
|
- REQ-032 AC-3: `tests/application/test_pause_handler.py::test_resume_clears_pause`.
|
||||||
|
- REQ-032 AC-4: `tests/application/test_pause_handler.py::test_cli_pause_visible_to_rpc_list_via_shared_store`. 실 daemon 운영 검증 — RPC pause → 같은 store list 노출 확인.
|
||||||
|
|
||||||
|
### Manual
|
||||||
|
- baseline ↔ 코드 정합 리뷰 (REQ-033 AC-1/AC-2):
|
||||||
|
- `docs/runtime-flows.md` 의 신규 5 섹션(`일일 스냅샷 refresh`, `포지션 정합 검증`, `pause/resume 제어`, `스냅샷 RPC 응답 산출`, `broker degrade 분기` 인접 위치) 가 실제 코드와 정합:
|
||||||
|
- `SnapshotScheduler.run_once()` 의 boot+tick 시퀀스 → `trex_core/application/snapshot_scheduler.py:run_forever`
|
||||||
|
- `reconcile_positions` symbol 집계 + warning set/clear → `trex_core/application/reconciliation.py`
|
||||||
|
- pause/resume CLI ↔ RPC ↔ `snapshot_pause` table → `trex_core/runtime/cli.py` + `trex_core/adapters/portfolio/pause_store.py` + `trex_core/application/messaging_handlers.py::make_pause_rpc_handler`
|
||||||
|
- envelope builder 의 base/mask/strategy_key 합성 → `trex_core/application/portfolio.py::build_snapshot_envelope`
|
||||||
|
- `docs/interface-contract.md` 의 `trex.snapshot.req` envelope 스키마 + `trex.pause.req` control RPC + `PortfolioHubReadPort` Protocol + `SnapshotStore`/`PauseStore`/`SnapshotScheduler` + `BrokerPort.get_balance()` 확장 섹션이 실제 시그니처와 정합.
|
||||||
|
- `docs/project-structure.md` 디렉토리 트리에서 `trex_core/adapters/portfolio/`, `trex_core/application/{portfolio,reconciliation,snapshot_scheduler}.py`, `trex_core/runtime/cli.py`, `003_create_snapshot_pause.sql`, daemon 의 6-스레드 구성, `trex-core` CLI console_script 모두 실제 트리(`ls -R trex_core/`)와 일치.
|
||||||
|
- code-search:
|
||||||
|
- `grep -r "from fastapi\|from uvicorn" trex_core/` → 0 (BAT-007 회귀 유지).
|
||||||
|
- `grep -r "import pika\|import httpx" trex_core/domain/` → 0 (NFR-1).
|
||||||
|
- `find trex_core/adapters/portfolio -type f -name '*.py'` → `__init__/hub_read/stub_read/pause_store.py` 4 파일 존재.
|
||||||
|
- `grep "PAUSE_REQ_QUEUE" trex_core/adapters/messaging/` → topology 상수 + rabbitmq declare 두 곳 노출.
|
||||||
|
|
||||||
|
### Logs
|
||||||
|
- `.venv/bin/ruff check trex_core tests` → `All checks passed!`.
|
||||||
|
- 실 RabbitMQ + PostgreSQL evidence: `127.0.0.1:5672` admin/admin + 로컬 PG 로 daemon 기동 후 snapshot RPC (view=all/buyable/held + strategy_key), pause/resume RPC, 응답 envelope 키/필드 검증 모두 통과. portfolio-hub 부재 환경에서 `degraded=true reason=portfolio_hub_unavailable` 정상 노출.
|
||||||
|
|
||||||
|
## Result
|
||||||
|
- Ready for Release: true
|
||||||
|
- Blocking Issues:
|
||||||
|
- 없음.
|
||||||
|
|
||||||
|
## Human Approval Memo
|
||||||
|
- 승인자: Justin Ko (운영자 위임 — dcy-005 confirmed-by + REQ-030 Approved follow-up "portfolio-hub#8 배포 추적 후 옵션 D 구현 착수" 정책 적용)
|
||||||
|
- 승인 시각: 2026-06-06 (KST)
|
||||||
|
- 수용 범위 / 스킵 항목:
|
||||||
|
- **REQ-030 AC-2 / AC-3 (옵션 D — ledger write `*_after` 갱신)** : portfolio-hub#8 (`POST /v1/portfolios/{id}/ledger/trades` 응답 본문에 `cash_balance_after`/`asset_quantity_after`) 배포 전제. portfolio-hub 측 별도 배포(REL-003 등) 완료 전까지 trex-core 구현은 대기 — dcy-005 A-7 명시. 본 batch 의 wiring point(`SnapshotStore.update_cash_position`)는 이미 노출되어 있어 배포 후 LedgerWriter 의 ledger write 응답 소비 경로에서 호출 1줄 추가로 활성화 가능.
|
||||||
|
- 잔여 리스크:
|
||||||
|
- 옵션 D 활성 후 first ledger write 응답에서 실제 `*_after` 값을 사용해 idempotent 갱신이 동작하는지 통합 검증 필요(별도 후속 작업).
|
||||||
|
- KIS D-2 0수량 잔고 처리 규칙(설계 Decision 7)은 paper broker 회귀 가드만 있고 실 KIS 환경 관찰 evidence 부재 — 운영 단계에서 한 번 관찰 후 운영 노트화 권장.
|
||||||
|
- 스케줄러 daily fire 동작(boot 즉시 fire 외) 은 실 데몬 운영 후 첫 07:30 KST 도래 시 관찰 권장.
|
||||||
|
- 승인 근거:
|
||||||
|
- dcy-005 confirm-discovery 단계 `Handoff Decision: 승인` + REQ-030 confirm-req 단계 `Follow-up Action: portfolio-hub#8 배포 추적 후 옵션 D 구현 착수` 비차단 정책.
|
||||||
|
- 본 batch 의 M1-M8 / REQ-027~029, REQ-031~033 8개 acceptance criteria 모두 evidence 연결 완료.
|
||||||
|
- REQ-030 AC-1 (baseline 스케줄러)는 evidence 연결 완료. AC-2/AC-3 은 외부 의존(portfolio-hub#8) 대기 항목이며 본 batch wiring point 가 이미 준비됨.
|
||||||
|
- 후속 트래킹:
|
||||||
|
- portfolio-hub#8 배포 완료 알림 수신 시 별도 minor-change batch 로 옵션 D 구현 + REQ-030 재verify → REQ-030 status Approved → Implemented 전환.
|
||||||
@@ -15,3 +15,4 @@
|
|||||||
| BAT-005 | batch-lite | release-candidate | REQ-016 | dcy-003 | [bat-005](./bat-005_20260604_kis-token-redis-cache/) |
|
| BAT-005 | batch-lite | release-candidate | REQ-016 | dcy-003 | [bat-005](./bat-005_20260604_kis-token-redis-cache/) |
|
||||||
| BAT-006 | standard | release-candidate | REQ-017, REQ-018, REQ-019, REQ-020 | dcy-004 | [bat-006](./bat-006_20260604_portfolio-hub-ledger-integration/) |
|
| BAT-006 | standard | release-candidate | REQ-017, REQ-018, REQ-019, REQ-020 | dcy-004 | [bat-006](./bat-006_20260604_portfolio-hub-ledger-integration/) |
|
||||||
| BAT-007 | standard | release-candidate | REQ-021, REQ-022, REQ-023, REQ-024, REQ-025, REQ-026 | dcy-006 | [bat-007](./bat-007_20260605_strategy-module-rabbitmq-transport/) |
|
| BAT-007 | standard | release-candidate | REQ-021, REQ-022, REQ-023, REQ-024, REQ-025, REQ-026 | dcy-006 | [bat-007](./bat-007_20260605_strategy-module-rabbitmq-transport/) |
|
||||||
|
| BAT-008 | standard | release-candidate | REQ-027, REQ-028, REQ-029, REQ-030, REQ-031, REQ-032, REQ-033 | dcy-005 | [bat-008](./bat-008_20260606_portfolio-hub-snapshot-read-layer/) |
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
# 0. 문서 상태
|
# 0. 문서 상태
|
||||||
이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다.
|
이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다.
|
||||||
|
|
||||||
- 상태: draft
|
- 상태: confirmed
|
||||||
- 문서 ID: dcy-005_20260604_portfolio-hub-asset-snapshot-api
|
- 문서 ID: dcy-005_20260604_portfolio-hub-asset-snapshot-api
|
||||||
- 이슈명: portfolio-hub 종목 스냅샷 read API — 전략 모듈에 전략 실행 대상 자산 리스트 제공
|
- 이슈명: portfolio-hub 종목 스냅샷 read API — 전략 모듈에 전략 실행 대상 자산 리스트 제공
|
||||||
- 작성 시각(KST): 2026-06-04 22:46
|
- 작성 시각(KST): 2026-06-04 22:46
|
||||||
- 마지막 갱신 시각(KST): 2026-06-05 23:57
|
- 마지막 갱신 시각(KST): 2026-06-06 00:20
|
||||||
- 대체됨: 없음
|
- 대체됨: 없음
|
||||||
- 후속 Discovery 참조: 없음
|
- 후속 Discovery 참조: 없음
|
||||||
- 생성된 REQ 참조: 없음
|
- 생성된 REQ 참조: REQ-027, REQ-028, REQ-029, REQ-030, REQ-031, REQ-032, REQ-033
|
||||||
|
|
||||||
# 1. 계획 상태 요약
|
# 1. 계획 상태 요약
|
||||||
현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다.
|
현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다.
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
문제의 현재 상태, 기대 상태, 관련 이해관계자, 제약사항을 정리한다. Discovery의 핵심 맥락을 가장 먼저 이해할 수 있어야 한다.
|
문제의 현재 상태, 기대 상태, 관련 이해관계자, 제약사항을 정리한다. Discovery의 핵심 맥락을 가장 먼저 이해할 수 있어야 한다.
|
||||||
|
|
||||||
- 현재 상태
|
- 현재 상태
|
||||||
- trex-core 의 read 경로는 슬롯/capacity 조회와 intent 조회뿐이고 portfolio-hub 종목을 노출하는 read 경로가 없다. (현재 이 read 들은 HTTP `/v1` 로 제공되나, dcy-006 confirmed 로 통신 transport 가 RabbitMQ 로 대체되고 HTTP 표면은 0 이 된다. 따라서 본 스냅샷 read 는 신규 HTTP 엔드포인트가 아니라 dcy-006 의 RabbitMQ RPC 로 노출한다.)
|
- trex-core 의 read 경로는 슬롯/capacity 조회와 intent 조회뿐이고 portfolio-hub 종목을 노출하는 read 경로가 없다. (dcy-006 구현으로 통신 transport 가 RabbitMQ 로 전환됨 — `adapters/api` HTTP 어댑터 제거, `adapters/messaging`(rabbitmq/topology) 추가, daemon 이 `trex.snapshot.req`/`trex.intent.req` RPC consumer 운영. 따라서 본 스냅샷 read 는 그 snapshot RPC 위에 portfolio-hub 종목 logical 계약을 얹는다 — portfolio-hub read 포트/어댑터는 아직 없다.)
|
||||||
- portfolio-hub read 어댑터(get_assets/get_portfolios)가 trex-core 에 없다. dcy-004/BAT-006 은 write 방향 `LedgerPort` + portfolio-hub 어댑터만 추가하며, read 포트는 다루지 않는다.
|
- portfolio-hub read 어댑터(get_assets/get_portfolios)가 trex-core 에 없다. dcy-004/BAT-006 은 write 방향 `LedgerPort` + portfolio-hub 어댑터만 추가하며, read 포트는 다루지 않는다.
|
||||||
- 결과적으로 전략 모듈이 "무엇을 매매할지" 정할 portfolio-hub 종목 정보를 trex-core 를 통해 받을 경로가 없다.
|
- 결과적으로 전략 모듈이 "무엇을 매매할지" 정할 portfolio-hub 종목 정보를 trex-core 를 통해 받을 경로가 없다.
|
||||||
- 기대 상태
|
- 기대 상태
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
|
|
||||||
- 코드 영향:
|
- 코드 영향:
|
||||||
- 신규: `trex_core/application` read 포트, 신규 `adapters/portfolio` read 어댑터 + stub, 스냅샷 read 를 dcy-006 messaging transport(RabbitMQ RPC, `adapters/messaging`)로 노출 + 응답 스키마(transport-무관), slot overlay(점유 종목 제외) + 현금/포지션 write-응답 갱신 로직(`cash_balance_after`/`asset_quantity_after`, 옵션 D), 일일 스냅샷 스케줄러(런타임 신규), 포지션 정합 검증 + `warning` 상태, `config` 에 스냅샷 페치 시각 주입(`daemon_id` 는 portfolio-hub 토큰에서 도출 — 별도 config 불필요, OQ-7), `strategy_key` 필터(요청 payload → `active_strategy_id` 매칭), 스냅샷 항목 pause/resume 상태 저장(Postgres, portfolio_id 키, refresh 무관) + 제어 표면(CLI + dcy-006 RPC 제어 메시지) + 응답 산출 시 pause 마스킹(전역).
|
- 신규: `trex_core/application` read 포트, 신규 `adapters/portfolio` read 어댑터 + stub, 스냅샷 read 를 dcy-006 messaging transport(RabbitMQ RPC, `adapters/messaging`)로 노출 + 응답 스키마(transport-무관), slot overlay(점유 종목 제외) + 현금/포지션 write-응답 갱신 로직(`cash_balance_after`/`asset_quantity_after`, 옵션 D), 일일 스냅샷 스케줄러(런타임 신규), 포지션 정합 검증 + `warning` 상태, `config` 에 스냅샷 페치 시각 주입(`daemon_id` 는 portfolio-hub 토큰에서 도출 — 별도 config 불필요, OQ-7), `strategy_key` 필터(요청 payload → `active_strategy_id` 매칭), 스냅샷 항목 pause/resume 상태 저장(Postgres, portfolio_id 키, refresh 무관) + 제어 표면(CLI + dcy-006 RPC 제어 메시지) + 응답 산출 시 pause 마스킹(전역).
|
||||||
- (주의: dcy-006 가 `adapters/api` HTTP 어댑터를 제거하므로, 스냅샷 read 는 신규 HTTP 엔드포인트가 아니라 dcy-006 의 RPC consumer 로 구현한다.)
|
- (dcy-006 구현 완료: `adapters/api` 제거·`adapters/messaging` 추가·`trex.snapshot.req` RPC consumer 존재. 스냅샷 read 는 이 RPC consumer 위에 portfolio-hub 종목 logical 계약을 채워 구현한다.)
|
||||||
- 기존: capacity/slot 조회 재사용(점유 종목·가용 슬롯 판정), 브로커 계좌 포지션 조회(BrokerPort 잔고조회 = KIS 주식잔고조회 `TTTC8434R`/`VTTC8434R`, `pdno`+`hldg_qty`, symbol 집계 — 정합 검증용, OQ-8) 재사용/확장.
|
- 기존: capacity/slot 조회 재사용(점유 종목·가용 슬롯 판정), 브로커 계좌 포지션 조회(BrokerPort 잔고조회 = KIS 주식잔고조회 `TTTC8434R`/`VTTC8434R`, `pdno`+`hldg_qty`, symbol 집계 — 정합 검증용, OQ-8) 재사용/확장.
|
||||||
- 문서 영향:
|
- 문서 영향:
|
||||||
- interface-contract/runtime-flows/project-structure 갱신.
|
- interface-contract/runtime-flows/project-structure 갱신.
|
||||||
@@ -270,15 +270,15 @@
|
|||||||
# 8. REQ로 넘기기 전 확인 체크
|
# 8. REQ로 넘기기 전 확인 체크
|
||||||
REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지 확인한다. 미완료 항목은 후속 결정이 필요함을 뜻한다.
|
REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지 확인한다. 미완료 항목은 후속 결정이 필요함을 뜻한다.
|
||||||
|
|
||||||
- [ ] 범위(In Scope/Out of Scope) 확정 — 특히 전략 의사결정 로직·reconciliation 제외
|
- [x] 범위(In Scope/Out of Scope) 확정 — 특히 전략 의사결정 로직·reconciliation 제외
|
||||||
- 기준: 운영자가 read 슬라이스 경계를 확인했다.
|
- 기준: 운영자가 read 슬라이스 경계를 확인했다.
|
||||||
- [ ] 성공 지표의 측정 방식/데이터 출처 확정
|
- [x] 성공 지표의 측정 방식/데이터 출처 확정
|
||||||
- 기준: S-1~S-12 측정 방식·출처 명시(본 문서 # 7).
|
- 기준: S-1~S-12 측정 방식·출처 명시(본 문서 # 7).
|
||||||
- [ ] High 영향 리스크 우선순위 및 해소 책임자 지정
|
- [x] High 영향 리스크 우선순위 및 해소 책임자 지정
|
||||||
- 기준: 본 Discovery 의 리스크는 현재 전부 Medium/Low. 신선도(R-1)·race(R-2)·가용성(R-3) 처리 방침을 운영자가 확인.
|
- 기준: 본 Discovery 의 리스크는 현재 전부 Medium/Low. 신선도(R-1)·race(R-2)·가용성(R-3) 처리 방침을 운영자가 확인.
|
||||||
- [ ] 모든 오픈 질문(OQ) `Open` 상태 없음 확인
|
- [x] 모든 오픈 질문(OQ) `Open` 상태 없음 확인
|
||||||
- 기준: OQ-1~OQ-12 이 전부 Answered/Deferred 처리(현재 OQ-1~OQ-12 전부 Answered).
|
- 기준: OQ-1~OQ-12 이 전부 Answered/Deferred 처리(현재 OQ-1~OQ-12 전부 Answered).
|
||||||
- [ ] Freeze 확정 담당자(CONFIRMED_BY) 지정
|
- [x] Freeze 확정 담당자(CONFIRMED_BY) 지정
|
||||||
- 기준: 운영자가 confirm-discovery 시점에 CONFIRMED_BY 를 지정한다.
|
- 기준: 운영자가 confirm-discovery 시점에 CONFIRMED_BY 를 지정한다.
|
||||||
|
|
||||||
# 9. 파일 처리 결과
|
# 9. 파일 처리 결과
|
||||||
@@ -306,13 +306,13 @@ REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지
|
|||||||
- ~~pause/resume 메커니즘~~ → 결정됨: portfolio_id 단위, CLI + dcy-006 RPC 제어, Postgres 영속(refresh 무관), 전역, 즉시 복귀 (OQ-10, 2026-06-05)
|
- ~~pause/resume 메커니즘~~ → 결정됨: portfolio_id 단위, CLI + dcy-006 RPC 제어, Postgres 영속(refresh 무관), 전역, 즉시 복귀 (OQ-10, 2026-06-05)
|
||||||
|
|
||||||
## CONFIRM
|
## CONFIRM
|
||||||
- {{CONFIRM_READ_SCOPE}} — read 슬라이스 범위 경계(전략 로직·reconciliation 제외) 확정
|
- ~~read 슬라이스 범위 경계~~ → 확정됨(2026-06-06, Justin): In Scope(portfolio-hub read 포트/어댑터·스냅샷 read 계약·마스킹·view 모드·옵션 D·정합 검증·baseline) / Out of Scope(전략 의사결정 로직·reconciliation·safety floor) 확인
|
||||||
- ~~core 경유 정책~~ → 결정됨: 모든 소비자 trex-core 경유, portfolio-hub 직접 호출 금지 (OQ-3, 2026-06-04)
|
- ~~core 경유 정책~~ → 결정됨: 모든 소비자 trex-core 경유, portfolio-hub 직접 호출 금지 (OQ-3, 2026-06-04)
|
||||||
- ~~in-flight 합성/제외 규칙~~ → 결정됨: holding 포함, pending·warning·pause 마스킹, core 권위 우선 (OQ-4, 2026-06-05)
|
- ~~in-flight 합성/제외 규칙~~ → 결정됨: holding 포함, pending·warning·pause 마스킹, core 권위 우선 (OQ-4, 2026-06-05)
|
||||||
- ~~가용성 degrade 정책~~ → 결정됨: 권위 cash/position 유지 불가 시 명시적 degraded 신호 + 신규 매매 보수적 중단, core 보호 매도 유지 (OQ-5, 2026-06-05). dcy-006 OQ-4 와 공통 보수적 안전.
|
- ~~가용성 degrade 정책~~ → 결정됨: 권위 cash/position 유지 불가 시 명시적 degraded 신호 + 신규 매매 보수적 중단, core 보호 매도 유지 (OQ-5, 2026-06-05). dcy-006 OQ-4 와 공통 보수적 안전.
|
||||||
|
|
||||||
## DATA
|
## DATA
|
||||||
- {{DATA_PORTFOLIO_HUB_READ_CONTRACT}} — portfolio-hub read API 계약(`managed_by_daemon_id` 로 daemon universe 조회) 및 응답 필드(cash_balance/asset_quantity/strategy_params JSONB) 확인
|
- ~~portfolio-hub read 계약 확인~~ → 검증됨(2026-06-06, confirm-discovery): portfolio-hub `app/api/v1_portfolios.py` `list_portfolios_v1`(`GET`)이 `managed_by_daemon_id=auth.daemon_id` 로 daemon universe 스코프 조회를 제공하고, `PortfolioItem` 에 `cash_balance`·`asset_quantity`·`managed_by_daemon_id` 포함. 전략파라미터는 `strategy_params` JSONB(A-5).
|
||||||
- ~~portfolio-hub write 응답 계약 확장~~ → 확정됨(2026-06-05, portfolio-hub#8): `POST /v1/portfolios/{portfolio_id}/ledger/trades` 응답 root 에 flat 필드 `cash_balance_after`, `asset_quantity_after` 추가 + ledger INSERT/잔고 UPDATE 단일 트랜잭션(회계 단일 권위). status 별 `*_after` 의미 정의(new=반영 후, duplicate=현재 권위, 409/422=미정). portfolio-hub 측은 dcy-006/REL-003 으로 배포 예정 — 배포 시점까지 trex-core 구현 대기. 추적: https://github.com/fourblocks2020/portfolio-hub/issues/8 (옵션 D 의존, A-7)
|
- ~~portfolio-hub write 응답 계약 확장~~ → 확정됨(2026-06-05, portfolio-hub#8): `POST /v1/portfolios/{portfolio_id}/ledger/trades` 응답 root 에 flat 필드 `cash_balance_after`, `asset_quantity_after` 추가 + ledger INSERT/잔고 UPDATE 단일 트랜잭션(회계 단일 권위). status 별 `*_after` 의미 정의(new=반영 후, duplicate=현재 권위, 409/422=미정). portfolio-hub 측은 dcy-006/REL-003 으로 배포 예정 — 배포 시점까지 trex-core 구현 대기. 추적: https://github.com/fourblocks2020/portfolio-hub/issues/8 (옵션 D 의존, A-7)
|
||||||
|
|
||||||
# 11. Discovery Freeze
|
# 11. Discovery Freeze
|
||||||
@@ -326,8 +326,8 @@ REQ 초안 작성 인계 시점의 확정 플래그와 섹션 참조만 기록
|
|||||||
- 사용자 결정 필요: [# 10. 사용자 결정 필요 항목 요약](#10-사용자-결정-필요-항목-요약)
|
- 사용자 결정 필요: [# 10. 사용자 결정 필요 항목 요약](#10-사용자-결정-필요-항목-요약)
|
||||||
|
|
||||||
## Freeze 플래그
|
## Freeze 플래그
|
||||||
- Handoff Decision: 보류
|
- Handoff Decision: 승인
|
||||||
- Handoff Rationale: OQ-1(스냅샷 정의)·OQ-2(신선도, 옵션 D — portfolio-hub#8 write 응답 `*_after` 확정)·OQ-3(core 경유 정책)·OQ-4(in-flight 합성 — holding 포함, pending·warning·pause 마스킹)·OQ-5(가용성 degrade — 권위 cash/position 유지 불가 시 degraded 신호+보수적 중단)·OQ-6(빈 표현 — envelope + `assets: []`/meta)·OQ-7(daemon_id — 토큰 도출 단일 진실원)·OQ-8(정합 — 수량 정확 일치·symbol 집계·자동 해제·KIS 잔고조회)·OQ-9(전략 ID — 사람이 읽는 안정 slug·단일 active 일치)·OQ-10(pause — portfolio_id·CLI+MQ·Postgres 영속·전역·즉시 복귀)·OQ-11(capacity 0 응답 — view 모드 파라미터)·OQ-12(view 파라미터 — `all`/`buyable`/`held` + envelope) **전부 Answered**. 남은 인계 전 항목은 범위 경계 확인(운영자)과 CONFIRMED_BY 지정이며, confirm-discovery 에서 확정 후 REQ Drafting 으로 인계한다.
|
- Handoff Rationale: OQ-1(스냅샷 정의)·OQ-2(신선도, 옵션 D — portfolio-hub#8 write 응답 `*_after` 확정)·OQ-3(core 경유 정책)·OQ-4(in-flight 합성 — holding 포함, pending·warning·pause 마스킹)·OQ-5(가용성 degrade — 권위 cash/position 유지 불가 시 degraded 신호+보수적 중단)·OQ-6(빈 표현 — envelope + `assets: []`/meta)·OQ-7(daemon_id — 토큰 도출 단일 진실원)·OQ-8(정합 — 수량 정확 일치·symbol 집계·자동 해제·KIS 잔고조회)·OQ-9(전략 ID — 사람이 읽는 안정 slug·단일 active 일치)·OQ-10(pause — portfolio_id·CLI+MQ·Postgres 영속·전역·즉시 복귀)·OQ-11(capacity 0 응답 — view 모드 파라미터)·OQ-12(view 파라미터 — `all`/`buyable`/`held` + envelope) **전부 Answered**. 남은 인계 전 항목은 범위 경계 확인(운영자)과 CONFIRMED_BY 지정이며, confirm-discovery 에서 확정 후 REQ Drafting 으로 인계한다.
|
||||||
- Ready for REQ Drafting: false
|
- Ready for REQ Drafting: true
|
||||||
- Confirmed By: TBD
|
- Confirmed By: Justin
|
||||||
- Confirmed At (KST): TBD
|
- Confirmed At (KST): 2026-06-06 00:08
|
||||||
|
|||||||
@@ -10,5 +10,5 @@
|
|||||||
| dcy-002_20260603_slot-core-daemon-http-api | 20260603 | trex-core 슬롯 코어 데몬화 + HTTP intent API | [Discovery](./dcy-002_20260603_slot-core-daemon-http-api.md) | confirmed |
|
| dcy-002_20260603_slot-core-daemon-http-api | 20260603 | trex-core 슬롯 코어 데몬화 + HTTP intent API | [Discovery](./dcy-002_20260603_slot-core-daemon-http-api.md) | confirmed |
|
||||||
| dcy-003_20260604_broker-execution-and-outbox | 20260604 | 브로커 단일 세션 + 주문 집행 + durable outbox (불변식 B) | [Discovery](./dcy-003_20260604_broker-execution-and-outbox.md) | confirmed |
|
| dcy-003_20260604_broker-execution-and-outbox | 20260604 | 브로커 단일 세션 + 주문 집행 + durable outbox (불변식 B) | [Discovery](./dcy-003_20260604_broker-execution-and-outbox.md) | confirmed |
|
||||||
| dcy-004_20260604_portfolio-hub-ledger-integration | 20260604 | portfolio-hub ledger 연동 — 체결을 외부 원장에 기록 | [Discovery](./dcy-004_20260604_portfolio-hub-ledger-integration.md) | confirmed |
|
| dcy-004_20260604_portfolio-hub-ledger-integration | 20260604 | portfolio-hub ledger 연동 — 체결을 외부 원장에 기록 | [Discovery](./dcy-004_20260604_portfolio-hub-ledger-integration.md) | confirmed |
|
||||||
| dcy-005_20260604_portfolio-hub-asset-snapshot-api | 20260604 | portfolio-hub 종목 스냅샷 read API — 매수 전략 모듈에 슬롯-제외 후보 제공 | [Discovery](./dcy-005_20260604_portfolio-hub-asset-snapshot-api.md) | draft |
|
| dcy-005_20260604_portfolio-hub-asset-snapshot-api | 20260604 | portfolio-hub 종목 스냅샷 read API — 전략 모듈에 전략 실행 대상 자산 리스트 제공 | [Discovery](./dcy-005_20260604_portfolio-hub-asset-snapshot-api.md) | confirmed |
|
||||||
| dcy-006_20260605_strategy-module-rabbitmq-transport | 20260605 | trex-core ↔ 전략 모듈 통신 transport — RabbitMQ 도입 (스냅샷 read + intent 제출 + 이벤트) | [Discovery](./dcy-006_20260605_strategy-module-rabbitmq-transport.md) | confirmed |
|
| dcy-006_20260605_strategy-module-rabbitmq-transport | 20260605 | trex-core ↔ 전략 모듈 통신 transport — RabbitMQ 도입 (스냅샷 read + intent 제출 + 이벤트) | [Discovery](./dcy-006_20260605_strategy-module-rabbitmq-transport.md) | confirmed |
|
||||||
+121
-3
@@ -4,7 +4,7 @@
|
|||||||
- Baseline Seed: .stage-pilot/bootstrap/baseline.yaml
|
- Baseline Seed: .stage-pilot/bootstrap/baseline.yaml
|
||||||
- Status: draft
|
- Status: draft
|
||||||
- Owner: project maintainers
|
- Owner: project maintainers
|
||||||
- Last Updated (KST): 2026-06-05 (BAT-007)
|
- Last Updated (KST): 2026-06-06 (BAT-008)
|
||||||
- Source Discovery / Batch: bootstrap-baseline (pre-Discovery); HTTP API = dcy-002 / BAT-002
|
- Source Discovery / Batch: bootstrap-baseline (pre-Discovery); HTTP API = dcy-002 / BAT-002
|
||||||
- Primary Runtime: daemon (HTTP API) + library (slot 도메인)
|
- Primary Runtime: daemon (HTTP API) + library (slot 도메인)
|
||||||
|
|
||||||
@@ -25,15 +25,19 @@
|
|||||||
|
|
||||||
## Interface Summary
|
## Interface Summary
|
||||||
|
|
||||||
- RabbitMQ `trex.snapshot.req` :: 전략 모듈의 스냅샷 RPC (request-reply, BAT-007)
|
- RabbitMQ `trex.snapshot.req` :: 전략 모듈의 스냅샷 RPC (request-reply, BAT-007/BAT-008 envelope)
|
||||||
- RabbitMQ `trex.intent.req` :: 전략 모듈의 매수/매도 admission RPC (request-reply, BAT-007)
|
- RabbitMQ `trex.intent.req` :: 전략 모듈의 매수/매도 admission RPC (request-reply, BAT-007)
|
||||||
- RabbitMQ `trex.events` :: 체결/슬롯 상태 이벤트 topic exchange (pub/sub, BAT-007)
|
- RabbitMQ `trex.events` :: 체결/슬롯 상태 이벤트 topic exchange (pub/sub, BAT-007)
|
||||||
|
- RabbitMQ `trex.pause.req` :: pause/resume 제어 RPC (request-reply, BAT-008)
|
||||||
|
- `trex-core` CLI :: pause/resume/paused 운영 명령 (BAT-008)
|
||||||
- SlotService :: 슬롯 생명주기 도메인 서비스(전이/사전할당/가용성/재진입 카운트)
|
- SlotService :: 슬롯 생명주기 도메인 서비스(전이/사전할당/가용성/재진입 카운트)
|
||||||
- SlotRepository (Protocol) :: 도메인 ↔ 저장소 경계 계약
|
- SlotRepository (Protocol) :: 도메인 ↔ 저장소 경계 계약
|
||||||
- BrokerPort (Protocol) :: 단일 브로커 세션 경계(submit/poll/cancel) + `OrderRequest`/`OrderAck`/`FillStatus`
|
- BrokerPort (Protocol) :: 단일 브로커 세션 경계(submit/poll/cancel) + `OrderRequest`/`OrderAck`/`FillStatus`
|
||||||
- LedgerPort (Protocol) :: 외부 원장(portfolio-hub) 기록 경계 + `LedgerTrade`/`LedgerWriteResult`/예외 (BAT-006)
|
- LedgerPort (Protocol) :: 외부 원장(portfolio-hub) 기록 경계 + `LedgerTrade`/`LedgerWriteResult`/예외 (BAT-006)
|
||||||
- LedgerOutbox (Protocol) + LedgerWriter :: 체결 → 원장 기록의 outbox/재시도/멱등 (BAT-006)
|
- LedgerOutbox (Protocol) + LedgerWriter :: 체결 → 원장 기록의 outbox/재시도/멱등 (BAT-006)
|
||||||
- MessagingTransport (Protocol) :: RabbitMQ / InMemory stub transport 경계 + `TransportHealth` enum (BAT-007)
|
- MessagingTransport (Protocol) :: RabbitMQ / InMemory stub transport 경계 + `TransportHealth` enum (BAT-007)
|
||||||
|
- PortfolioHubReadPort (Protocol) :: portfolio-hub universe read 경계 + `PortfolioItem`/`PortfolioHubUnavailableError` (BAT-008)
|
||||||
|
- SnapshotStore + PauseStore + SnapshotScheduler :: in-memory snapshot + durable pause overlay + 일일 refresh (BAT-008)
|
||||||
|
|
||||||
> BAT-007 에서 HTTP `/v1` API 표면이 0 으로 제거됨(REQ-025). 이전 baseline 의 `HTTP /v1` 인터페이스 섹션은 더 이상 유효하지 않으며 본 인터페이스 인덱스에서 제거되었다. 전략-facing 통신은 위의 3 RabbitMQ 표면이 전부다.
|
> BAT-007 에서 HTTP `/v1` API 표면이 0 으로 제거됨(REQ-025). 이전 baseline 의 `HTTP /v1` 인터페이스 섹션은 더 이상 유효하지 않으며 본 인터페이스 인덱스에서 제거되었다. 전략-facing 통신은 위의 3 RabbitMQ 표면이 전부다.
|
||||||
|
|
||||||
@@ -106,7 +110,120 @@ sell payload 는 `{"intent_type":"sell", "intent_id":..., "slot_id":..., "reason
|
|||||||
- 재연결은 백오프 1s→2s→4s→8s→16s→30s cap, 어댑터가 자체 구현.
|
- 재연결은 백오프 1s→2s→4s→8s→16s→30s cap, 어댑터가 자체 구현.
|
||||||
- core 자율 안전 매도는 transport 와 무관하게 동작해야 한다(architecture contract; 자동 트리거 구현은 별도 Discovery).
|
- core 자율 안전 매도는 transport 와 무관하게 동작해야 한다(architecture contract; 자동 트리거 구현은 별도 Discovery).
|
||||||
|
|
||||||
## Interface: `SlotService`
|
### `trex.snapshot.req` envelope (BAT-008 / REQ-028)
|
||||||
|
|
||||||
|
전략 모듈이 보내는 요청:
|
||||||
|
```
|
||||||
|
{ "view": "all" | "buyable" | "held" # default "all"
|
||||||
|
"strategy_key": "<slug>" # optional, == filter on active_strategy_id
|
||||||
|
"trading_date": "YYYY-MM-DD" # optional, default UTC today
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
코어가 reply-to 큐로 보내는 응답 envelope:
|
||||||
|
```
|
||||||
|
{ "assets": [ # always a list (REQ-028 AC-1, never null)
|
||||||
|
{ "symbol": str,
|
||||||
|
"asset_id": str, # == portfolio_id (dcy-004 OQ-1)
|
||||||
|
"cash_balance": number,
|
||||||
|
"asset_quantity": number,
|
||||||
|
"active_strategy_id": str | null,
|
||||||
|
"strategy_params": object # opaque, pass-through
|
||||||
|
}, ...
|
||||||
|
],
|
||||||
|
"view": "all" | "buyable" | "held", # echo
|
||||||
|
"available_slots": int, # idle_count snapshot
|
||||||
|
"degraded": bool, # REQ-028 AC-2
|
||||||
|
"reason": "portfolio_hub_unavailable" | "snapshot_fetch_failed"
|
||||||
|
| "cash_position_stale" | null,
|
||||||
|
"message": str | null # sanitized — no credentials/raw exceptions
|
||||||
|
}
|
||||||
|
```
|
||||||
|
**No `price` field on items** (REQ-028 AC-3 / REQ-027 AC-1).
|
||||||
|
|
||||||
|
### `trex.pause.req` control RPC (BAT-008 / REQ-032)
|
||||||
|
|
||||||
|
요청:
|
||||||
|
```
|
||||||
|
{ "action": "pause" | "resume" | "list",
|
||||||
|
"portfolio_id": str, # required for pause/resume
|
||||||
|
"actor": str # optional, default "operator"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
응답:
|
||||||
|
```
|
||||||
|
{ "status": "ok" | "error",
|
||||||
|
"paused": [str, ...], # current paused portfolio_ids (sorted)
|
||||||
|
"action": echo,
|
||||||
|
"reason": str # only when status="error"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- pause 는 `snapshot_pause` Postgres 테이블에 upsert(actor 갱신)되어 daemon 재시작·일일 refresh 후에도 유지된다(REQ-032 AC-2).
|
||||||
|
- CLI `trex-core pause/resume/paused` 가 동일한 store 를 가리키므로 CLI ↔ RPC 가 같은 상태를 본다(REQ-032 AC-4).
|
||||||
|
|
||||||
|
## Interface: `PortfolioHubReadPort` (Protocol, BAT-008)
|
||||||
|
|
||||||
|
- Type: external-service (HTTP, portfolio-hub)
|
||||||
|
- Consumers / Producers: `SnapshotScheduler`(소비) ↔ `PortfolioHubReadAdapter` 또는 `StubPortfolioRead`
|
||||||
|
- Purpose: portfolio-hub universe(이 데몬이 관리하는 portfolio 집합) read 경계 — write 는 BAT-006 `LedgerPort` 와 분리.
|
||||||
|
- Stability: experimental (BAT-008)
|
||||||
|
|
||||||
|
### Inputs
|
||||||
|
|
||||||
|
- `whoami() -> str` :: 자격증명/토큰에서 `daemon_id` 도출(1회). 별도 env 1차 출처 두지 않음.
|
||||||
|
- `get_portfolios(daemon_id) -> list[PortfolioItem]` :: `managed_by_daemon_id == daemon_id` scope 조회.
|
||||||
|
|
||||||
|
### `PortfolioItem` Fields
|
||||||
|
|
||||||
|
- `portfolio_id :: str` :: == asset_id (dcy-004 OQ-1)
|
||||||
|
- `symbol :: str`
|
||||||
|
- `cash_balance :: float`
|
||||||
|
- `asset_quantity :: float` :: integer shares semantically
|
||||||
|
- `active_strategy_id :: str | None`
|
||||||
|
- `strategy_params :: dict` :: opaque pass-through (no core parsing)
|
||||||
|
|
||||||
|
### Outputs / Outcomes
|
||||||
|
|
||||||
|
- 정상: `list[PortfolioItem]` (배열, 비면 `[]`).
|
||||||
|
- 어댑터 어떤 실패든 `PortfolioHubUnavailableError` 로 통일 (호출자는 `SnapshotStore.set_degraded` 로 전환).
|
||||||
|
|
||||||
|
### Error Contract
|
||||||
|
|
||||||
|
- `PortfolioHubUnavailableError` :: 네트워크/5xx/4xx/JSON 파싱 실패 등 fetch 불가. 자격증명/예외 본문은 호출 측에서 sanitize 후 노출(REQ-028 AC-2).
|
||||||
|
|
||||||
|
### portfolio-hub Endpoint (어댑터 구현)
|
||||||
|
|
||||||
|
| HTTP | URL | 응답 |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/v1/auth/whoami` | `{daemon_id: str, ...}` |
|
||||||
|
| GET | `/v1/portfolios?managed_by_daemon_id=<id>` | `{items: [PortfolioItem, ...]}` 또는 list root |
|
||||||
|
|
||||||
|
## Interface: `SnapshotStore` + `PauseStore` + `SnapshotScheduler` (Protocol, BAT-008)
|
||||||
|
|
||||||
|
- Type: internal-service
|
||||||
|
- Consumers / Producers: `SnapshotScheduler`(생산) → `SnapshotStore`(read 채널) ← `make_snapshot_rpc_handler`(소비). `PauseStore`(`PostgreSQLPauseStore` 또는 `InMemoryPauseStore`)는 CLI/RPC 양쪽이 쓰고 handler 가 읽는다.
|
||||||
|
|
||||||
|
### Inputs
|
||||||
|
|
||||||
|
- `SnapshotStore.swap(items)` :: atomic immutable swap. 직전 `warning` flag 는 carry-over(다음 reconcile 에서 자동 해제 판단).
|
||||||
|
- `SnapshotStore.set_warning(pid, on)` :: 정합 결과 overlay(in-memory only).
|
||||||
|
- `SnapshotStore.update_cash_position(pid, cash, qty)` :: 옵션 D wiring point(REQ-030 AC-2/AC-3 — portfolio-hub#8 배포 후에만 ledger writer 가 호출).
|
||||||
|
- `SnapshotStore.set_degraded(reason, message)` / `clear_degraded()` :: degraded signal.
|
||||||
|
- `PauseStore.pause(pid, actor)` / `resume(pid)` / `list_paused()` :: durable manual overlay.
|
||||||
|
- `SnapshotScheduler.run_once()` :: fetch + swap + reconcile 1 회.
|
||||||
|
- `SnapshotScheduler.run_forever(stop, tick_sec)` :: boot 즉시 1 회 + 매일 `TREX_SNAPSHOT_SCHEDULE_KST` 시각에 1 회.
|
||||||
|
|
||||||
|
### Outputs / Outcomes
|
||||||
|
|
||||||
|
- `run_once()` returns `True`(성공) / `False`(degraded). `True` 시 reconcile 한 패스가 함께 돈다.
|
||||||
|
- `list_assets()` + `degraded_state()` 를 envelope builder 가 매 RPC 요청마다 읽음.
|
||||||
|
|
||||||
|
### Compatibility Rules
|
||||||
|
|
||||||
|
- pause 의 영속성 단위는 `portfolio_id`(= asset_id). 동일 portfolio_id 재 pause 는 upsert(actor만 갱신).
|
||||||
|
- warning 은 in-memory only — 재시작 시 초기화되며 다음 정합 패스가 다시 판정한다.
|
||||||
|
- universe swap 은 pause/warning 의 결정을 invalidate 하지 않는다(warning 은 carry-over, pause 는 store 가 별도 영속).
|
||||||
|
|
||||||
- Type: internal-service
|
- Type: internal-service
|
||||||
- Consumers / Producers: 상위 애플리케이션(예: trex-orbit 류 매매 데몬)의 매수/매도/주문 결정 경로 및 운영자 도구
|
- Consumers / Producers: 상위 애플리케이션(예: trex-orbit 류 매매 데몬)의 매수/매도/주문 결정 경로 및 운영자 도구
|
||||||
@@ -300,3 +417,4 @@ sell payload 는 `{"intent_type":"sell", "intent_id":..., "slot_id":..., "reason
|
|||||||
- 2026-06-04 :: BAT-004 — 브로커 집행 레이어 반영(BrokerPort/paper·KIS 어댑터, execution 루프, broker_order_id 실행큐·멱등, reconciliation) (REQ-011~015).
|
- 2026-06-04 :: BAT-004 — 브로커 집행 레이어 반영(BrokerPort/paper·KIS 어댑터, execution 루프, broker_order_id 실행큐·멱등, reconciliation) (REQ-011~015).
|
||||||
- 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-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-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 배포 후).
|
||||||
|
|||||||
+20
-12
@@ -4,7 +4,7 @@
|
|||||||
- Baseline Seed: .stage-pilot/bootstrap/baseline.yaml
|
- Baseline Seed: .stage-pilot/bootstrap/baseline.yaml
|
||||||
- Status: draft
|
- Status: draft
|
||||||
- Owner: project maintainers
|
- Owner: project maintainers
|
||||||
- Last Updated (KST): 2026-06-05 (BAT-007)
|
- Last Updated (KST): 2026-06-06 (BAT-008)
|
||||||
- Source Discovery / Batch: bootstrap-baseline (pre-Discovery, 3-phase lifecycle 밖의 bootstrap 산출물)
|
- Source Discovery / Batch: bootstrap-baseline (pre-Discovery, 3-phase lifecycle 밖의 bootstrap 산출물)
|
||||||
|
|
||||||
## Project Summary
|
## Project Summary
|
||||||
@@ -23,10 +23,12 @@
|
|||||||
## Primary Runtime
|
## Primary Runtime
|
||||||
- Type: daemon (sync + thread, no HTTP) + library (slot 도메인)
|
- Type: daemon (sync + thread, no HTTP) + library (slot 도메인)
|
||||||
- Entry Points:
|
- Entry Points:
|
||||||
- `trex-core-daemon` (console_script → `trex_core.runtime.daemon:main`) :: 슬롯 코어 데몬 — migrate+preallocate+reconcile 후 4 백그라운드 스레드(intent RPC consumer / snapshot RPC consumer / execution / ledger) 기동, RabbitMQ messaging 으로 전략 모듈과 통신 (BAT-002 + BAT-007)
|
- `trex-core-daemon` (console_script → `trex_core.runtime.daemon:main`) :: 슬롯 코어 데몬 — migrate+preallocate+reconcile 후 6 백그라운드 스레드(intent RPC consumer / snapshot RPC consumer / pause RPC consumer / snapshot scheduler / execution / ledger) 기동, RabbitMQ messaging 으로 전략 모듈과 통신 (BAT-002 + BAT-007 + BAT-008)
|
||||||
- RabbitMQ `trex.snapshot.req` / `trex.intent.req` / `trex.events` :: 매수·매도 전략 데몬과의 transport (docs/interface-contract.md)
|
- `trex-core` (console_script → `trex_core.runtime.cli:main`) :: 운영 CLI (`pause` / `resume` / `paused`) (BAT-008)
|
||||||
|
- RabbitMQ `trex.snapshot.req` / `trex.intent.req` / `trex.pause.req` / `trex.events` :: 매수·매도 전략 데몬과의 transport (docs/interface-contract.md)
|
||||||
- SlotService / SlotRepository (Protocol) :: in-process 도메인 서비스 + 저장소 경계(라이브러리 사용)
|
- SlotService / SlotRepository (Protocol) :: in-process 도메인 서비스 + 저장소 경계(라이브러리 사용)
|
||||||
- MessagingTransport (Protocol) :: transport 추상화 (RabbitMQ / InMemory stub)
|
- MessagingTransport (Protocol) :: transport 추상화 (RabbitMQ / InMemory stub)
|
||||||
|
- PortfolioHubReadPort (Protocol) :: portfolio-hub universe read 경계 (BAT-008)
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
- This document is the current approved repository and package structure baseline.
|
- This document is the current approved repository and package structure baseline.
|
||||||
@@ -63,23 +65,28 @@
|
|||||||
│ ├── domain/slot/ # slot 순수 도메인 (BAT-001, 무의존)
|
│ ├── domain/slot/ # slot 순수 도메인 (BAT-001, 무의존)
|
||||||
│ │ ├── __init__.py · state.py · models.py(+EXPECTED_SLOT_FIELDS) · errors.py
|
│ │ ├── __init__.py · state.py · models.py(+EXPECTED_SLOT_FIELDS) · errors.py
|
||||||
│ │ ├── repository.py(Protocol) · service.py · in_memory_repository.py
|
│ │ ├── repository.py(Protocol) · service.py · in_memory_repository.py
|
||||||
│ ├── application/ # 유스케이스 (BAT-002, BAT-004, BAT-006, BAT-007)
|
│ ├── application/ # 유스케이스 (BAT-002, BAT-004, BAT-006, BAT-007, BAT-008)
|
||||||
│ │ ├── ports.py # IntentStore Protocol
|
│ │ ├── ports.py # IntentStore Protocol
|
||||||
│ │ ├── intents.py # IntentService / BuyIntent / SellIntent / IntentDecision
|
│ │ ├── intents.py # IntentService / BuyIntent / SellIntent / IntentDecision
|
||||||
│ │ ├── broker_port.py # BrokerPort + OrderRequest/OrderAck/FillStatus + calculate_fee (BAT-004/BAT-006)
|
│ │ ├── broker_port.py # BrokerPort + OrderRequest/OrderAck/FillStatus + BalanceItem + calculate_fee (BAT-004/BAT-006/BAT-008)
|
||||||
│ │ ├── execution.py # ExecutionService (dispatch·poll·transition·reconcile + ledger enqueue + event emit hook)
|
│ │ ├── execution.py # ExecutionService (dispatch·poll·transition·reconcile + ledger enqueue + event emit hook)
|
||||||
│ │ ├── ledger_port.py # LedgerPort + LedgerTrade/LedgerWriteResult/예외 (BAT-006)
|
│ │ ├── ledger_port.py # LedgerPort + LedgerTrade/LedgerWriteResult/예외 (BAT-006)
|
||||||
│ │ ├── ledger_outbox.py # LedgerOutbox Protocol + LedgerWriter + InMemoryLedgerOutbox (BAT-006)
|
│ │ ├── ledger_outbox.py # LedgerOutbox Protocol + LedgerWriter + InMemoryLedgerOutbox (BAT-006)
|
||||||
│ │ ├── messaging.py # MessagingTransport Protocol + DTO + TransportHealth (BAT-007)
|
│ │ ├── messaging.py # MessagingTransport Protocol + DTO + TransportHealth (BAT-007)
|
||||||
│ │ └── messaging_handlers.py # intent/snapshot RPC handlers + slot event payload builder (BAT-007)
|
│ │ ├── messaging_handlers.py # intent/snapshot/pause RPC handlers + slot event payload builder (BAT-007/BAT-008)
|
||||||
│ ├── adapters/ # 인프라 어댑터 (BAT-002, BAT-004, BAT-006, BAT-007)
|
│ │ ├── portfolio.py # PortfolioHubReadPort + SnapshotStore + PauseStore + envelope builder (BAT-008)
|
||||||
│ │ ├── broker/ # paper.py(결정적 async 더블) · kis.py(KIS Open API) (BAT-004)
|
│ │ ├── reconciliation.py # reconcile_positions (hub ↔ broker symbol-aggregate) (BAT-008)
|
||||||
|
│ │ └── snapshot_scheduler.py # SnapshotScheduler — daily fetch + swap + reconcile (BAT-008)
|
||||||
|
│ ├── adapters/ # 인프라 어댑터 (BAT-002, BAT-004, BAT-006, BAT-007, BAT-008)
|
||||||
|
│ │ ├── broker/ # paper.py(결정적 async 더블) · kis.py(KIS Open API + 잔고조회) (BAT-004/BAT-008)
|
||||||
│ │ ├── ledger/ # in_memory.py(stub) · portfolio_hub.py(HTTP 어댑터) (BAT-006)
|
│ │ ├── ledger/ # in_memory.py(stub) · portfolio_hub.py(HTTP 어댑터) (BAT-006)
|
||||||
│ │ ├── messaging/ # topology.py · in_memory.py(stub) · rabbitmq.py(pika 동기) (BAT-007)
|
│ │ ├── messaging/ # topology.py · in_memory.py(stub) · rabbitmq.py(pika 동기) (BAT-007)
|
||||||
│ │ └── persistence/ # postgresql.py(+PostgreSQLLedgerOutbox) · in_memory_intent_store.py · migrate.py · sql/(001_…, 002_create_ledger_outbox.sql)
|
│ │ ├── portfolio/ # hub_read.py(HTTP) · stub_read.py(stub) · pause_store.py(Postgres) (BAT-008)
|
||||||
│ └── runtime/ # 데몬 (BAT-002, BAT-004, BAT-006, BAT-007)
|
│ │ └── persistence/ # postgresql.py(+PostgreSQLLedgerOutbox) · in_memory_intent_store.py · migrate.py · sql/(001_…, 002_create_ledger_outbox.sql, 003_create_snapshot_pause.sql)
|
||||||
│ ├── config.py # DaemonConfig + KISConfig + PortfolioHubConfig + RabbitMQConfig (env)
|
│ └── runtime/ # 데몬 (BAT-002, BAT-004, BAT-006, BAT-007, BAT-008)
|
||||||
│ └── daemon.py # Daemon class: setup(migrate/preallocate/reconcile/declare-topology) + 4 worker threads (execution·ledger·intent-rpc·snapshot-rpc) + graceful shutdown
|
│ ├── config.py # DaemonConfig + KISConfig + PortfolioHubConfig + RabbitMQConfig + SnapshotScheduleConfig (env)
|
||||||
|
│ ├── daemon.py # Daemon class: setup(migrate/preallocate/reconcile/declare-topology) + 6 worker threads (execution·ledger·intent-rpc·snapshot-rpc·pause-rpc·snapshot-scheduler) + graceful shutdown
|
||||||
|
│ └── cli.py # trex-core CLI: pause/resume/paused subcommands (BAT-008 / REQ-032)
|
||||||
└── tests/
|
└── tests/
|
||||||
├── domain/slot/ # test_state / test_service / test_contract
|
├── domain/slot/ # test_state / test_service / test_contract
|
||||||
├── domain/ # test_domain_import_sweep (NFR-1 가드, BAT-006/BAT-007)
|
├── domain/ # test_domain_import_sweep (NFR-1 가드, BAT-006/BAT-007)
|
||||||
@@ -135,3 +142,4 @@
|
|||||||
- 2026-06-04 :: BAT-004 — 브로커 집행 레이어 반영(BrokerPort/paper·KIS 어댑터, execution 루프, broker_order_id 실행큐·멱등, reconciliation) (REQ-011~015).
|
- 2026-06-04 :: BAT-004 — 브로커 집행 레이어 반영(BrokerPort/paper·KIS 어댑터, execution 루프, broker_order_id 실행큐·멱등, reconciliation) (REQ-011~015).
|
||||||
- 2026-06-04 :: BAT-006 — ledger 레이어 추가(application LedgerPort/LedgerOutbox, adapters/ledger in_memory·portfolio_hub, persistence PostgreSQLLedgerOutbox + 002 마이그레이션, runtime ledger writer 루프, FillStatus 확장, NFR-1 import 스윕) (REQ-017~020).
|
- 2026-06-04 :: BAT-006 — ledger 레이어 추가(application LedgerPort/LedgerOutbox, adapters/ledger in_memory·portfolio_hub, persistence PostgreSQLLedgerOutbox + 002 마이그레이션, runtime ledger writer 루프, FillStatus 확장, NFR-1 import 스윕) (REQ-017~020).
|
||||||
- 2026-06-05 :: BAT-007 — transport 전환(`adapters/api/` 제거, `application/messaging.py` + `application/messaging_handlers.py` 신설, `adapters/messaging/` 신설(topology/in_memory/rabbitmq), runtime/daemon 을 sync+thread 4 스레드 구조로 재구성, `RabbitMQConfig` + `TREX_RABBITMQ_URL` env, `TREX_API_HOST`/`TREX_API_PORT` 폐기, `fastapi`/`uvicorn` 의존 제거 + `pika` 도입, NFR-1 import 스윕에 pika/messaging 추가, HTTP 표면 0 회귀 가드) (REQ-021~026).
|
- 2026-06-05 :: BAT-007 — transport 전환(`adapters/api/` 제거, `application/messaging.py` + `application/messaging_handlers.py` 신설, `adapters/messaging/` 신설(topology/in_memory/rabbitmq), runtime/daemon 을 sync+thread 4 스레드 구조로 재구성, `RabbitMQConfig` + `TREX_RABBITMQ_URL` env, `TREX_API_HOST`/`TREX_API_PORT` 폐기, `fastapi`/`uvicorn` 의존 제거 + `pika` 도입, NFR-1 import 스윕에 pika/messaging 추가, HTTP 표면 0 회귀 가드) (REQ-021~026).
|
||||||
|
- 2026-06-06 :: BAT-008 — portfolio-hub 스냅샷 read 레이어 추가(`application/portfolio.py` + `application/reconciliation.py` + `application/snapshot_scheduler.py` 신설, `adapters/portfolio/` 신설(hub_read/stub_read/pause_store), `BrokerPort.get_balance()` 확장 + paper/KIS 구현, `messaging_handlers` 에 snapshot envelope + pause RPC 추가, runtime/daemon 을 6 스레드로 확장(pause-rpc + snapshot-scheduler), `SnapshotScheduleConfig` + `TREX_SNAPSHOT_*` env, `runtime/cli.py` + `trex-core` 콘솔 스크립트, `003_create_snapshot_pause.sql` 마이그레이션, NFR-1 import 스윕 유지) (REQ-027~033; REQ-030 옵션 D 는 portfolio-hub#8 배포 후 별도 구현).
|
||||||
|
|||||||
+42
-1
@@ -4,7 +4,7 @@
|
|||||||
- Baseline Seed: .stage-pilot/bootstrap/baseline.yaml
|
- Baseline Seed: .stage-pilot/bootstrap/baseline.yaml
|
||||||
- Status: draft
|
- Status: draft
|
||||||
- Owner: project maintainers
|
- Owner: project maintainers
|
||||||
- Last Updated (KST): 2026-06-05 (BAT-007)
|
- Last Updated (KST): 2026-06-06 (BAT-008)
|
||||||
- Source Discovery / Batch: bootstrap-baseline (pre-Discovery); 데몬/HTTP 흐름 = dcy-002 / BAT-002
|
- Source Discovery / Batch: bootstrap-baseline (pre-Discovery); 데몬/HTTP 흐름 = dcy-002 / BAT-002
|
||||||
- Primary Runtime: daemon (HTTP API) + library (slot 도메인)
|
- Primary Runtime: daemon (HTTP API) + library (slot 도메인)
|
||||||
|
|
||||||
@@ -67,6 +67,46 @@
|
|||||||
3. 전략별 durable queue(`trex.events.strategy.{strategy_id}`)는 `*.{strategy_id}.#` 패턴으로 바인딩 → broker 단 필터로 자기 자산 이벤트만 수신.
|
3. 전략별 durable queue(`trex.events.strategy.{strategy_id}`)는 `*.{strategy_id}.#` 패턴으로 바인딩 → broker 단 필터로 자기 자산 이벤트만 수신.
|
||||||
4. 전략 down 중 적체된 메시지는 catch-up 으로 복구; TTL(1h) / max-length(10k) 초과는 `drop-head` + 후속 스냅샷 RPC reconcile.
|
4. 전략 down 중 적체된 메시지는 catch-up 으로 복구; TTL(1h) / max-length(10k) 초과는 `drop-head` + 후속 스냅샷 RPC reconcile.
|
||||||
|
|
||||||
|
## Flow: `일일 스냅샷 refresh` (BAT-008 / REQ-030)
|
||||||
|
|
||||||
|
데몬 lifespan 의 `trex-snapshot-scheduler` 스레드가 portfolio-hub universe 를 fetch 해 in-memory 스냅샷을 atomic-swap 으로 교체하고, 같은 패스에서 정합 검증을 수행한다.
|
||||||
|
|
||||||
|
1. boot: `SnapshotScheduler.run_once()` 한 번 호출 → 첫 universe 로딩(또는 degraded 마킹).
|
||||||
|
2. tick(`TREX_SNAPSHOT_TICK_SEC`, 기본 60s): 현재 KST 시각이 `TREX_SNAPSHOT_SCHEDULE_KST`(기본 07:30) 이상이고 오늘 아직 안 돌았으면 fire.
|
||||||
|
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: `포지션 정합 검증` (BAT-008 / REQ-031)
|
||||||
|
|
||||||
|
`reconcile_positions` 는 portfolio-hub 보유 수량을 symbol 별로 합산해 broker `get_balance()` 결과와 비교한다.
|
||||||
|
|
||||||
|
1. `BrokerPort.get_balance() → list[BalanceItem(symbol, hldg_qty)]` 조회. KIS 어댑터는 `TTTC8434R`/`VTTC8434R` `inquire-balance`, paper 는 결정론 fill aggregate. **0수량 row 는 양쪽 모두 drop**(D-2 leftover 처리, Decision 7).
|
||||||
|
2. 자산을 `symbol` 로 그룹화해 `hub_total = sum(asset_quantity)` 계산 → broker 의 같은 symbol `hldg_qty` 와 비교.
|
||||||
|
3. 불일치: 해당 symbol 의 모든 portfolio_id 에 `SnapshotStore.set_warning(pid, True)` → 다음 envelope 부터 응답에서 마스킹. 첫 raise 시 한 번 로그.
|
||||||
|
4. 일치: 이전에 `warning` 이었던 자산은 자동 해제 → 다음 envelope 부터 다시 포함. resume 과 달리 자동·임시 overlay(영속 X).
|
||||||
|
|
||||||
|
## Flow: `pause/resume 제어` (BAT-008 / REQ-032)
|
||||||
|
|
||||||
|
운영자가 자산을 매매에서 제외/복귀시키는 두 경로(CLI + RPC)는 동일한 `snapshot_pause` Postgres 테이블을 가리킨다.
|
||||||
|
|
||||||
|
1. CLI: `trex-core pause <portfolio_id>` / `trex-core resume <portfolio_id>` → `PostgreSQLPauseStore.pause/resume` 호출(`portfolio_id` PK upsert/delete).
|
||||||
|
2. RPC: 전략 모듈/운영자가 `trex.pause.req` 큐에 `{action: pause|resume|list, portfolio_id, actor}` 발행 → `make_pause_rpc_handler` 가 같은 store 호출 → `{status, paused[], action}` 응답.
|
||||||
|
3. 응답 산출: `build_snapshot_envelope` 가 `pause_store.list_paused()` 를 매 요청마다 읽음 → `mask(pending | warning | pause)` 합성. resume 후 **다음 요청부터 즉시 복귀**(별도 reload 불필요).
|
||||||
|
4. 영속: daemon 재시작 + 일일 refresh 후에도 `snapshot_pause` row 유지(universe swap 은 pause 를 만지지 않음).
|
||||||
|
|
||||||
|
## Flow: `스냅샷 RPC 응답 산출` (BAT-008 / REQ-028, REQ-029)
|
||||||
|
|
||||||
|
`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`.
|
||||||
|
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).
|
||||||
|
- `strategy_key` 일치(`==`) 직교 필터.
|
||||||
|
- degraded 상태가 있으면 `degraded=true + reason + sanitized message` 동봉.
|
||||||
|
4. envelope: `{assets[], view, available_slots, degraded, reason, message}` — `assets` 는 항상 배열, null 금지. 항목 필드는 `symbol/asset_id/cash_balance/asset_quantity/active_strategy_id/strategy_params`, **price 필드는 없음**.
|
||||||
|
|
||||||
## Flow: `broker degrade 분기` (BAT-007 / REQ-024)
|
## Flow: `broker degrade 분기` (BAT-007 / REQ-024)
|
||||||
|
|
||||||
1. transport connection drop → 어댑터가 `_mark_disconnected()` + 백오프(1s→2s→4s→8s→16s→30s cap) 재연결.
|
1. transport connection drop → 어댑터가 `_mark_disconnected()` + 백오프(1s→2s→4s→8s→16s→30s cap) 재연결.
|
||||||
@@ -157,3 +197,4 @@
|
|||||||
- 2026-06-04 :: BAT-004 — 브로커 집행 레이어 반영(BrokerPort/paper·KIS 어댑터, execution 루프, broker_order_id 실행큐·멱등, reconciliation) (REQ-011~015).
|
- 2026-06-04 :: BAT-004 — 브로커 집행 레이어 반영(BrokerPort/paper·KIS 어댑터, execution 루프, broker_order_id 실행큐·멱등, reconciliation) (REQ-011~015).
|
||||||
- 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-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-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 배포 후 별도 구현).
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# req-033: 스냅샷 read 레이어 baseline 문서 정합
|
||||||
|
|
||||||
|
- Status: Implemented
|
||||||
|
- Type: Documentation
|
||||||
|
- Priority: Medium
|
||||||
|
- Owner: project maintainers
|
||||||
|
|
||||||
|
## Intent
|
||||||
|
- portfolio-hub read 포트·스냅샷 read 메시지 계약·일일 스냅샷 스케줄러·정합 검증을 baseline 문서에 정합시켜 문서-코드 일치를 유지한다.
|
||||||
|
|
||||||
|
## Requirement
|
||||||
|
- `docs/interface-contract.md` 를 read 포트(`PortfolioHubReadPort`)·스냅샷 read 메시지 계약(envelope·view·strategy_key·degraded)에 맞게 갱신한다.
|
||||||
|
- `docs/runtime-flows.md` 를 일일 스냅샷 조회 흐름·옵션 D 현금/포지션 갱신·정합 검증 흐름에 맞게 갱신한다.
|
||||||
|
- `docs/project-structure.md` 를 신규 `adapters/portfolio` read 어댑터·스냅샷 스케줄러 컴포넌트에 맞게 갱신한다.
|
||||||
|
- dcy-006 의 transport baseline 갱신(REQ-026)과 정합을 유지한다.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
1. Given REQ-027~032 구현, When baseline 3개 문서를 검토, Then read 포트·스냅샷 메시지 계약·스케줄러·정합 검증이 실제 코드와 정합한다.
|
||||||
|
2. Given 문서-코드 정합 점검, Then baseline 문서와 실제 포트/계약/흐름 사이 불일치가 0 이다.
|
||||||
|
|
||||||
|
## Impacted Area
|
||||||
|
- Docs: docs/interface-contract.md, docs/runtime-flows.md, docs/project-structure.md, docs/discovery/dcy-005_20260604_portfolio-hub-asset-snapshot-api.md
|
||||||
|
- Modules: none(문서)
|
||||||
|
- Tests: 문서-코드 정합 리뷰
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- dcy-005 FR-5 반영. dcy-006 transport baseline 과 정합.
|
||||||
|
- 보통 REQ-027~032 구현 batch 의 검증 단계에서 함께 마감한다.
|
||||||
|
|
||||||
|
## Change Log
|
||||||
|
|
||||||
|
### CHG-20260606-01
|
||||||
|
- Date: 2026-06-06
|
||||||
|
- Author: project maintainers
|
||||||
|
|
||||||
|
#### Change Summary
|
||||||
|
- dcy-005 FR-5 분해로 최초 생성(Proposed).
|
||||||
|
|
||||||
|
#### Intent
|
||||||
|
- read 레이어 baseline 문서 정합 작업 정의.
|
||||||
|
|
||||||
|
#### Acceptance Criteria Delta
|
||||||
|
- Added:
|
||||||
|
- AC-1~2 신규
|
||||||
|
- Updated:
|
||||||
|
- none
|
||||||
|
- Removed:
|
||||||
|
- none
|
||||||
|
|
||||||
|
#### Impacted Area Delta
|
||||||
|
- Docs:
|
||||||
|
- interface-contract, runtime-flows, project-structure, dcy-005
|
||||||
|
- Modules:
|
||||||
|
- none
|
||||||
|
- Tests:
|
||||||
|
- 문서-코드 정합 리뷰
|
||||||
|
|
||||||
|
#### Delivery Trace Delta
|
||||||
|
- Batches:
|
||||||
|
- none
|
||||||
|
- Releases:
|
||||||
|
- none
|
||||||
|
|
||||||
|
#### Revalidation Impact
|
||||||
|
- Reverification Needed: no
|
||||||
|
- Existing Implementation Invalidated: no
|
||||||
|
- Status Recommendation: keep-current
|
||||||
|
- Follow-up Action: confirm-req
|
||||||
|
|
||||||
|
#### Validation Plan
|
||||||
|
- baseline 문서 vs 실제 포트/계약/흐름 정합 리뷰로 AC 검증.
|
||||||
|
|
||||||
|
### CHG-20260606-02
|
||||||
|
- Date: 2026-06-06
|
||||||
|
- Author: project maintainers
|
||||||
|
|
||||||
|
#### Change Summary
|
||||||
|
- 승인 게이트 통과 → Proposed → Approved.
|
||||||
|
|
||||||
|
#### Acceptance Criteria Delta
|
||||||
|
- Added: none
|
||||||
|
- Updated: none
|
||||||
|
- Removed: none
|
||||||
|
|
||||||
|
#### Impacted Area Delta
|
||||||
|
- 변경 없음
|
||||||
|
|
||||||
|
#### Delivery Trace Delta
|
||||||
|
- Batches: none
|
||||||
|
- Releases: none
|
||||||
|
|
||||||
|
#### Revalidation Impact
|
||||||
|
- Reverification Needed: no
|
||||||
|
- Existing Implementation Invalidated: no
|
||||||
|
- Status Recommendation: keep-current
|
||||||
|
- Follow-up Action: suggest-batch-reqs / draft-batch
|
||||||
|
|
||||||
|
#### Validation Plan
|
||||||
|
- 승인 기록. REQ-027~032 구현 batch 검증 단계에서 함께 마감.
|
||||||
|
|
||||||
|
### CHG-20260606-03
|
||||||
|
- Date: 2026-06-06
|
||||||
|
- Author: Justin Ko
|
||||||
|
|
||||||
|
#### Change Summary
|
||||||
|
- Status 변경: Approved → Implemented. BAT-008 release-candidate verification evidence 기반.
|
||||||
|
|
||||||
|
#### Intent
|
||||||
|
- baseline 3 문서(runtime-flows / interface-contract / project-structure) 에 BAT-008 섹션 + Change Log 추가 완료 상태로 동기화.
|
||||||
|
|
||||||
|
#### Acceptance Criteria Delta
|
||||||
|
- Added/Updated/Removed: 없음
|
||||||
|
|
||||||
|
#### Impacted Area Delta
|
||||||
|
- Docs/Modules/Tests: 없음
|
||||||
|
|
||||||
|
#### Delivery Trace Delta
|
||||||
|
- Batches:
|
||||||
|
- BAT-008 (release-candidate)
|
||||||
|
- Releases:
|
||||||
|
- none
|
||||||
|
|
||||||
|
#### Revalidation Impact
|
||||||
|
- Reverification Needed: no
|
||||||
|
- Existing Implementation Invalidated: no
|
||||||
|
- Status Recommendation: Implemented
|
||||||
|
- Follow-up Action: release 단계는 별도 진행
|
||||||
|
|
||||||
|
#### Validation Plan
|
||||||
|
- AC-1: 3 baseline 문서 모두 BAT-008 섹션 + 2026-06-06 Change Log 항목 추가됨.
|
||||||
|
- AC-2: BAT-008 verification.md Manual 섹션의 baseline ↔ 코드 정합 리뷰 (필드 시그니처/디렉토리 트리/Protocol 동작 일치 확인).
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# req-031: 포지션 정합 검증 + warning 마스킹
|
||||||
|
|
||||||
|
- Status: Implemented
|
||||||
|
- Type: Exception
|
||||||
|
- Priority: High
|
||||||
|
- Owner: project maintainers
|
||||||
|
|
||||||
|
## Intent
|
||||||
|
- portfolio-hub 포지션이 실제 브로커 계좌 포지션과 어긋난 자산을 매매에서 보수적으로 제외해, 틀린 baseline 위에서 매매하는 위험을 막는다.
|
||||||
|
|
||||||
|
## Requirement
|
||||||
|
- 스냅샷 페치 시 각 자산에 대해 portfolio-hub 포지션과 실제 브로커 계좌 포지션을 **수량 정확 일치**(정수 shares, 허용 오차 없음)로 검증한다. 현금은 대상이 아니다(수량만).
|
||||||
|
- 브로커 포지션은 KIS 주식잔고조회(실전 `TTTC8434R` / 모의 `VTTC8434R`, `GET /uapi/domestic-stock/v1/trading/inquire-balance`)를 BrokerPort 잔고조회로 조회한다(항목당 `pdno`+`hldg_qty`). paper broker 는 결정론 stub.
|
||||||
|
- 브로커 잔고는 단일 계좌(불변식 B)라 symbol 단위 집계이므로, portfolio-hub per-portfolio 포지션을 symbol 로 합산해 broker per-symbol `hldg_qty` 와 대조한다.
|
||||||
|
- 불일치 자산은 `warning` 상태로 표시·응답에서 마스킹하고, 다음 정합 성공 시 자동 해제한다. warning raise 는 매번 로깅한다.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
1. Given symbol 집계 수량 불일치, When 정합 검증, Then 해당 자산은 `warning` 으로 표시되고 응답에서 마스킹된다.
|
||||||
|
2. Given 이전에 `warning` 인 자산이 다음 정합에서 수량 일치, When 재검증, Then `warning` 이 자동 해제되어 다시 응답에 포함된다.
|
||||||
|
3. Given paper broker, When 정합 검증, Then 결정론 잔고 stub 로 검증이 수행된다.
|
||||||
|
|
||||||
|
## Impacted Area
|
||||||
|
- Docs: docs/discovery/dcy-005_20260604_portfolio-hub-asset-snapshot-api.md
|
||||||
|
- Modules: BrokerPort 잔고조회(KIS 주식잔고조회) 확장, 정합 검증 + `warning` 상태 로직
|
||||||
|
- Tests: 수량 불일치 주입, warning 자동 해제, symbol 집계 대조
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- 정확 일치(tolerance 없음) — A-6(유일 매매 주체)상 정확 일치가 정상이며 tolerance 는 실제 이상을 가린다.
|
||||||
|
- 영속 hold 는 pause(REQ-032)로 분리(자동 warning=일시 / 수동 pause=영속).
|
||||||
|
- 당일 전량매도분이 `hldg_qty=0` 으로 D-2 까지 잔고에 남을 수 있음(KIS 015 문서) → 0 수량 잔고 처리 규칙은 설계 시 확정.
|
||||||
|
- 참고: docs/broker/kis/rest/국내주식/주문/계좌/015_v1_국내주식-006_주식잔고조회.md. OQ-8 반영.
|
||||||
|
|
||||||
|
## Change Log
|
||||||
|
|
||||||
|
### CHG-20260606-01
|
||||||
|
- Date: 2026-06-06
|
||||||
|
- Author: project maintainers
|
||||||
|
|
||||||
|
#### Change Summary
|
||||||
|
- dcy-005 FR-7 + OQ-8 분해로 최초 생성(Proposed).
|
||||||
|
|
||||||
|
#### Intent
|
||||||
|
- 포지션 정합 검증 + warning 마스킹/자동 해제 정의.
|
||||||
|
|
||||||
|
#### Acceptance Criteria Delta
|
||||||
|
- Added:
|
||||||
|
- AC-1~3 신규
|
||||||
|
- Updated:
|
||||||
|
- none
|
||||||
|
- Removed:
|
||||||
|
- none
|
||||||
|
|
||||||
|
#### Impacted Area Delta
|
||||||
|
- Docs:
|
||||||
|
- dcy-005
|
||||||
|
- Modules:
|
||||||
|
- BrokerPort 잔고조회, 정합/warning 로직
|
||||||
|
- Tests:
|
||||||
|
- 불일치 주입, 자동 해제
|
||||||
|
|
||||||
|
#### Delivery Trace Delta
|
||||||
|
- Batches:
|
||||||
|
- none
|
||||||
|
- Releases:
|
||||||
|
- none
|
||||||
|
|
||||||
|
#### Revalidation Impact
|
||||||
|
- Reverification Needed: no
|
||||||
|
- Existing Implementation Invalidated: no
|
||||||
|
- Status Recommendation: keep-current
|
||||||
|
- Follow-up Action: confirm-req
|
||||||
|
|
||||||
|
#### Validation Plan
|
||||||
|
- 불일치 주입 → warning 마스킹 → 일치 복귀 → 자동 해제 시퀀스 테스트로 AC 검증.
|
||||||
|
|
||||||
|
### CHG-20260606-02
|
||||||
|
- Date: 2026-06-06
|
||||||
|
- Author: project maintainers
|
||||||
|
|
||||||
|
#### Change Summary
|
||||||
|
- 승인 게이트 통과 → Proposed → Approved. D-2 0수량 잔고 처리 규칙은 설계 단계 확정(비블로커).
|
||||||
|
|
||||||
|
#### Acceptance Criteria Delta
|
||||||
|
- Added: none
|
||||||
|
- Updated: none
|
||||||
|
- Removed: none
|
||||||
|
|
||||||
|
#### Impacted Area Delta
|
||||||
|
- 변경 없음
|
||||||
|
|
||||||
|
#### Delivery Trace Delta
|
||||||
|
- Batches: none
|
||||||
|
- Releases: none
|
||||||
|
|
||||||
|
#### Revalidation Impact
|
||||||
|
- Reverification Needed: no
|
||||||
|
- Existing Implementation Invalidated: no
|
||||||
|
- Status Recommendation: keep-current
|
||||||
|
- Follow-up Action: suggest-batch-reqs / draft-batch
|
||||||
|
|
||||||
|
#### Validation Plan
|
||||||
|
- 승인 기록. 구현 전 재검증 불필요.
|
||||||
|
|
||||||
|
### CHG-20260606-03
|
||||||
|
- Date: 2026-06-06
|
||||||
|
- Author: Justin Ko
|
||||||
|
|
||||||
|
#### Change Summary
|
||||||
|
- Status 변경: Approved → Implemented. BAT-008 release-candidate verification evidence 기반.
|
||||||
|
|
||||||
|
#### Intent
|
||||||
|
- 정합 검증(`reconcile_positions`) + warning 자동 set/clear + `BrokerPort.get_balance()` 확장 구현 완료 상태로 동기화.
|
||||||
|
|
||||||
|
#### Acceptance Criteria Delta
|
||||||
|
- Added/Updated/Removed: 없음
|
||||||
|
|
||||||
|
#### Impacted Area Delta
|
||||||
|
- Docs/Modules/Tests: 없음
|
||||||
|
|
||||||
|
#### Delivery Trace Delta
|
||||||
|
- Batches:
|
||||||
|
- BAT-008 (release-candidate)
|
||||||
|
- Releases:
|
||||||
|
- none
|
||||||
|
|
||||||
|
#### Revalidation Impact
|
||||||
|
- Reverification Needed: no
|
||||||
|
- Existing Implementation Invalidated: no
|
||||||
|
- Status Recommendation: Implemented
|
||||||
|
- Follow-up Action: 실 KIS 환경에서 D-2 0수량 잔고 케이스 관찰 후 운영 노트화 권장 (별도 작업).
|
||||||
|
|
||||||
|
#### Validation Plan
|
||||||
|
- AC-1: tests/application/test_reconciliation.py::test_mismatch_raises_warning_and_masks_asset.
|
||||||
|
- AC-2: ::test_subsequent_match_auto_clears_warning.
|
||||||
|
- AC-3: ::test_paper_broker_returns_deterministic_balance.
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# req-027: portfolio-hub read 포트 + 어댑터 + stub
|
||||||
|
|
||||||
|
- Status: Implemented
|
||||||
|
- Type: Integration
|
||||||
|
- Priority: High
|
||||||
|
- Owner: project maintainers
|
||||||
|
|
||||||
|
## Intent
|
||||||
|
- 전략 모듈에 제공할 종목 스냅샷의 원천인 portfolio-hub universe(자신이 관리하는 portfolio 집합)를 trex-core 가 read 할 경로를 만든다. dcy-004 write 어댑터/자격증명을 재사용하고 도메인 순수성을 유지한다.
|
||||||
|
|
||||||
|
## Requirement
|
||||||
|
- `PortfolioHubReadPort.get_portfolios(daemon_id)` application 포트 + portfolio-hub read 어댑터(`managed_by_daemon_id` 스코프 조회, portfolio-hub `list_portfolios_v1`) + 결정론 stub 를 제공한다.
|
||||||
|
- 어댑터는 dcy-004 의 portfolio-hub 자격증명(.env `TREX_PORTFOLIO_HUB_*`)을 재사용한다.
|
||||||
|
- `daemon_id` 는 portfolio-hub 자격증명/인증 토큰에서 도출한다(단일 진실원). 별도 `TREX_DAEMON_ID` 설정값을 1차 출처로 두지 않으며, 필요 시 whoami 1회로 도출·캐시한다.
|
||||||
|
- 각 universe 항목은 symbol, asset_id(=portfolio_id), cash_balance, asset_quantity, active_strategy_id, 전략파라미터(opaque JSON)를 담고 가격(price)은 제외한다. 전략파라미터는 trex-core 가 해석하지 않고 통과시킨다.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
1. Given daemon 자격증명/토큰, When `get_portfolios` 를 호출, Then `managed_by_daemon_id == 토큰 daemon_id` 인 portfolio 집합을 항목당 symbol/asset_id/cash_balance/asset_quantity/active_strategy_id/strategy_params(opaque) 형태로 반환하고 가격 필드는 없다.
|
||||||
|
2. Given 결정론 stub 어댑터, When 호출, Then 네트워크 없이 고정된 universe 를 반환한다.
|
||||||
|
3. Given import 스윕, When `trex_core/domain` 을 검사, Then portfolio-hub/네트워크 의존이 0 이다(NFR-1).
|
||||||
|
4. Given `TREX_DAEMON_ID` 미설정, When daemon 기동·universe 조회, Then 토큰에서 도출한 daemon_id 로 동작한다(OQ-7).
|
||||||
|
|
||||||
|
## Impacted Area
|
||||||
|
- Docs: docs/discovery/dcy-005_20260604_portfolio-hub-asset-snapshot-api.md, docs/interface-contract.md
|
||||||
|
- Modules: trex_core/application (read 포트), trex_core/adapters/portfolio (read 어댑터 + stub), trex_core/runtime/config (daemon_id 토큰 도출)
|
||||||
|
- Tests: stub read 통합테스트, 도메인 import 스윕, daemon_id 토큰 도출
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- 가정: portfolio-hub `list_portfolios_v1`(`GET`)이 `managed_by_daemon_id=auth.daemon_id` 로 스코프 조회를 제공하고 `PortfolioItem` 에 cash_balance/asset_quantity 포함(confirm-discovery 에서 검증). 전략파라미터는 portfolio-hub `strategy_params` JSONB(dcy-005 A-5).
|
||||||
|
- NFR-1(도메인 순수성)을 본 REQ 의 AC-3 로 흡수한다.
|
||||||
|
|
||||||
|
## Change Log
|
||||||
|
|
||||||
|
### CHG-20260606-01
|
||||||
|
- Date: 2026-06-06
|
||||||
|
- Author: project maintainers
|
||||||
|
|
||||||
|
#### Change Summary
|
||||||
|
- dcy-005 FR-4 + OQ-7 분해로 최초 생성(Proposed). NFR-1 흡수.
|
||||||
|
|
||||||
|
#### Intent
|
||||||
|
- portfolio-hub universe read 경로를 포트/어댑터/stub 로 정의.
|
||||||
|
|
||||||
|
#### Acceptance Criteria Delta
|
||||||
|
- Added:
|
||||||
|
- AC-1~4 신규
|
||||||
|
- Updated:
|
||||||
|
- none
|
||||||
|
- Removed:
|
||||||
|
- none
|
||||||
|
|
||||||
|
#### Impacted Area Delta
|
||||||
|
- Docs:
|
||||||
|
- dcy-005, interface-contract 참조
|
||||||
|
- Modules:
|
||||||
|
- application read 포트, adapters/portfolio
|
||||||
|
- Tests:
|
||||||
|
- stub read 통합테스트, import 스윕
|
||||||
|
|
||||||
|
#### Delivery Trace Delta
|
||||||
|
- Batches:
|
||||||
|
- none (미배치)
|
||||||
|
- Releases:
|
||||||
|
- none
|
||||||
|
|
||||||
|
#### Revalidation Impact
|
||||||
|
- Reverification Needed: no
|
||||||
|
- Existing Implementation Invalidated: no
|
||||||
|
- Status Recommendation: keep-current
|
||||||
|
- Follow-up Action: confirm-req 로 승인 게이트 점검
|
||||||
|
|
||||||
|
#### Validation Plan
|
||||||
|
- stub read 통합테스트 + 도메인 import 스윕으로 AC 검증.
|
||||||
|
|
||||||
|
### CHG-20260606-02
|
||||||
|
- Date: 2026-06-06
|
||||||
|
- Author: project maintainers
|
||||||
|
|
||||||
|
#### Change Summary
|
||||||
|
- 승인 게이트 통과 → Proposed → Approved.
|
||||||
|
|
||||||
|
#### Acceptance Criteria Delta
|
||||||
|
- Added: none
|
||||||
|
- Updated: none
|
||||||
|
- Removed: none
|
||||||
|
|
||||||
|
#### Impacted Area Delta
|
||||||
|
- 변경 없음
|
||||||
|
|
||||||
|
#### Delivery Trace Delta
|
||||||
|
- Batches: none
|
||||||
|
- Releases: none
|
||||||
|
|
||||||
|
#### Revalidation Impact
|
||||||
|
- Reverification Needed: no
|
||||||
|
- Existing Implementation Invalidated: no
|
||||||
|
- Status Recommendation: keep-current
|
||||||
|
- Follow-up Action: suggest-batch-reqs / draft-batch
|
||||||
|
|
||||||
|
#### Validation Plan
|
||||||
|
- 승인 기록. 구현 전 재검증 불필요.
|
||||||
|
|
||||||
|
### CHG-20260606-03
|
||||||
|
- Date: 2026-06-06
|
||||||
|
- Author: Justin Ko
|
||||||
|
|
||||||
|
#### Change Summary
|
||||||
|
- Status 변경: Approved → Implemented. BAT-008 release-candidate verification evidence 기반.
|
||||||
|
|
||||||
|
#### Intent
|
||||||
|
- `PortfolioHubReadPort` + 어댑터(`PortfolioHubReadAdapter`, `StubPortfolioRead`) 구현과 검증 evidence 확보 후 구현 완료 상태로 동기화.
|
||||||
|
|
||||||
|
#### Acceptance Criteria Delta
|
||||||
|
- Added/Updated/Removed: 없음
|
||||||
|
|
||||||
|
#### Impacted Area Delta
|
||||||
|
- Docs/Modules/Tests: 없음
|
||||||
|
|
||||||
|
#### Delivery Trace Delta
|
||||||
|
- Batches:
|
||||||
|
- BAT-008 (release-candidate)
|
||||||
|
- Releases:
|
||||||
|
- none
|
||||||
|
|
||||||
|
#### Revalidation Impact
|
||||||
|
- Reverification Needed: no
|
||||||
|
- Existing Implementation Invalidated: no
|
||||||
|
- Status Recommendation: Implemented
|
||||||
|
- Follow-up Action: release 단계는 별도 진행
|
||||||
|
|
||||||
|
#### Validation Plan
|
||||||
|
- AC-1: tests/adapters/portfolio/test_hub_read.py::test_get_portfolios_scoped_by_managed_by_daemon_id + test_stub_read.py.
|
||||||
|
- AC-2: tests/adapters/portfolio/test_stub_read.py 전체.
|
||||||
|
- AC-3: tests/domain/test_domain_import_sweep.py.
|
||||||
|
- AC-4: tests/adapters/portfolio/test_hub_read.py::test_whoami_returns_token_daemon_id.
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# req-030: 일일 스냅샷 스케줄러 + 옵션 D 현금/포지션 갱신
|
||||||
|
|
||||||
|
- Status: Approved
|
||||||
|
- Type: Integration
|
||||||
|
- Priority: High
|
||||||
|
- Owner: project maintainers
|
||||||
|
|
||||||
|
## Intent
|
||||||
|
- universe 멤버십·전략파라미터는 일일 스냅샷으로 고정하고, 현금/포지션은 portfolio-hub 가 ledger write 응답으로 돌려주는 권위 값으로 유지해 로컬 누적 보정 drift 를 제거한다(옵션 D).
|
||||||
|
|
||||||
|
## Requirement
|
||||||
|
- 설정 가능한 시각(기본 07:30 KST, .env)에 스케줄러(신규 daemon 컴포넌트)가 `daemon_id` universe 를 페치해 스냅샷을 갱신한다. universe 멤버십·전략파라미터는 그 일일 스냅샷으로 고정한다.
|
||||||
|
- 현금/포지션은 스냅샷 baseline 에 더해, 체결 시 portfolio-hub ledger write 응답의 `cash_balance_after` / `asset_quantity_after` 권위 값으로 해당 자산을 갱신한다.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
1. Given 설정 시각 도래, When 스케줄러 tick, Then universe 를 페치해 스냅샷(멤버십·전략파라미터·baseline 현금/포지션)을 갱신한다.
|
||||||
|
2. Given 체결 후 ledger write 응답이 `*_after` 를 반환, When 처리, Then 해당 자산의 현금/포지션을 그 권위 값으로 갱신한다.
|
||||||
|
3. Given `duplicate` write 응답(idempotent), When 처리, Then 현재 권위 `*_after` 값으로 동일하게 갱신한다.
|
||||||
|
|
||||||
|
## Impacted Area
|
||||||
|
- Docs: docs/discovery/dcy-005_20260604_portfolio-hub-asset-snapshot-api.md, docs/runtime-flows.md
|
||||||
|
- Modules: 일일 스냅샷 스케줄러(런타임 신규), ledger write 응답 소비/스냅샷 갱신 로직, config(스냅샷 페치 시각)
|
||||||
|
- Tests: 스케줄 tick 페치, `*_after` 갱신, duplicate idempotent 갱신
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- **의존: portfolio-hub#8 — ledger write 응답에 `cash_balance_after`/`asset_quantity_after` 반환(회계 단일 트랜잭션). portfolio-hub 배포(REL-003 등) 전까지 본 REQ 의 옵션 D 갱신 구현은 대기(dcy-005 A-7).** 그 전까지는 baseline + 스케줄러 페치까지만 선구현 가능.
|
||||||
|
- A-6(유일 매매 주체·장중 외부 변동 없음) 전제.
|
||||||
|
- NFR-2(일관성)를 본 REQ 로 흡수한다.
|
||||||
|
|
||||||
|
## Change Log
|
||||||
|
|
||||||
|
### CHG-20260606-01
|
||||||
|
- Date: 2026-06-06
|
||||||
|
- Author: project maintainers
|
||||||
|
|
||||||
|
#### Change Summary
|
||||||
|
- dcy-005 FR-6 + OQ-2 분해로 최초 생성(Proposed). NFR-2 흡수.
|
||||||
|
|
||||||
|
#### Intent
|
||||||
|
- 일일 스냅샷 스케줄러 + 옵션 D 현금/포지션 갱신 정의.
|
||||||
|
|
||||||
|
#### Acceptance Criteria Delta
|
||||||
|
- Added:
|
||||||
|
- AC-1~3 신규
|
||||||
|
- Updated:
|
||||||
|
- none
|
||||||
|
- Removed:
|
||||||
|
- none
|
||||||
|
|
||||||
|
#### Impacted Area Delta
|
||||||
|
- Docs:
|
||||||
|
- dcy-005, runtime-flows
|
||||||
|
- Modules:
|
||||||
|
- 스냅샷 스케줄러, write 응답 소비 로직, config
|
||||||
|
- Tests:
|
||||||
|
- 스케줄 tick, *_after 갱신
|
||||||
|
|
||||||
|
#### Delivery Trace Delta
|
||||||
|
- Batches:
|
||||||
|
- none
|
||||||
|
- Releases:
|
||||||
|
- none
|
||||||
|
|
||||||
|
#### Revalidation Impact
|
||||||
|
- Reverification Needed: no
|
||||||
|
- Existing Implementation Invalidated: no
|
||||||
|
- Status Recommendation: keep-current
|
||||||
|
- Follow-up Action: portfolio-hub#8 배포 추적 후 옵션 D 구현 착수
|
||||||
|
|
||||||
|
#### Validation Plan
|
||||||
|
- 스케줄 tick 페치 + `*_after` 갱신/idempotent 갱신 테스트로 AC 검증.
|
||||||
|
|
||||||
|
### CHG-20260606-02
|
||||||
|
- Date: 2026-06-06
|
||||||
|
- Author: project maintainers
|
||||||
|
|
||||||
|
#### Change Summary
|
||||||
|
- 승인 게이트 통과 → Proposed → Approved. 옵션 D 구현은 portfolio-hub#8 배포 후 착수(Notes 의존 유지, 비블로커).
|
||||||
|
|
||||||
|
#### Acceptance Criteria Delta
|
||||||
|
- Added: none
|
||||||
|
- Updated: none
|
||||||
|
- Removed: none
|
||||||
|
|
||||||
|
#### Impacted Area Delta
|
||||||
|
- 변경 없음
|
||||||
|
|
||||||
|
#### Delivery Trace Delta
|
||||||
|
- Batches: none
|
||||||
|
- Releases: none
|
||||||
|
|
||||||
|
#### Revalidation Impact
|
||||||
|
- Reverification Needed: no
|
||||||
|
- Existing Implementation Invalidated: no
|
||||||
|
- Status Recommendation: keep-current
|
||||||
|
- Follow-up Action: suggest-batch-reqs / draft-batch; 옵션 D 구현은 portfolio-hub#8 배포 트래킹 후
|
||||||
|
|
||||||
|
#### Validation Plan
|
||||||
|
- 승인 기록. 스케줄러 baseline 구현 → portfolio-hub#8 배포 후 옵션 D 갱신 통합 검증.
|
||||||
|
|
||||||
|
### CHG-20260606-03
|
||||||
|
- Date: 2026-06-06
|
||||||
|
- Author: Justin Ko
|
||||||
|
|
||||||
|
#### Change Summary
|
||||||
|
- Status 유지: Approved. BAT-008 에서 AC-1(일일 스냅샷 스케줄러 baseline)은 구현 완료 + evidence 확보, AC-2/AC-3(옵션 D `*_after` 갱신)은 외부 의존(portfolio-hub#8) 배포 대기로 **deferred**. 본 REQ 는 AC 일부 충족이므로 `Implemented` 전환을 보류한다(SRS `Status Transition Evidence` 의 "미충족 Acceptance Criteria 가 있으면 Implemented 로 전환하지 않는다" 규정 준수).
|
||||||
|
|
||||||
|
#### Intent
|
||||||
|
- 스케줄러 baseline 부분 구현 완료를 추적 + 옵션 D 후속 작업의 wiring point 가 노출되어 있음을 기록.
|
||||||
|
|
||||||
|
#### Acceptance Criteria Delta
|
||||||
|
- Added/Updated/Removed: 없음
|
||||||
|
|
||||||
|
#### Impacted Area Delta
|
||||||
|
- Docs/Modules/Tests: 없음 (AC-1 baseline 은 BAT-008 에서 구현 + 검증 완료, AC-2/AC-3 wiring point 노출만 추가)
|
||||||
|
|
||||||
|
#### Delivery Trace Delta
|
||||||
|
- Batches:
|
||||||
|
- BAT-008 (release-candidate; AC-1 evidence)
|
||||||
|
- Releases:
|
||||||
|
- none
|
||||||
|
|
||||||
|
#### Revalidation Impact
|
||||||
|
- Reverification Needed: yes (portfolio-hub#8 배포 후 AC-2/AC-3 통합 검증)
|
||||||
|
- Existing Implementation Invalidated: no
|
||||||
|
- Status Recommendation: keep-current (Approved 유지)
|
||||||
|
- Follow-up Action: portfolio-hub#8 배포 완료 알림 수신 시 별도 minor-change batch 로 옵션 D 구현 (LedgerWriter 의 ledger write 응답 소비 경로에서 `cash_balance_after`/`asset_quantity_after` → `SnapshotStore.update_cash_position(...)` 호출 1줄 추가) + AC-2/AC-3 통합 evidence 확보 → REQ-030 Approved → Implemented 전환.
|
||||||
|
|
||||||
|
#### Validation Plan
|
||||||
|
- AC-1 (현재 완료): tests/application/test_snapshot_scheduler.py::test_run_once_fetches_universe_and_swaps_store + ::test_swap_preserves_warning_for_carried_over_portfolio + 실 daemon boot 후 첫 fetch 동작 확인.
|
||||||
|
- AC-2 (deferred): portfolio-hub#8 배포 후 ledger write 응답 소비 경로에 wiring 연결 + integration 테스트.
|
||||||
|
- AC-3 (deferred): duplicate write 응답 idempotent 갱신 테스트.
|
||||||
|
|
||||||
|
#### Notes
|
||||||
|
- BAT-008 verification.md `Human Approval Memo` 에 deferred residual risk 로 명시되어 있고, batch 전체는 release-candidate 로 promote 됨.
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
# req-028: 종목 스냅샷 read 메시지 계약 + envelope + degraded 신호
|
||||||
|
|
||||||
|
- Status: Implemented
|
||||||
|
- Type: Interface
|
||||||
|
- Priority: High
|
||||||
|
- Owner: project maintainers
|
||||||
|
|
||||||
|
## Intent
|
||||||
|
- 전략 모듈이 dcy-006 snapshot RPC 로 종목 스냅샷을 받는 요청/응답 계약(transport-무관 logical)을 정의한다. 빈 응답과 가용성 degrade 의 시맨틱을 모호하지 않게 한다.
|
||||||
|
|
||||||
|
## Requirement
|
||||||
|
- 스냅샷 read 는 dcy-006 의 `trex.snapshot.req` request-reply RPC 위에서 동작한다. 요청 payload 는 선택적 `strategy_key`·`view` 를 가진다.
|
||||||
|
- 응답은 **envelope** 다: `assets` 는 항상 배열(비면 `[]`, null 금지)이고, meta 로 `view`(에코), `available_slots`, `degraded`, `reason` 을 포함한다.
|
||||||
|
- 각 자산 항목은 symbol, asset_id, cash_balance, asset_quantity, active_strategy_id, 전략파라미터(opaque)를 담고 가격은 제외한다.
|
||||||
|
- portfolio-hub 가 불가해 권위 cash/position 을 유지할 수 없으면(write `*_after` 갱신 불가 또는 일일 fetch 실패) 응답에 `degraded=true` + machine-readable reason code(예: `portfolio_hub_unavailable`/`snapshot_fetch_failed`/`cash_position_stale`) + sanitize 된 message 를 실어 전략의 신규 매매 보류를 신호한다(빈 리스트/silent stale 금지).
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
1. Given 정상 상태, When 스냅샷을 요청, Then `assets`(배열) + meta(`view`·`available_slots`·`degraded`·`reason`) envelope 를 반환하고, 자산이 없어도 `assets` 는 `[]`(null 아님)다.
|
||||||
|
2. Given portfolio-hub 불가(권위 cash/position 유지 불가), When 요청, Then `degraded=true` + reason code 가 포함되고 raw 예외/자격증명 등 민감정보는 노출되지 않는다.
|
||||||
|
3. Given 응답 항목, Then 필드는 symbol/asset_id/cash_balance/asset_quantity/active_strategy_id/strategy_params(opaque) 이고 가격 필드는 없다.
|
||||||
|
|
||||||
|
## Impacted Area
|
||||||
|
- Docs: docs/discovery/dcy-005_20260604_portfolio-hub-asset-snapshot-api.md, docs/interface-contract.md
|
||||||
|
- Modules: trex_core/adapters/messaging (snapshot RPC 핸들러), 응답 스키마(transport-무관)
|
||||||
|
- Tests: envelope 스키마 검증(빈 응답 포함), degraded 주입 테스트
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- transport 는 dcy-006(REQ-021/022) 의 snapshot RPC 를 사용하며 본 REQ 는 logical 계약만 정의한다.
|
||||||
|
- NFR-3(가용성 degrade, OQ-5) + OQ-6(빈 표현)을 본 REQ 로 흡수/반영한다.
|
||||||
|
- 정확한 reason code enum/필드 스키마는 구현 단계에서 확정한다(dcy-005 OQ-5/OQ-6 결정 범위 내).
|
||||||
|
|
||||||
|
## Change Log
|
||||||
|
|
||||||
|
### CHG-20260606-01
|
||||||
|
- Date: 2026-06-06
|
||||||
|
- Author: project maintainers
|
||||||
|
|
||||||
|
#### Change Summary
|
||||||
|
- dcy-005 FR-1/FR-3 + OQ-5/OQ-6 분해로 최초 생성(Proposed). NFR-3 흡수.
|
||||||
|
|
||||||
|
#### Intent
|
||||||
|
- 스냅샷 read 의 요청/응답 envelope 계약 + degrade 신호 정의.
|
||||||
|
|
||||||
|
#### Acceptance Criteria Delta
|
||||||
|
- Added:
|
||||||
|
- AC-1~3 신규
|
||||||
|
- Updated:
|
||||||
|
- none
|
||||||
|
- Removed:
|
||||||
|
- none
|
||||||
|
|
||||||
|
#### Impacted Area Delta
|
||||||
|
- Docs:
|
||||||
|
- dcy-005, interface-contract
|
||||||
|
- Modules:
|
||||||
|
- adapters/messaging snapshot RPC 핸들러, 응답 스키마
|
||||||
|
- Tests:
|
||||||
|
- envelope 스키마, degraded 주입
|
||||||
|
|
||||||
|
#### Delivery Trace Delta
|
||||||
|
- Batches:
|
||||||
|
- none
|
||||||
|
- Releases:
|
||||||
|
- none
|
||||||
|
|
||||||
|
#### Revalidation Impact
|
||||||
|
- Reverification Needed: no
|
||||||
|
- Existing Implementation Invalidated: no
|
||||||
|
- Status Recommendation: keep-current
|
||||||
|
- Follow-up Action: confirm-req
|
||||||
|
|
||||||
|
#### Validation Plan
|
||||||
|
- envelope 스키마 검증 + portfolio-hub 불가 주입으로 degraded AC 검증.
|
||||||
|
|
||||||
|
### CHG-20260606-02
|
||||||
|
- Date: 2026-06-06
|
||||||
|
- Author: project maintainers
|
||||||
|
|
||||||
|
#### Change Summary
|
||||||
|
- 승인 게이트 통과 → Proposed → Approved.
|
||||||
|
|
||||||
|
#### Acceptance Criteria Delta
|
||||||
|
- Added: none
|
||||||
|
- Updated: none
|
||||||
|
- Removed: none
|
||||||
|
|
||||||
|
#### Impacted Area Delta
|
||||||
|
- 변경 없음
|
||||||
|
|
||||||
|
#### Delivery Trace Delta
|
||||||
|
- Batches: none
|
||||||
|
- Releases: none
|
||||||
|
|
||||||
|
#### Revalidation Impact
|
||||||
|
- Reverification Needed: no
|
||||||
|
- Existing Implementation Invalidated: no
|
||||||
|
- Status Recommendation: keep-current
|
||||||
|
- Follow-up Action: suggest-batch-reqs / draft-batch
|
||||||
|
|
||||||
|
#### Validation Plan
|
||||||
|
- 승인 기록. 구현 전 재검증 불필요.
|
||||||
|
|
||||||
|
### CHG-20260606-03
|
||||||
|
- Date: 2026-06-06
|
||||||
|
- Author: Justin Ko
|
||||||
|
|
||||||
|
#### Change Summary
|
||||||
|
- Status 변경: Approved → Implemented. BAT-008 release-candidate verification evidence 기반.
|
||||||
|
|
||||||
|
#### Intent
|
||||||
|
- snapshot RPC envelope 응답 + degraded 신호 + sanitization 구현 완료 상태로 동기화.
|
||||||
|
|
||||||
|
#### Acceptance Criteria Delta
|
||||||
|
- Added/Updated/Removed: 없음
|
||||||
|
|
||||||
|
#### Impacted Area Delta
|
||||||
|
- Docs/Modules/Tests: 없음
|
||||||
|
|
||||||
|
#### Delivery Trace Delta
|
||||||
|
- Batches:
|
||||||
|
- BAT-008 (release-candidate)
|
||||||
|
- Releases:
|
||||||
|
- none
|
||||||
|
|
||||||
|
#### Revalidation Impact
|
||||||
|
- Reverification Needed: no
|
||||||
|
- Existing Implementation Invalidated: no
|
||||||
|
- Status Recommendation: Implemented
|
||||||
|
- Follow-up Action: release 단계는 별도 진행
|
||||||
|
|
||||||
|
#### Validation Plan
|
||||||
|
- AC-1: tests/application/test_snapshot_handler.py::test_envelope_shape_returned + test_snapshot_selection.py::test_empty_envelope_assets_is_list_not_null.
|
||||||
|
- AC-2: tests/application/test_snapshot_handler.py::test_degraded_signal_does_not_leak_credentials + 실 daemon 운영 검증.
|
||||||
|
- AC-3: tests/application/test_snapshot_selection.py::test_envelope_shape_no_price_field.
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# req-029: 자산 선별(slot overlay) + view 모드 + strategy_key 필터
|
||||||
|
|
||||||
|
- Status: Implemented
|
||||||
|
- Type: Interface
|
||||||
|
- Priority: High
|
||||||
|
- Owner: project maintainers
|
||||||
|
|
||||||
|
## Intent
|
||||||
|
- 스냅샷 응답에 담을 자산 집합을 core 의 권위 슬롯 상태로 선별한다. 보유(holding)는 포함하되 일시·불안전 상태는 마스킹하고, 전략 타입별 view 와 전략별 필터를 제공한다.
|
||||||
|
|
||||||
|
## Requirement
|
||||||
|
- 응답 base 집합은 요청 `view`(`all` 기본 | `buyable` | `held`)로 선택한다: `all` = universe 전체(portfolio-hub 보유 + holding 슬롯), `buyable` = universe − 슬롯 점유, `held` = holding 슬롯 자산만.
|
||||||
|
- 마스킹(pending·warning·pause)은 전 모드에 균일 적용한다. pending 은 모든 모드에서 마스킹되므로 `held` 는 holding 만 포함한다. 포함/마스킹 판정은 core 권위 슬롯 상태 + 정합 결과 + pause 상태를 portfolio-hub 보유 여부보다 우선한다(slot overlay, 불변식 A).
|
||||||
|
- 선택적 `strategy_key`(요청 시) 는 자산의 단일 `active_strategy_id` 와 대소문자 구분 정확 일치로 직교 필터링한다. `strategy_key` 는 사람이 읽는 안정 유일 slug(예: `target-value-v1`)다.
|
||||||
|
- 최종 합성: `assets = base(view) − mask(pending·warning·pause) − (strategy_key 필터, 주어진 경우)`. `buyable` 은 `available_slots=0` 이면 `[]` 다.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
1. Given 같은 종목이 holding 슬롯·pending 슬롯에 각각 있을 때, When `view=all`, Then holding 자산은 포함되고 pending 자산은 마스킹된다.
|
||||||
|
2. Given `available_slots=0`, When `view=buyable`, Then `[]` 를 반환한다.
|
||||||
|
3. Given `view=held`, Then holding 슬롯 자산만 반환하고 pending 은 제외한다.
|
||||||
|
4. Given `strategy_key` 제공, Then `active_strategy_id` 가 일치하는 자산만 반환하고, 생략 시 (마스킹 후) 전체 자산을 반환한다.
|
||||||
|
5. Given warning 또는 pause 자산, When 임의 `view`, Then 해당 자산은 마스킹된다.
|
||||||
|
|
||||||
|
## Impacted Area
|
||||||
|
- Docs: docs/discovery/dcy-005_20260604_portfolio-hub-asset-snapshot-api.md
|
||||||
|
- Modules: trex_core/application 스냅샷 선별/slot overlay 로직(slot repository 재사용), strategy_key 필터
|
||||||
|
- Tests: view 모드별·마스킹·strategy_key 필터 합성 테스트
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- core 권위 슬롯 상태(불변식 A)가 portfolio-hub 보유 여부보다 우선한다.
|
||||||
|
- OQ-4(in-flight 합성)·OQ-9(strategy_key)·OQ-11/OQ-12(view 모드) 반영.
|
||||||
|
- holding 자산의 보유 수량 표기를 core 슬롯 vs 스냅샷 중 무엇 기준으로 할지(holding→ledger flush 갭)는 설계 시 확정(dcy-005 OQ-4 잔여).
|
||||||
|
|
||||||
|
## Change Log
|
||||||
|
|
||||||
|
### CHG-20260606-01
|
||||||
|
- Date: 2026-06-06
|
||||||
|
- Author: project maintainers
|
||||||
|
|
||||||
|
#### Change Summary
|
||||||
|
- dcy-005 FR-2/FR-8 + OQ-4/9/11/12 분해로 최초 생성(Proposed).
|
||||||
|
|
||||||
|
#### Intent
|
||||||
|
- 응답 자산 선별(slot overlay·view·strategy_key) 규칙 정의.
|
||||||
|
|
||||||
|
#### Acceptance Criteria Delta
|
||||||
|
- Added:
|
||||||
|
- AC-1~5 신규
|
||||||
|
- Updated:
|
||||||
|
- none
|
||||||
|
- Removed:
|
||||||
|
- none
|
||||||
|
|
||||||
|
#### Impacted Area Delta
|
||||||
|
- Docs:
|
||||||
|
- dcy-005
|
||||||
|
- Modules:
|
||||||
|
- 스냅샷 선별/slot overlay, strategy_key 필터
|
||||||
|
- Tests:
|
||||||
|
- view/마스킹/필터 테스트
|
||||||
|
|
||||||
|
#### Delivery Trace Delta
|
||||||
|
- Batches:
|
||||||
|
- none
|
||||||
|
- Releases:
|
||||||
|
- none
|
||||||
|
|
||||||
|
#### Revalidation Impact
|
||||||
|
- Reverification Needed: no
|
||||||
|
- Existing Implementation Invalidated: no
|
||||||
|
- Status Recommendation: keep-current
|
||||||
|
- Follow-up Action: confirm-req
|
||||||
|
|
||||||
|
#### Validation Plan
|
||||||
|
- 슬롯 상태 주입 후 view/마스킹/strategy_key 합성 테스트로 AC 검증.
|
||||||
|
|
||||||
|
### CHG-20260606-02
|
||||||
|
- Date: 2026-06-06
|
||||||
|
- Author: project maintainers
|
||||||
|
|
||||||
|
#### Change Summary
|
||||||
|
- 승인 게이트 통과 → Proposed → Approved.
|
||||||
|
|
||||||
|
#### Acceptance Criteria Delta
|
||||||
|
- Added: none
|
||||||
|
- Updated: none
|
||||||
|
- Removed: none
|
||||||
|
|
||||||
|
#### Impacted Area Delta
|
||||||
|
- 변경 없음
|
||||||
|
|
||||||
|
#### Delivery Trace Delta
|
||||||
|
- Batches: none
|
||||||
|
- Releases: none
|
||||||
|
|
||||||
|
#### Revalidation Impact
|
||||||
|
- Reverification Needed: no
|
||||||
|
- Existing Implementation Invalidated: no
|
||||||
|
- Status Recommendation: keep-current
|
||||||
|
- Follow-up Action: suggest-batch-reqs / draft-batch
|
||||||
|
|
||||||
|
#### Validation Plan
|
||||||
|
- 승인 기록. 구현 전 재검증 불필요.
|
||||||
|
|
||||||
|
### CHG-20260606-03
|
||||||
|
- Date: 2026-06-06
|
||||||
|
- Author: Justin Ko
|
||||||
|
|
||||||
|
#### Change Summary
|
||||||
|
- Status 변경: Approved → Implemented. BAT-008 release-candidate verification evidence 기반.
|
||||||
|
|
||||||
|
#### Intent
|
||||||
|
- `build_snapshot_envelope` 의 base(view) 산출 + masking OR 합성 + strategy_key 직교 필터 구현 완료 상태로 동기화.
|
||||||
|
|
||||||
|
#### Acceptance Criteria Delta
|
||||||
|
- Added/Updated/Removed: 없음
|
||||||
|
|
||||||
|
#### Impacted Area Delta
|
||||||
|
- Docs/Modules/Tests: 없음
|
||||||
|
|
||||||
|
#### Delivery Trace Delta
|
||||||
|
- Batches:
|
||||||
|
- BAT-008 (release-candidate)
|
||||||
|
- Releases:
|
||||||
|
- none
|
||||||
|
|
||||||
|
#### Revalidation Impact
|
||||||
|
- Reverification Needed: no
|
||||||
|
- Existing Implementation Invalidated: no
|
||||||
|
- Status Recommendation: Implemented
|
||||||
|
- Follow-up Action: release 단계는 별도 진행
|
||||||
|
|
||||||
|
#### Validation Plan
|
||||||
|
- AC-1: tests/application/test_snapshot_selection.py::test_view_all_includes_holding_excludes_pending_for_same_symbol.
|
||||||
|
- AC-2: ::test_view_buyable_with_zero_available_slots_returns_empty.
|
||||||
|
- AC-3: ::test_view_held_returns_only_holding_slot_assets.
|
||||||
|
- AC-4: ::test_strategy_key_orthogonal_filter + ::test_strategy_key_missing_includes_everything_unmasked.
|
||||||
|
- AC-5: ::test_warning_overlay_masks_asset_uniformly_across_views + ::test_pause_overlay_masks_asset.
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# req-032: 런타임 pause/resume 제어
|
||||||
|
|
||||||
|
- Status: Implemented
|
||||||
|
- Type: Interface
|
||||||
|
- Priority: Medium
|
||||||
|
- Owner: project maintainers
|
||||||
|
|
||||||
|
## Intent
|
||||||
|
- 운영자가 특정 자산을 런타임에 매매에서 제외(pause)하거나 다시 포함(resume)할 수 있게 한다. 프로그램 이상·이벤트 시 안전 통제를 위한 수동 overlay 다.
|
||||||
|
|
||||||
|
## Requirement
|
||||||
|
- pause/resume 단위는 `portfolio_id`(= asset_id)다.
|
||||||
|
- 제어는 **CLI** 와 **dcy-006 RPC 제어 메시지** 둘 다로 가능하며, 둘은 동일한 pause 상태를 변경한다. config 리로드 방식은 채택하지 않는다.
|
||||||
|
- pause 상태는 **Postgres 에 영속**(portfolio_id 키)하며 daemon 재시작 후에도, 일일 스냅샷 refresh 후에도 유지된다(refresh 는 universe/포지션만 갱신, pause overlay 불변).
|
||||||
|
- 적용 범위는 **전역** — pause 된 자산은 모든 view/전략 요청에서 마스킹된다.
|
||||||
|
- pause 는 request-time mask 이므로 resume 시 다음 요청부터 즉시 응답에 복귀한다.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
1. Given 운영자가 `portfolio_id` 를 pause, When 임의 `view`/`strategy_key` 로 스냅샷을 요청, Then 그 자산은 응답에서 마스킹된다.
|
||||||
|
2. Given pause 상태에서 daemon 재시작 또는 일일 refresh 발생, When 이후 요청, Then pause 가 유지된다.
|
||||||
|
3. Given 운영자가 resume, When 다음 요청, Then 그 자산이 응답에 다시 포함된다.
|
||||||
|
4. Given CLI 로 pause 후 RPC 제어로 조회(또는 그 반대), Then 동일한 pause 상태가 보인다.
|
||||||
|
|
||||||
|
## Impacted Area
|
||||||
|
- Docs: docs/discovery/dcy-005_20260604_portfolio-hub-asset-snapshot-api.md
|
||||||
|
- Modules: pause 상태 store(Postgres), CLI 제어, dcy-006 RPC 제어 메시지 핸들러, 응답 산출 시 pause 마스킹
|
||||||
|
- Tests: pause→호출→resume→호출 시퀀스, 재시작/refresh 후 영속
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- pause(수동·영속) vs warning(자동·일시, REQ-031)은 독립 overlay 이며 OR 합성(둘 중 하나라도면 마스킹).
|
||||||
|
- OQ-10 반영.
|
||||||
|
|
||||||
|
## Change Log
|
||||||
|
|
||||||
|
### CHG-20260606-01
|
||||||
|
- Date: 2026-06-06
|
||||||
|
- Author: project maintainers
|
||||||
|
|
||||||
|
#### Change Summary
|
||||||
|
- dcy-005 FR-9 + OQ-10 분해로 최초 생성(Proposed).
|
||||||
|
|
||||||
|
#### Intent
|
||||||
|
- 런타임 pause/resume 제어(단위·제어 표면·영속·범위) 정의.
|
||||||
|
|
||||||
|
#### Acceptance Criteria Delta
|
||||||
|
- Added:
|
||||||
|
- AC-1~4 신규
|
||||||
|
- Updated:
|
||||||
|
- none
|
||||||
|
- Removed:
|
||||||
|
- none
|
||||||
|
|
||||||
|
#### Impacted Area Delta
|
||||||
|
- Docs:
|
||||||
|
- dcy-005
|
||||||
|
- Modules:
|
||||||
|
- pause store, CLI, RPC 제어, 마스킹
|
||||||
|
- Tests:
|
||||||
|
- pause/resume 시퀀스, 영속
|
||||||
|
|
||||||
|
#### Delivery Trace Delta
|
||||||
|
- Batches:
|
||||||
|
- none
|
||||||
|
- Releases:
|
||||||
|
- none
|
||||||
|
|
||||||
|
#### Revalidation Impact
|
||||||
|
- Reverification Needed: no
|
||||||
|
- Existing Implementation Invalidated: no
|
||||||
|
- Status Recommendation: keep-current
|
||||||
|
- Follow-up Action: confirm-req
|
||||||
|
|
||||||
|
#### Validation Plan
|
||||||
|
- pause→호출→resume→호출 + 재시작 영속 테스트로 AC 검증.
|
||||||
|
|
||||||
|
### CHG-20260606-02
|
||||||
|
- Date: 2026-06-06
|
||||||
|
- Author: project maintainers
|
||||||
|
|
||||||
|
#### Change Summary
|
||||||
|
- 승인 게이트 통과 → Proposed → Approved.
|
||||||
|
|
||||||
|
#### Acceptance Criteria Delta
|
||||||
|
- Added: none
|
||||||
|
- Updated: none
|
||||||
|
- Removed: none
|
||||||
|
|
||||||
|
#### Impacted Area Delta
|
||||||
|
- 변경 없음
|
||||||
|
|
||||||
|
#### Delivery Trace Delta
|
||||||
|
- Batches: none
|
||||||
|
- Releases: none
|
||||||
|
|
||||||
|
#### Revalidation Impact
|
||||||
|
- Reverification Needed: no
|
||||||
|
- Existing Implementation Invalidated: no
|
||||||
|
- Status Recommendation: keep-current
|
||||||
|
- Follow-up Action: suggest-batch-reqs / draft-batch
|
||||||
|
|
||||||
|
#### Validation Plan
|
||||||
|
- 승인 기록. 구현 전 재검증 불필요.
|
||||||
|
|
||||||
|
### CHG-20260606-03
|
||||||
|
- Date: 2026-06-06
|
||||||
|
- Author: Justin Ko
|
||||||
|
|
||||||
|
#### Change Summary
|
||||||
|
- Status 변경: Approved → Implemented. BAT-008 release-candidate verification evidence 기반.
|
||||||
|
|
||||||
|
#### Intent
|
||||||
|
- pause/resume CLI + RPC 제어 + Postgres 영속 구현 완료 상태로 동기화.
|
||||||
|
|
||||||
|
#### Acceptance Criteria Delta
|
||||||
|
- Added/Updated/Removed: 없음
|
||||||
|
|
||||||
|
#### Impacted Area Delta
|
||||||
|
- Docs/Modules/Tests: 없음
|
||||||
|
|
||||||
|
#### Delivery Trace Delta
|
||||||
|
- Batches:
|
||||||
|
- BAT-008 (release-candidate)
|
||||||
|
- Releases:
|
||||||
|
- none
|
||||||
|
|
||||||
|
#### Revalidation Impact
|
||||||
|
- Reverification Needed: no
|
||||||
|
- Existing Implementation Invalidated: no
|
||||||
|
- Status Recommendation: Implemented
|
||||||
|
- Follow-up Action: release 단계는 별도 진행
|
||||||
|
|
||||||
|
#### Validation Plan
|
||||||
|
- AC-1: tests/application/test_pause_handler.py::test_pause_then_list + test_snapshot_selection.py::test_pause_overlay_masks_asset.
|
||||||
|
- AC-2: tests/adapters/portfolio/test_postgresql_pause_store.py::test_durability_simulated_via_fresh_store_instance.
|
||||||
|
- AC-3: tests/application/test_pause_handler.py::test_resume_clears_pause.
|
||||||
|
- AC-4: tests/application/test_pause_handler.py::test_cli_pause_visible_to_rpc_list_via_shared_store + 실 daemon RPC 운영 검증.
|
||||||
+11
-4
@@ -74,7 +74,7 @@
|
|||||||
|
|
||||||
## Next Requirement ID
|
## Next Requirement ID
|
||||||
|
|
||||||
- Current: `REQ-027`
|
- Current: `REQ-034`
|
||||||
- Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다.
|
- Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다.
|
||||||
|
|
||||||
## Requirement Register
|
## Requirement Register
|
||||||
@@ -107,11 +107,18 @@
|
|||||||
| REQ-024 | RabbitMQ 가용성 degrade 정책 (버퍼링 금지 + 보수적 중단 + core 안전 매도 독립) | Non-Functional | Implemented | High | project maintainers | [req-024](./Non-Functional/req-024_rabbitmq-availability-degrade-policy.md) |
|
| REQ-024 | RabbitMQ 가용성 degrade 정책 (버퍼링 금지 + 보수적 중단 + core 안전 매도 독립) | Non-Functional | Implemented | High | project maintainers | [req-024](./Non-Functional/req-024_rabbitmq-availability-degrade-policy.md) |
|
||||||
| REQ-025 | HTTP `/v1` API 완전 제거 (healthz 포함, HTTP 표면 0) | Interface | Implemented | High | project maintainers | [req-025](./Interface/req-025_http-api-complete-removal.md) |
|
| REQ-025 | HTTP `/v1` API 완전 제거 (healthz 포함, HTTP 표면 0) | Interface | Implemented | High | project maintainers | [req-025](./Interface/req-025_http-api-complete-removal.md) |
|
||||||
| REQ-026 | messaging 레이어 baseline 문서 정합 | Documentation | Implemented | Medium | project maintainers | [req-026](./Documentation/req-026_messaging-baseline-alignment.md) |
|
| REQ-026 | messaging 레이어 baseline 문서 정합 | Documentation | Implemented | Medium | project maintainers | [req-026](./Documentation/req-026_messaging-baseline-alignment.md) |
|
||||||
|
| REQ-027 | portfolio-hub read 포트 + 어댑터 + stub | Integration | Implemented | High | project maintainers | [req-027](./Integration/req-027_portfolio-hub-read-port-and-adapter.md) |
|
||||||
|
| REQ-028 | 종목 스냅샷 read 메시지 계약 + envelope + degraded 신호 | Interface | Implemented | High | project maintainers | [req-028](./Interface/req-028_snapshot-read-message-contract-and-envelope.md) |
|
||||||
|
| REQ-029 | 자산 선별(slot overlay) + view 모드 + strategy_key 필터 | Interface | Implemented | High | project maintainers | [req-029](./Interface/req-029_asset-selection-slot-overlay-view-and-strategy-key.md) |
|
||||||
|
| REQ-030 | 일일 스냅샷 스케줄러 + 옵션 D 현금/포지션 갱신 | Integration | Approved | High | project maintainers | [req-030](./Integration/req-030_daily-snapshot-scheduler-and-option-d-cash-position.md) |
|
||||||
|
| REQ-031 | 포지션 정합 검증 + warning 마스킹 | Exception | Implemented | High | project maintainers | [req-031](./Exception/req-031_position-reconciliation-and-warning.md) |
|
||||||
|
| REQ-032 | 런타임 pause/resume 제어 | Interface | Implemented | Medium | project maintainers | [req-032](./Interface/req-032_runtime-pause-resume-control.md) |
|
||||||
|
| REQ-033 | 스냅샷 read 레이어 baseline 문서 정합 | Documentation | Implemented | Medium | project maintainers | [req-033](./Documentation/req-033_snapshot-read-baseline-alignment.md) |
|
||||||
|
|
||||||
## Recent Change Log Summary
|
## Recent Change Log Summary
|
||||||
|
|
||||||
|
- 2026-06-06 :: REQ-027~029, REQ-031~033 Approved → Implemented — BAT-008 release-candidate verification evidence 기반. REQ-030 은 AC-1 evidence 확보·AC-2/AC-3 portfolio-hub#8 배포 대기로 Approved 유지. (confirm-req-implemented)
|
||||||
|
- 2026-06-06 :: REQ-027~033 Proposed → Approved — dcy-005 confirmed, 전체 승인 게이트 통과. REQ-030 옵션 D 구현은 portfolio-hub#8 배포 후 착수. (confirm-req)
|
||||||
|
- 2026-06-06 :: REQ-027~033 최초 생성(Proposed) — dcy-005 FR-1~FR-9 분해(NFR-1 → REQ-027 흡수, NFR-2 → REQ-030 흡수, NFR-3 → REQ-028 흡수). (draft-req)
|
||||||
- 2026-06-05 :: REQ-021~026 Approved → Implemented — BAT-007 release-candidate verification evidence 기반. (confirm-req-implemented)
|
- 2026-06-05 :: REQ-021~026 Approved → Implemented — BAT-007 release-candidate verification evidence 기반. (confirm-req-implemented)
|
||||||
- 2026-06-05 :: REQ-021~026 Proposed -> Approved — 승인 게이트 통과. (confirm-req)
|
- 2026-06-05 :: REQ-021~026 Proposed -> Approved — 승인 게이트 통과. (confirm-req)
|
||||||
- 2026-06-05 :: REQ-021~026 최초 생성(Proposed) — dcy-006 FR-1~FR-6 분해(NFR-1 → REQ-022 흡수, NFR-2 → REQ-023 흡수, NFR-3 → REQ-024 승격). (draft-req)
|
|
||||||
- 2026-06-04 :: REQ-017~020 Approved → Implemented — BAT-006 release-candidate verification evidence 기반. (confirm-req-implemented)
|
|
||||||
- 2026-06-04 :: REQ-017~020 Proposed -> Approved — 승인 게이트 통과. (confirm-req)
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ dev = [
|
|||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
trex-core-daemon = "trex_core.runtime.daemon:main"
|
trex-core-daemon = "trex_core.runtime.daemon:main"
|
||||||
|
trex-core = "trex_core.runtime.cli:main"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["setuptools>=68"]
|
requires = ["setuptools>=68"]
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ _EXPECTED_TABLES = {
|
|||||||
"daily_symbol_entries",
|
"daily_symbol_entries",
|
||||||
"slot_transition_log",
|
"slot_transition_log",
|
||||||
"intent_log",
|
"intent_log",
|
||||||
"ledger_outbox", # BAT-006 / REQ-019
|
"ledger_outbox", # BAT-006 / REQ-019
|
||||||
|
"snapshot_pause", # BAT-008 / REQ-032
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""PortfolioHubReadAdapter HTTP contract tests (REQ-027 AC-1/AC-4)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from trex_core.adapters.portfolio.hub_read import PortfolioHubReadAdapter
|
||||||
|
from trex_core.application.portfolio import PortfolioHubUnavailableError
|
||||||
|
|
||||||
|
|
||||||
|
def _client(handler):
|
||||||
|
return httpx.Client(transport=httpx.MockTransport(handler))
|
||||||
|
|
||||||
|
|
||||||
|
def test_whoami_returns_token_daemon_id():
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def handler(req: httpx.Request) -> httpx.Response:
|
||||||
|
captured["url"] = str(req.url)
|
||||||
|
captured["auth"] = req.headers.get("authorization")
|
||||||
|
return httpx.Response(200, json={"daemon_id": "d-7", "scope": ["read"]})
|
||||||
|
|
||||||
|
adapter = PortfolioHubReadAdapter("https://pf.test", token="tok",
|
||||||
|
client=_client(handler))
|
||||||
|
assert adapter.whoami() == "d-7"
|
||||||
|
assert captured["url"] == "https://pf.test/v1/auth/whoami"
|
||||||
|
assert captured["auth"] == "Bearer tok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_portfolios_scoped_by_managed_by_daemon_id():
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def handler(req: httpx.Request) -> httpx.Response:
|
||||||
|
captured["url"] = str(req.url)
|
||||||
|
return httpx.Response(200, json={
|
||||||
|
"items": [
|
||||||
|
{"portfolio_id": "pf-1", "symbol": "005930",
|
||||||
|
"cash_balance": 1_000_000, "asset_quantity": 10,
|
||||||
|
"active_strategy_id": "target-value-v1",
|
||||||
|
"strategy_params": {"target_value": 1_000_000}},
|
||||||
|
{"portfolio_id": "pf-2", "symbol": "000660",
|
||||||
|
"cash_balance": 2_000_000, "asset_quantity": 0,
|
||||||
|
"active_strategy_id": None, "strategy_params": {}},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
adapter = PortfolioHubReadAdapter("https://pf.test", token="tok",
|
||||||
|
client=_client(handler))
|
||||||
|
items = adapter.get_portfolios("d-7")
|
||||||
|
assert "managed_by_daemon_id=d-7" in captured["url"]
|
||||||
|
assert {it.portfolio_id for it in items} == {"pf-1", "pf-2"}
|
||||||
|
assert items[0].symbol == "005930"
|
||||||
|
assert items[0].cash_balance == 1_000_000
|
||||||
|
assert items[0].active_strategy_id == "target-value-v1"
|
||||||
|
assert items[0].strategy_params == {"target_value": 1_000_000}
|
||||||
|
# no price field
|
||||||
|
for it in items:
|
||||||
|
assert not hasattr(it, "price")
|
||||||
|
|
||||||
|
|
||||||
|
def test_whoami_5xx_raises_unavailable():
|
||||||
|
def handler(req):
|
||||||
|
return httpx.Response(503, text="upstream down")
|
||||||
|
adapter = PortfolioHubReadAdapter("https://pf.test", client=_client(handler))
|
||||||
|
with pytest.raises(PortfolioHubUnavailableError):
|
||||||
|
adapter.whoami()
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_portfolios_network_error_raises_unavailable():
|
||||||
|
def handler(req):
|
||||||
|
raise httpx.ConnectError("nope")
|
||||||
|
adapter = PortfolioHubReadAdapter("https://pf.test", client=_client(handler))
|
||||||
|
with pytest.raises(PortfolioHubUnavailableError):
|
||||||
|
adapter.get_portfolios("d-7")
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""PostgreSQLPauseStore integration tests (REQ-032 AC-2 — durable across restart)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from trex_core.adapters.portfolio.pause_store import PostgreSQLPauseStore
|
||||||
|
|
||||||
|
|
||||||
|
def test_pause_persists_and_listing_works(pg_clean):
|
||||||
|
store = PostgreSQLPauseStore(pg_clean)
|
||||||
|
store.pause("pf-A", actor="cli")
|
||||||
|
store.pause("pf-B", actor="rpc")
|
||||||
|
assert store.list_paused() == {"pf-A", "pf-B"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_resume_removes_row(pg_clean):
|
||||||
|
store = PostgreSQLPauseStore(pg_clean)
|
||||||
|
store.pause("pf-X")
|
||||||
|
store.resume("pf-X")
|
||||||
|
assert store.list_paused() == set()
|
||||||
|
|
||||||
|
|
||||||
|
def test_pause_is_upsert_on_repeat(pg_clean):
|
||||||
|
store = PostgreSQLPauseStore(pg_clean)
|
||||||
|
store.pause("pf-A", actor="op1")
|
||||||
|
store.pause("pf-A", actor="op2")
|
||||||
|
assert store.list_paused() == {"pf-A"} # still one row
|
||||||
|
|
||||||
|
|
||||||
|
def test_durability_simulated_via_fresh_store_instance(pg_clean):
|
||||||
|
"""REQ-032 AC-2: pause survives a process restart (re-instantiate store)."""
|
||||||
|
a = PostgreSQLPauseStore(pg_clean)
|
||||||
|
a.pause("pf-restart")
|
||||||
|
# "restart" — new store instance, same DSN
|
||||||
|
b = PostgreSQLPauseStore(pg_clean)
|
||||||
|
assert "pf-restart" in b.list_paused()
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""StubPortfolioRead tests (REQ-027 AC-2)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from trex_core.adapters.portfolio.stub_read import StubPortfolioRead
|
||||||
|
from trex_core.application.portfolio import PortfolioItem
|
||||||
|
|
||||||
|
|
||||||
|
def test_stub_returns_fixed_universe_without_network():
|
||||||
|
stub = StubPortfolioRead()
|
||||||
|
items = stub.get_portfolios(daemon_id="anything")
|
||||||
|
assert items
|
||||||
|
assert all(isinstance(it, PortfolioItem) for it in items)
|
||||||
|
# required fields populated, no `price` attribute
|
||||||
|
for it in items:
|
||||||
|
assert it.symbol
|
||||||
|
assert it.portfolio_id
|
||||||
|
assert it.cash_balance >= 0
|
||||||
|
assert not hasattr(it, "price")
|
||||||
|
|
||||||
|
|
||||||
|
def test_stub_whoami_returns_configured_daemon_id():
|
||||||
|
stub = StubPortfolioRead(daemon_id="d-42")
|
||||||
|
assert stub.whoami() == "d-42"
|
||||||
|
|
||||||
|
|
||||||
|
def test_stub_items_are_mutable_for_tests():
|
||||||
|
stub = StubPortfolioRead(items=[
|
||||||
|
PortfolioItem(portfolio_id="x", symbol="X", cash_balance=1.0, asset_quantity=0),
|
||||||
|
])
|
||||||
|
assert len(stub.get_portfolios("d")) == 1
|
||||||
|
stub.items.append(
|
||||||
|
PortfolioItem(portfolio_id="y", symbol="Y", cash_balance=2.0, asset_quantity=0)
|
||||||
|
)
|
||||||
|
assert len(stub.get_portfolios("d")) == 2
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""pause/resume RPC handler tests (REQ-032 AC-4)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from trex_core.application.messaging import RpcRequest
|
||||||
|
from trex_core.application.messaging_handlers import make_pause_rpc_handler
|
||||||
|
from trex_core.application.portfolio import InMemoryPauseStore
|
||||||
|
|
||||||
|
|
||||||
|
def _handler():
|
||||||
|
store = InMemoryPauseStore()
|
||||||
|
return store, make_pause_rpc_handler(store)
|
||||||
|
|
||||||
|
|
||||||
|
def _call(handler, action, pid=None, actor="cli"):
|
||||||
|
return handler(RpcRequest(
|
||||||
|
payload={"action": action, "portfolio_id": pid, "actor": actor},
|
||||||
|
correlation_id="cid-1",
|
||||||
|
)).payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_pause_then_list():
|
||||||
|
store, handler = _handler()
|
||||||
|
assert _call(handler, "pause", "pf-A")["status"] == "ok"
|
||||||
|
assert _call(handler, "pause", "pf-B")["status"] == "ok"
|
||||||
|
listed = _call(handler, "list")
|
||||||
|
assert listed["status"] == "ok"
|
||||||
|
assert listed["paused"] == ["pf-A", "pf-B"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_resume_clears_pause():
|
||||||
|
store, handler = _handler()
|
||||||
|
_call(handler, "pause", "pf-A")
|
||||||
|
_call(handler, "resume", "pf-A")
|
||||||
|
assert _call(handler, "list")["paused"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_portfolio_id_returns_error():
|
||||||
|
_, handler = _handler()
|
||||||
|
resp = _call(handler, "pause", None)
|
||||||
|
assert resp["status"] == "error"
|
||||||
|
assert "portfolio_id" in resp["reason"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_action_returns_error():
|
||||||
|
_, handler = _handler()
|
||||||
|
resp = _call(handler, "kill", "pf-A")
|
||||||
|
assert resp["status"] == "error"
|
||||||
|
assert "unknown action" in resp["reason"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_pause_visible_to_rpc_list_via_shared_store():
|
||||||
|
"""REQ-032 AC-4 — CLI and RPC share the same store."""
|
||||||
|
store = InMemoryPauseStore()
|
||||||
|
handler = make_pause_rpc_handler(store)
|
||||||
|
# CLI-side: directly mutate the store (CLI uses the same impl)
|
||||||
|
store.pause("pf-cli", actor="cli")
|
||||||
|
# RPC-side: list returns the same pause
|
||||||
|
resp = handler(RpcRequest(
|
||||||
|
payload={"action": "list"}, correlation_id="cid-cli",
|
||||||
|
)).payload
|
||||||
|
assert "pf-cli" in resp["paused"]
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""Position reconciliation tests (REQ-031)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from trex_core.application.broker_port import BalanceItem
|
||||||
|
from trex_core.application.portfolio import PortfolioItem, SnapshotStore
|
||||||
|
from trex_core.application.reconciliation import reconcile_positions
|
||||||
|
|
||||||
|
|
||||||
|
class FakeBroker:
|
||||||
|
def __init__(self, balances):
|
||||||
|
self._b = balances
|
||||||
|
def name(self): return "fake"
|
||||||
|
def submit_order(self, req): ...
|
||||||
|
def poll_fill(self, *args, **kw): ...
|
||||||
|
def cancel_order(self, *args, **kw): ...
|
||||||
|
def get_balance(self): return list(self._b)
|
||||||
|
|
||||||
|
|
||||||
|
def _store_with(*items):
|
||||||
|
store = SnapshotStore()
|
||||||
|
store.swap(list(items))
|
||||||
|
return store
|
||||||
|
|
||||||
|
|
||||||
|
def test_mismatch_raises_warning_and_masks_asset():
|
||||||
|
store = _store_with(
|
||||||
|
PortfolioItem(portfolio_id="pf-A", symbol="005930",
|
||||||
|
cash_balance=0, asset_quantity=10),
|
||||||
|
)
|
||||||
|
broker = FakeBroker([BalanceItem(symbol="005930", hldg_qty=7)]) # mismatch
|
||||||
|
decisions = reconcile_positions(store=store, broker=broker)
|
||||||
|
assert decisions["pf-A"] == "warning"
|
||||||
|
assert store.warnings() == {"pf-A"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_subsequent_match_auto_clears_warning():
|
||||||
|
store = _store_with(
|
||||||
|
PortfolioItem(portfolio_id="pf-A", symbol="005930",
|
||||||
|
cash_balance=0, asset_quantity=10),
|
||||||
|
)
|
||||||
|
# round 1 — mismatch
|
||||||
|
reconcile_positions(store=store, broker=FakeBroker([BalanceItem("005930", 7)]))
|
||||||
|
assert "pf-A" in store.warnings()
|
||||||
|
# round 2 — match
|
||||||
|
decisions = reconcile_positions(store=store, broker=FakeBroker([BalanceItem("005930", 10)]))
|
||||||
|
assert decisions["pf-A"] == "cleared"
|
||||||
|
assert store.warnings() == set()
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiple_portfolios_same_symbol_aggregate():
|
||||||
|
store = _store_with(
|
||||||
|
PortfolioItem(portfolio_id="pf-A", symbol="005930",
|
||||||
|
cash_balance=0, asset_quantity=6),
|
||||||
|
PortfolioItem(portfolio_id="pf-B", symbol="005930",
|
||||||
|
cash_balance=0, asset_quantity=4),
|
||||||
|
)
|
||||||
|
# hub total 6+4=10 == broker 10 → both ok
|
||||||
|
decisions = reconcile_positions(
|
||||||
|
store=store, broker=FakeBroker([BalanceItem("005930", 10)]),
|
||||||
|
)
|
||||||
|
assert decisions == {"pf-A": "ok", "pf-B": "ok"}
|
||||||
|
assert store.warnings() == set()
|
||||||
|
|
||||||
|
|
||||||
|
def test_paper_broker_returns_deterministic_balance():
|
||||||
|
"""REQ-031 AC-3: paper stub provides deterministic balance."""
|
||||||
|
from trex_core.adapters.broker.paper import PaperBroker
|
||||||
|
from trex_core.application.broker_port import OrderRequest
|
||||||
|
|
||||||
|
broker = PaperBroker(fill_after_polls=0)
|
||||||
|
ack = broker.submit_order(OrderRequest(slot_id="s", side="buy",
|
||||||
|
symbol="005930", qty=10, limit_price=1000))
|
||||||
|
broker.poll_fill(ack.broker_order_id, side="buy", symbol="005930") # fill
|
||||||
|
balances = broker.get_balance()
|
||||||
|
assert len(balances) == 1
|
||||||
|
assert balances[0].symbol == "005930"
|
||||||
|
assert balances[0].hldg_qty == 10
|
||||||
|
|
||||||
|
# zero-qty rows dropped (D-2 0-qty leftover semantics)
|
||||||
|
ack2 = broker.submit_order(OrderRequest(slot_id="s2", side="buy",
|
||||||
|
symbol="000660", qty=5, limit_price=500))
|
||||||
|
broker.poll_fill(ack2.broker_order_id, side="buy", symbol="000660")
|
||||||
|
ack3 = broker.submit_order(OrderRequest(slot_id="s3", side="sell",
|
||||||
|
symbol="000660", qty=5, limit_price=500))
|
||||||
|
broker.poll_fill(ack3.broker_order_id, side="sell", symbol="000660")
|
||||||
|
syms = {b.symbol for b in broker.get_balance()}
|
||||||
|
assert "000660" not in syms # net 0 → dropped
|
||||||
|
assert "005930" in syms
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""Snapshot RPC handler envelope path (REQ-028 AC-1/AC-2/AC-3)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from trex_core.application.messaging import RpcRequest
|
||||||
|
from trex_core.application.messaging_handlers import make_snapshot_rpc_handler
|
||||||
|
from trex_core.application.portfolio import (
|
||||||
|
DegradedReason,
|
||||||
|
InMemoryPauseStore,
|
||||||
|
PortfolioItem,
|
||||||
|
SnapshotStore,
|
||||||
|
)
|
||||||
|
from trex_core.domain.slot import SlotService
|
||||||
|
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||||
|
|
||||||
|
DAY = date(2026, 6, 5)
|
||||||
|
|
||||||
|
|
||||||
|
def _wire(items=None):
|
||||||
|
repo = InMemorySlotRepository()
|
||||||
|
svc = SlotService(repo)
|
||||||
|
svc.preallocate_daily_slots(DAY, 5)
|
||||||
|
store = SnapshotStore()
|
||||||
|
store.swap(items or [
|
||||||
|
PortfolioItem(portfolio_id="pf-A", symbol="005930",
|
||||||
|
cash_balance=1_000_000, asset_quantity=0,
|
||||||
|
active_strategy_id="target-value-v1",
|
||||||
|
strategy_params={"k": 1}),
|
||||||
|
])
|
||||||
|
pause = InMemoryPauseStore()
|
||||||
|
handler = make_snapshot_rpc_handler(
|
||||||
|
repo, svc,
|
||||||
|
snapshot_store=store, pause_store=pause, default_total_slots=5,
|
||||||
|
)
|
||||||
|
return store, pause, handler
|
||||||
|
|
||||||
|
|
||||||
|
def test_envelope_shape_returned():
|
||||||
|
_, _, handler = _wire()
|
||||||
|
resp = handler(RpcRequest(
|
||||||
|
payload={"view": "all", "trading_date": DAY.isoformat()},
|
||||||
|
correlation_id="snap-1",
|
||||||
|
))
|
||||||
|
body = resp.payload
|
||||||
|
assert set(body.keys()) == {"assets", "view", "available_slots", "degraded", "reason", "message"}
|
||||||
|
assert isinstance(body["assets"], list)
|
||||||
|
assert body["view"] == "all"
|
||||||
|
assert body["degraded"] is False
|
||||||
|
assert body["reason"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_envelope_strategy_key_filter():
|
||||||
|
_, _, handler = _wire([
|
||||||
|
PortfolioItem(portfolio_id="x", symbol="X", cash_balance=0, asset_quantity=0,
|
||||||
|
active_strategy_id="alpha"),
|
||||||
|
PortfolioItem(portfolio_id="y", symbol="Y", cash_balance=0, asset_quantity=0,
|
||||||
|
active_strategy_id="beta"),
|
||||||
|
])
|
||||||
|
resp = handler(RpcRequest(
|
||||||
|
payload={"view": "all", "strategy_key": "alpha"}, correlation_id="snap-2",
|
||||||
|
))
|
||||||
|
pids = {a["asset_id"] for a in resp.payload["assets"]}
|
||||||
|
assert pids == {"x"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_degraded_signal_does_not_leak_credentials():
|
||||||
|
store, _, handler = _wire()
|
||||||
|
store.set_degraded(
|
||||||
|
DegradedReason.PORTFOLIO_HUB_UNAVAILABLE,
|
||||||
|
"GET https://hub failed: 401 Bearer secret-token-abc",
|
||||||
|
)
|
||||||
|
resp = handler(RpcRequest(payload={"view": "all"}, correlation_id="snap-3"))
|
||||||
|
body = resp.payload
|
||||||
|
assert body["degraded"] is True
|
||||||
|
assert body["reason"] == "portfolio_hub_unavailable"
|
||||||
|
assert body["message"] is not None
|
||||||
|
assert "secret-token-abc" not in body["message"]
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
handler = make_snapshot_rpc_handler(repo, svc)
|
||||||
|
resp = handler(RpcRequest(
|
||||||
|
payload={"view": "all", "trading_date": DAY.isoformat()}, correlation_id="legacy",
|
||||||
|
))
|
||||||
|
body = resp.payload
|
||||||
|
assert "slots" in body and "capacity" in body
|
||||||
|
assert "assets" not in body
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""Snapshot scheduler tests (REQ-030 AC-1)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from trex_core.adapters.broker.paper import PaperBroker
|
||||||
|
from trex_core.adapters.portfolio.stub_read import StubPortfolioRead
|
||||||
|
from trex_core.application.portfolio import PortfolioItem, SnapshotStore
|
||||||
|
from trex_core.application.snapshot_scheduler import SnapshotScheduler
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_once_fetches_universe_and_swaps_store():
|
||||||
|
read = StubPortfolioRead(daemon_id="d-1", items=[
|
||||||
|
PortfolioItem(portfolio_id="pf-X", symbol="X",
|
||||||
|
cash_balance=100, asset_quantity=0),
|
||||||
|
])
|
||||||
|
store = SnapshotStore()
|
||||||
|
sched = SnapshotScheduler(read_port=read, store=store, broker=PaperBroker())
|
||||||
|
assert sched.run_once() is True
|
||||||
|
pids = {a.item.portfolio_id for a in store.list_assets()}
|
||||||
|
assert pids == {"pf-X"}
|
||||||
|
# universe membership + strategy params come from fetch (baseline)
|
||||||
|
# cash/quantity are baseline values (option D will overwrite later)
|
||||||
|
a = store.get("pf-X")
|
||||||
|
assert a.item.cash_balance == 100
|
||||||
|
assert a.warning is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_once_failure_sets_degraded_signal():
|
||||||
|
class BadRead:
|
||||||
|
def whoami(self): return "d-2"
|
||||||
|
def get_portfolios(self, daemon_id):
|
||||||
|
from trex_core.application.portfolio import PortfolioHubUnavailableError
|
||||||
|
raise PortfolioHubUnavailableError("simulated")
|
||||||
|
store = SnapshotStore()
|
||||||
|
sched = SnapshotScheduler(read_port=BadRead(), store=store, broker=PaperBroker())
|
||||||
|
assert sched.run_once() is False
|
||||||
|
reason, msg = store.degraded_state()
|
||||||
|
assert reason is not None and reason.value == "portfolio_hub_unavailable"
|
||||||
|
|
||||||
|
|
||||||
|
def test_swap_preserves_warning_for_carried_over_portfolio():
|
||||||
|
store = SnapshotStore()
|
||||||
|
store.swap([PortfolioItem(portfolio_id="pf-X", symbol="X",
|
||||||
|
cash_balance=100, asset_quantity=0)])
|
||||||
|
store.set_warning("pf-X", True)
|
||||||
|
# second swap includes the same portfolio with refreshed cash
|
||||||
|
store.swap([PortfolioItem(portfolio_id="pf-X", symbol="X",
|
||||||
|
cash_balance=200, asset_quantity=0)])
|
||||||
|
a = store.get("pf-X")
|
||||||
|
assert a.warning is True
|
||||||
|
assert a.item.cash_balance == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_option_d_update_cash_position_overrides_baseline():
|
||||||
|
"""REQ-030 wiring — when *_after lands we update the authoritative values."""
|
||||||
|
store = SnapshotStore()
|
||||||
|
store.swap([PortfolioItem(portfolio_id="pf-X", symbol="X",
|
||||||
|
cash_balance=100, asset_quantity=0)])
|
||||||
|
store.update_cash_position("pf-X", cash_balance=92.5, asset_quantity=1)
|
||||||
|
a = store.get("pf-X")
|
||||||
|
assert a.item.cash_balance == 92.5
|
||||||
|
assert a.item.asset_quantity == 1
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
"""Snapshot selection + masking + view composition (REQ-029, REQ-028)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from trex_core.application.portfolio import (
|
||||||
|
DegradedReason,
|
||||||
|
InMemoryPauseStore,
|
||||||
|
PortfolioItem,
|
||||||
|
SnapshotStore,
|
||||||
|
View,
|
||||||
|
build_snapshot_envelope,
|
||||||
|
)
|
||||||
|
from trex_core.domain.slot import SlotService, SlotState
|
||||||
|
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
|
||||||
|
|
||||||
|
DAY = date(2026, 6, 5)
|
||||||
|
|
||||||
|
|
||||||
|
def _universe():
|
||||||
|
return [
|
||||||
|
PortfolioItem(portfolio_id="pf-A", symbol="005930",
|
||||||
|
cash_balance=1_000_000, asset_quantity=0,
|
||||||
|
active_strategy_id="target-value-v1"),
|
||||||
|
PortfolioItem(portfolio_id="pf-B", symbol="000660",
|
||||||
|
cash_balance=2_000_000, asset_quantity=10,
|
||||||
|
active_strategy_id="trend-follow-v1"),
|
||||||
|
PortfolioItem(portfolio_id="pf-C", symbol="035720",
|
||||||
|
cash_balance=3_000_000, asset_quantity=0,
|
||||||
|
active_strategy_id="target-value-v1"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _wire(*, slots_setup=None):
|
||||||
|
store = SnapshotStore()
|
||||||
|
store.swap(_universe())
|
||||||
|
pause = InMemoryPauseStore()
|
||||||
|
repo = InMemorySlotRepository()
|
||||||
|
svc = SlotService(repo)
|
||||||
|
svc.preallocate_daily_slots(DAY, 5)
|
||||||
|
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)
|
||||||
|
assert s is not None
|
||||||
|
svc.transit_slot(
|
||||||
|
s.slot_id, SlotState.IDLE, SlotState.PENDING_BUY,
|
||||||
|
reason="buy_intent", actor="t",
|
||||||
|
symbol=symbol, portfolio_id=portfolio_id,
|
||||||
|
strategy_id=strategy_id, qty=10,
|
||||||
|
)
|
||||||
|
if status is SlotState.PENDING_BUY:
|
||||||
|
return s.slot_id
|
||||||
|
svc.transit_slot(
|
||||||
|
s.slot_id, SlotState.PENDING_BUY, SlotState.HOLDING,
|
||||||
|
reason="filled", actor="t", avg_price=100.0, qty=10,
|
||||||
|
)
|
||||||
|
return s.slot_id
|
||||||
|
|
||||||
|
|
||||||
|
def _env(store, pause, repo, **kw):
|
||||||
|
"""Helper: always pin trading_date to the test's hardcoded DAY."""
|
||||||
|
return build_snapshot_envelope(
|
||||||
|
store=store, pause_store=pause, slot_repo=repo, trading_date=DAY, **kw,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_view_all_includes_holding_excludes_pending_for_same_symbol():
|
||||||
|
def setup(svc, repo):
|
||||||
|
_take_slot(svc, repo, portfolio_id="pf-A", symbol="005930",
|
||||||
|
strategy_id="target-value-v1", status=SlotState.HOLDING)
|
||||||
|
_take_slot(svc, repo, portfolio_id="pf-B", symbol="000660",
|
||||||
|
strategy_id="trend-follow-v1", status=SlotState.PENDING_BUY)
|
||||||
|
store, pause, repo, svc = _wire(slots_setup=setup)
|
||||||
|
env = _env(store, pause, repo, available_slots=3, view=View.ALL)
|
||||||
|
pids = {a["asset_id"] for a in env.assets}
|
||||||
|
assert "pf-A" in pids # holding -> included
|
||||||
|
assert "pf-B" not in pids # pending -> masked
|
||||||
|
assert "pf-C" in pids # universe-only -> included
|
||||||
|
|
||||||
|
|
||||||
|
def test_view_buyable_with_zero_available_slots_returns_empty():
|
||||||
|
store, pause, repo, svc = _wire()
|
||||||
|
env = _env(store, pause, repo, available_slots=0, view=View.BUYABLE)
|
||||||
|
assert env.assets == []
|
||||||
|
assert env.available_slots == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_view_buyable_excludes_occupied_portfolios():
|
||||||
|
def setup(svc, repo):
|
||||||
|
_take_slot(svc, repo, portfolio_id="pf-A", symbol="005930",
|
||||||
|
strategy_id="target-value-v1", status=SlotState.PENDING_BUY)
|
||||||
|
_take_slot(svc, repo, portfolio_id="pf-B", symbol="000660",
|
||||||
|
strategy_id="trend-follow-v1", status=SlotState.HOLDING)
|
||||||
|
store, pause, repo, svc = _wire(slots_setup=setup)
|
||||||
|
env = _env(store, pause, repo, available_slots=3, view=View.BUYABLE)
|
||||||
|
pids = {a["asset_id"] for a in env.assets}
|
||||||
|
assert pids == {"pf-C"} # A occupied (pending), B occupied (holding)
|
||||||
|
|
||||||
|
|
||||||
|
def test_view_held_returns_only_holding_slot_assets():
|
||||||
|
def setup(svc, repo):
|
||||||
|
_take_slot(svc, repo, portfolio_id="pf-A", symbol="005930",
|
||||||
|
strategy_id="target-value-v1", status=SlotState.HOLDING)
|
||||||
|
_take_slot(svc, repo, portfolio_id="pf-B", symbol="000660",
|
||||||
|
strategy_id="trend-follow-v1", status=SlotState.PENDING_BUY)
|
||||||
|
store, pause, repo, svc = _wire(slots_setup=setup)
|
||||||
|
env = _env(store, pause, repo, available_slots=3, view=View.HELD)
|
||||||
|
pids = {a["asset_id"] for a in env.assets}
|
||||||
|
assert pids == {"pf-A"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_strategy_key_orthogonal_filter():
|
||||||
|
store, pause, repo, svc = _wire()
|
||||||
|
env = _env(store, pause, repo, available_slots=3, view=View.ALL,
|
||||||
|
strategy_key="target-value-v1")
|
||||||
|
assert {a["active_strategy_id"] for a in env.assets} == {"target-value-v1"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_strategy_key_missing_includes_everything_unmasked():
|
||||||
|
store, pause, repo, svc = _wire()
|
||||||
|
env = _env(store, pause, repo, available_slots=3, view=View.ALL, strategy_key=None)
|
||||||
|
assert {a["asset_id"] for a in env.assets} == {"pf-A", "pf-B", "pf-C"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_warning_overlay_masks_asset_uniformly_across_views():
|
||||||
|
store, pause, repo, svc = _wire()
|
||||||
|
store.set_warning("pf-A", True)
|
||||||
|
for view in (View.ALL, View.BUYABLE):
|
||||||
|
env = _env(store, pause, repo, available_slots=3, view=view)
|
||||||
|
pids = {a["asset_id"] for a in env.assets}
|
||||||
|
assert "pf-A" not in pids
|
||||||
|
|
||||||
|
|
||||||
|
def test_pause_overlay_masks_asset():
|
||||||
|
store, pause, repo, svc = _wire()
|
||||||
|
pause.pause("pf-B")
|
||||||
|
env = _env(store, pause, repo, available_slots=3, view=View.ALL)
|
||||||
|
assert all(a["asset_id"] != "pf-B" for a in env.assets)
|
||||||
|
|
||||||
|
|
||||||
|
def test_envelope_shape_no_price_field():
|
||||||
|
store, pause, repo, svc = _wire()
|
||||||
|
env = _env(store, pause, repo, available_slots=3, view=View.ALL)
|
||||||
|
d = env.to_dict()
|
||||||
|
assert set(d.keys()) == {"assets", "view", "available_slots", "degraded", "reason", "message"}
|
||||||
|
assert isinstance(d["assets"], list)
|
||||||
|
for asset in d["assets"]:
|
||||||
|
assert set(asset.keys()) == {"symbol", "asset_id", "cash_balance",
|
||||||
|
"asset_quantity", "active_strategy_id",
|
||||||
|
"strategy_params"}
|
||||||
|
assert "price" not in asset
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_envelope_assets_is_list_not_null():
|
||||||
|
store, pause, repo, svc = _wire()
|
||||||
|
pause.pause("pf-A")
|
||||||
|
pause.pause("pf-B")
|
||||||
|
pause.pause("pf-C")
|
||||||
|
env = _env(store, pause, repo, available_slots=3, view=View.ALL)
|
||||||
|
d = env.to_dict()
|
||||||
|
assert d["assets"] == []
|
||||||
|
assert d["assets"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_degraded_signal_in_envelope():
|
||||||
|
store, pause, repo, svc = _wire()
|
||||||
|
store.set_degraded(DegradedReason.PORTFOLIO_HUB_UNAVAILABLE,
|
||||||
|
"auth.daemon get failed (Bearer abc123)")
|
||||||
|
env = _env(store, pause, repo, available_slots=3, view=View.ALL)
|
||||||
|
d = env.to_dict()
|
||||||
|
assert d["degraded"] is True
|
||||||
|
assert d["reason"] == "portfolio_hub_unavailable"
|
||||||
|
# sanitized — credentials stripped
|
||||||
|
assert "Bearer abc123" not in d["message"]
|
||||||
|
assert "<redacted>" in d["message"] or "auth" in d["message"]
|
||||||
@@ -15,6 +15,7 @@ import os
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
_PG_TABLES = (
|
_PG_TABLES = (
|
||||||
|
"snapshot_pause",
|
||||||
"ledger_outbox",
|
"ledger_outbox",
|
||||||
"intent_log",
|
"intent_log",
|
||||||
"slot_transition_log",
|
"slot_transition_log",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from typing import Optional
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from trex_core.application.broker_port import (
|
from trex_core.application.broker_port import (
|
||||||
|
BalanceItem,
|
||||||
FillStatus,
|
FillStatus,
|
||||||
OrderAck,
|
OrderAck,
|
||||||
OrderRequest,
|
OrderRequest,
|
||||||
@@ -196,6 +197,43 @@ class KISBroker:
|
|||||||
return FillStatus(state="partially_filled", filled_qty=ccld_qty,
|
return FillStatus(state="partially_filled", filled_qty=ccld_qty,
|
||||||
avg_price=float(avg) if avg else None, raw=row)
|
avg_price=float(avg) if avg else None, raw=row)
|
||||||
|
|
||||||
|
def get_balance(self) -> list[BalanceItem]:
|
||||||
|
"""KIS 주식잔고조회 (`TTTC8434R`/`VTTC8434R`).
|
||||||
|
|
||||||
|
Aggregates `output1[*].pdno` + `hldg_qty`, drops zero-qty rows
|
||||||
|
(D-2 leftover, Design Decision 7). Network/credential errors map to
|
||||||
|
an empty list (caller treats as no broker positions).
|
||||||
|
"""
|
||||||
|
cano, product = self._account()
|
||||||
|
tr_id = "VTTC8434R" if self._mock else "TTTC8434R"
|
||||||
|
params = {
|
||||||
|
"CANO": cano, "ACNT_PRDT_CD": product,
|
||||||
|
"AFHR_FLPR_YN": "N", "OFL_YN": "",
|
||||||
|
"INQR_DVSN": "02", "UNPR_DVSN": "01",
|
||||||
|
"FUND_STTL_ICLD_YN": "N", "FNCG_AMT_AUTO_RDPT_YN": "N",
|
||||||
|
"PRCS_DVSN": "01",
|
||||||
|
"CTX_AREA_FK100": "", "CTX_AREA_NK100": "",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
resp = httpx.get(
|
||||||
|
f"{self._base_url}/uapi/domestic-stock/v1/trading/inquire-balance",
|
||||||
|
headers=self._headers(tr_id), params=params, timeout=self._timeout,
|
||||||
|
)
|
||||||
|
payload = resp.json()
|
||||||
|
except (httpx.HTTPError, ValueError):
|
||||||
|
return []
|
||||||
|
if str(payload.get("rt_cd")) != "0":
|
||||||
|
return []
|
||||||
|
rows = payload.get("output1") or []
|
||||||
|
out: list[BalanceItem] = []
|
||||||
|
for row in rows:
|
||||||
|
symbol = str(row.get("pdno") or "").strip()
|
||||||
|
qty = _safe_int(row.get("hldg_qty")) or 0
|
||||||
|
if not symbol or qty <= 0:
|
||||||
|
continue
|
||||||
|
out.append(BalanceItem(symbol=symbol, hldg_qty=qty))
|
||||||
|
return out
|
||||||
|
|
||||||
def cancel_order(self, broker_order_id: str, *, side: str, symbol: str, qty: int) -> bool:
|
def cancel_order(self, broker_order_id: str, *, side: str, symbol: str, qty: int) -> bool:
|
||||||
cano, product = self._account()
|
cano, product = self._account()
|
||||||
parts = broker_order_id.split(":")
|
parts = broker_order_id.split(":")
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import threading
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from trex_core.application.broker_port import (
|
from trex_core.application.broker_port import (
|
||||||
|
BalanceItem,
|
||||||
FillStatus,
|
FillStatus,
|
||||||
OrderAck,
|
OrderAck,
|
||||||
OrderRequest,
|
OrderRequest,
|
||||||
@@ -94,3 +95,19 @@ class PaperBroker:
|
|||||||
return False
|
return False
|
||||||
o.cancelled = True
|
o.cancelled = True
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def get_balance(self) -> list[BalanceItem]:
|
||||||
|
"""Deterministic per-symbol fill aggregate (REQ-031 AC-3).
|
||||||
|
|
||||||
|
Aggregates buys − sells across all filled (or partially filled)
|
||||||
|
paper orders, drops zero-qty rows (Design Decision 7).
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
balances: dict[str, int] = {}
|
||||||
|
for o in self._orders.values():
|
||||||
|
if o.filled <= 0:
|
||||||
|
continue
|
||||||
|
sign = 1 if o.side == "buy" else -1
|
||||||
|
balances[o.symbol] = balances.get(o.symbol, 0) + sign * int(o.filled)
|
||||||
|
return [BalanceItem(symbol=sym, hldg_qty=qty)
|
||||||
|
for sym, qty in balances.items() if qty != 0]
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ from trex_core.adapters.messaging.topology import (
|
|||||||
EVENTS_DLQ_QUEUE,
|
EVENTS_DLQ_QUEUE,
|
||||||
EVENTS_EXCHANGE,
|
EVENTS_EXCHANGE,
|
||||||
INTENT_REQ_QUEUE,
|
INTENT_REQ_QUEUE,
|
||||||
|
PAUSE_REQ_QUEUE,
|
||||||
SNAPSHOT_REQ_QUEUE,
|
SNAPSHOT_REQ_QUEUE,
|
||||||
STRATEGY_QUEUE_ARGS,
|
STRATEGY_QUEUE_ARGS,
|
||||||
)
|
)
|
||||||
@@ -136,6 +137,11 @@ class RabbitMQTransport:
|
|||||||
durable=True,
|
durable=True,
|
||||||
arguments={"x-dead-letter-exchange": EVENTS_DLQ_EXCHANGE},
|
arguments={"x-dead-letter-exchange": EVENTS_DLQ_EXCHANGE},
|
||||||
)
|
)
|
||||||
|
ch.queue_declare(
|
||||||
|
PAUSE_REQ_QUEUE,
|
||||||
|
durable=True,
|
||||||
|
arguments={"x-dead-letter-exchange": EVENTS_DLQ_EXCHANGE},
|
||||||
|
)
|
||||||
|
|
||||||
def declare_strategy_queue(self, queue: str, binding_pattern: str) -> None:
|
def declare_strategy_queue(self, queue: str, binding_pattern: str) -> None:
|
||||||
"""Per-strategy durable queue (REQ-023 AC-4) bound to events exchange."""
|
"""Per-strategy durable queue (REQ-023 AC-4) bound to events exchange."""
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from __future__ import annotations
|
|||||||
# durable for simplicity since the queue itself is shared) ---
|
# durable for simplicity since the queue itself is shared) ---
|
||||||
SNAPSHOT_REQ_QUEUE = "trex.snapshot.req"
|
SNAPSHOT_REQ_QUEUE = "trex.snapshot.req"
|
||||||
INTENT_REQ_QUEUE = "trex.intent.req"
|
INTENT_REQ_QUEUE = "trex.intent.req"
|
||||||
|
PAUSE_REQ_QUEUE = "trex.pause.req" # BAT-008 / REQ-032 control RPC
|
||||||
|
|
||||||
# --- Events ---
|
# --- Events ---
|
||||||
EVENTS_EXCHANGE = "trex.events" # topic, durable
|
EVENTS_EXCHANGE = "trex.events" # topic, durable
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- BAT-008 / REQ-032: durable manual pause overlay for snapshot read responses.
|
||||||
|
-- pause unit = portfolio_id (= asset_id). Survives daemon restart and the
|
||||||
|
-- daily snapshot refresh; only CLI / RPC control messages mutate it.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS snapshot_pause (
|
||||||
|
portfolio_id TEXT PRIMARY KEY,
|
||||||
|
actor TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT (now() AT TIME ZONE 'UTC'),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT (now() AT TIME ZONE 'UTC')
|
||||||
|
);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""portfolio-hub read adapters (REQ-027, dcy-005)."""
|
||||||
|
from trex_core.adapters.portfolio.hub_read import PortfolioHubReadAdapter
|
||||||
|
from trex_core.adapters.portfolio.pause_store import PostgreSQLPauseStore
|
||||||
|
from trex_core.adapters.portfolio.stub_read import StubPortfolioRead
|
||||||
|
|
||||||
|
__all__ = ["PortfolioHubReadAdapter", "PostgreSQLPauseStore", "StubPortfolioRead"]
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""portfolio-hub read HTTP adapter (REQ-027 AC-1/AC-4).
|
||||||
|
|
||||||
|
Mirrors portfolio-hub `list_portfolios_v1`:
|
||||||
|
GET /v1/portfolios?managed_by_daemon_id=<id>
|
||||||
|
GET /v1/auth/whoami (returns {"daemon_id": "..."})
|
||||||
|
|
||||||
|
Credentials reused from BAT-006 (TREX_PORTFOLIO_HUB_*). All failures lift
|
||||||
|
to PortfolioHubUnavailableError so the snapshot store can set the degraded
|
||||||
|
signal (REQ-028 AC-2) without callers seeing raw httpx exceptions.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from trex_core.application.portfolio import (
|
||||||
|
PortfolioHubUnavailableError,
|
||||||
|
PortfolioItem,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PortfolioHubReadAdapter:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str,
|
||||||
|
*,
|
||||||
|
token: Optional[str] = None,
|
||||||
|
timeout: float = 5.0,
|
||||||
|
client: Optional[httpx.Client] = None,
|
||||||
|
) -> None:
|
||||||
|
self._base_url = base_url.rstrip("/")
|
||||||
|
self._token = token
|
||||||
|
self._timeout = timeout
|
||||||
|
self._client = client
|
||||||
|
|
||||||
|
def _headers(self) -> dict[str, str]:
|
||||||
|
h = {"accept": "application/json"}
|
||||||
|
if self._token:
|
||||||
|
h["authorization"] = f"Bearer {self._token}"
|
||||||
|
return h
|
||||||
|
|
||||||
|
def _get(self, url: str, *, params: Optional[dict] = None) -> httpx.Response:
|
||||||
|
if self._client is not None:
|
||||||
|
return self._client.get(url, params=params, headers=self._headers(),
|
||||||
|
timeout=self._timeout)
|
||||||
|
return httpx.get(url, params=params, headers=self._headers(),
|
||||||
|
timeout=self._timeout)
|
||||||
|
|
||||||
|
def whoami(self) -> str:
|
||||||
|
url = f"{self._base_url}/v1/auth/whoami"
|
||||||
|
try:
|
||||||
|
resp = self._get(url)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise PortfolioHubUnavailableError(
|
||||||
|
f"whoami network error: {exc.__class__.__name__}"
|
||||||
|
) from exc
|
||||||
|
if not (200 <= resp.status_code < 300):
|
||||||
|
raise PortfolioHubUnavailableError(
|
||||||
|
f"whoami {resp.status_code}: {resp.text[:120]}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
body = resp.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise PortfolioHubUnavailableError("whoami response not JSON") from exc
|
||||||
|
did = body.get("daemon_id") if isinstance(body, dict) else None
|
||||||
|
if not did:
|
||||||
|
raise PortfolioHubUnavailableError("whoami missing daemon_id")
|
||||||
|
return str(did)
|
||||||
|
|
||||||
|
def get_portfolios(self, daemon_id: str) -> list[PortfolioItem]:
|
||||||
|
url = f"{self._base_url}/v1/portfolios"
|
||||||
|
try:
|
||||||
|
resp = self._get(url, params={"managed_by_daemon_id": daemon_id})
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise PortfolioHubUnavailableError(
|
||||||
|
f"get_portfolios network error: {exc.__class__.__name__}"
|
||||||
|
) from exc
|
||||||
|
if not (200 <= resp.status_code < 300):
|
||||||
|
raise PortfolioHubUnavailableError(
|
||||||
|
f"get_portfolios {resp.status_code}: {resp.text[:120]}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
body = resp.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise PortfolioHubUnavailableError("get_portfolios not JSON") from exc
|
||||||
|
|
||||||
|
items_raw = body.get("items", body) if isinstance(body, dict) else body
|
||||||
|
if not isinstance(items_raw, list):
|
||||||
|
raise PortfolioHubUnavailableError("get_portfolios shape unexpected")
|
||||||
|
|
||||||
|
out: list[PortfolioItem] = []
|
||||||
|
for raw in items_raw:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
continue
|
||||||
|
pid = str(raw.get("portfolio_id") or raw.get("asset_id") or "")
|
||||||
|
if not pid:
|
||||||
|
continue
|
||||||
|
out.append(PortfolioItem(
|
||||||
|
portfolio_id=pid,
|
||||||
|
symbol=str(raw.get("symbol") or ""),
|
||||||
|
cash_balance=float(raw.get("cash_balance") or 0.0),
|
||||||
|
asset_quantity=float(raw.get("asset_quantity") or 0.0),
|
||||||
|
active_strategy_id=raw.get("active_strategy_id") or None,
|
||||||
|
strategy_params=dict(raw.get("strategy_params") or {}),
|
||||||
|
))
|
||||||
|
return out
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""PostgreSQL-backed durable pause store (REQ-032 AC-2)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from trex_core.adapters.persistence.postgresql import _require_psycopg
|
||||||
|
|
||||||
|
|
||||||
|
class PostgreSQLPauseStore:
|
||||||
|
"""`snapshot_pause` table CRUD. portfolio_id PK is the gate."""
|
||||||
|
|
||||||
|
def __init__(self, dsn: str, *, psycopg_module: Any = None) -> None:
|
||||||
|
self._psycopg = _require_psycopg(psycopg_module)
|
||||||
|
self._dsn = dsn
|
||||||
|
|
||||||
|
def list_paused(self) -> set[str]:
|
||||||
|
with self._psycopg.connect(self._dsn) as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT portfolio_id FROM snapshot_pause")
|
||||||
|
return {row[0] for row in cur.fetchall()}
|
||||||
|
|
||||||
|
def pause(self, portfolio_id: str, *, actor: str = "operator") -> None:
|
||||||
|
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO snapshot_pause (portfolio_id, actor) "
|
||||||
|
"VALUES (%s, %s) "
|
||||||
|
"ON CONFLICT (portfolio_id) DO UPDATE "
|
||||||
|
"SET actor = EXCLUDED.actor, updated_at = %s",
|
||||||
|
(portfolio_id, actor, datetime.now(tz=timezone.utc)),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def resume(self, portfolio_id: str) -> None:
|
||||||
|
with self._psycopg.connect(self._dsn, autocommit=False) as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM snapshot_pause WHERE portfolio_id = %s",
|
||||||
|
(portfolio_id,),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Deterministic stub for PortfolioHubReadPort (REQ-027 AC-2)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from trex_core.application.portfolio import PortfolioItem
|
||||||
|
|
||||||
|
|
||||||
|
class StubPortfolioRead:
|
||||||
|
"""Returns a fixed universe — no network required.
|
||||||
|
|
||||||
|
`whoami()` returns the configured daemon_id; tests can override the
|
||||||
|
universe by mutating `self.items`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
daemon_id: str = "stub-daemon",
|
||||||
|
items: list[PortfolioItem] | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._daemon_id = daemon_id
|
||||||
|
self.items: list[PortfolioItem] = list(items or [
|
||||||
|
PortfolioItem(
|
||||||
|
portfolio_id="pf-005930",
|
||||||
|
symbol="005930",
|
||||||
|
cash_balance=10_000_000.0,
|
||||||
|
asset_quantity=0.0,
|
||||||
|
active_strategy_id="target-value-v1",
|
||||||
|
strategy_params={"target_value": 1_000_000},
|
||||||
|
),
|
||||||
|
PortfolioItem(
|
||||||
|
portfolio_id="pf-000660",
|
||||||
|
symbol="000660",
|
||||||
|
cash_balance=5_000_000.0,
|
||||||
|
asset_quantity=10.0,
|
||||||
|
active_strategy_id="trend-follow-v1",
|
||||||
|
strategy_params={"window": 20},
|
||||||
|
),
|
||||||
|
])
|
||||||
|
|
||||||
|
def get_portfolios(self, daemon_id: str) -> list[PortfolioItem]:
|
||||||
|
# Stub does not filter by daemon_id — every test universe is scoped.
|
||||||
|
return list(self.items)
|
||||||
|
|
||||||
|
def whoami(self) -> str:
|
||||||
|
return self._daemon_id
|
||||||
@@ -29,6 +29,18 @@ class OrderAck:
|
|||||||
raw: dict[str, Any] = field(default_factory=dict)
|
raw: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BalanceItem:
|
||||||
|
"""One symbol in a broker account balance (REQ-031, BAT-008).
|
||||||
|
|
||||||
|
KIS `TTTC8434R`/`VTTC8434R` returns per-symbol `pdno` + `hldg_qty`. The
|
||||||
|
paper broker returns a deterministic in-memory ledger keyed by symbol.
|
||||||
|
"""
|
||||||
|
symbol: str # KIS `pdno`
|
||||||
|
hldg_qty: int # held quantity (integer shares; D-2 zero-qty rows are
|
||||||
|
# treated as not-held — design Decision 7)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class FillStatus:
|
class FillStatus:
|
||||||
state: str # accepted | partially_filled | filled | rejected | cancelled | unknown
|
state: str # accepted | partially_filled | filled | rejected | cancelled | unknown
|
||||||
@@ -69,6 +81,15 @@ class BrokerPort(Protocol):
|
|||||||
"""Best-effort cancel. Returns True if the broker accepted the cancel."""
|
"""Best-effort cancel. Returns True if the broker accepted the cancel."""
|
||||||
...
|
...
|
||||||
|
|
||||||
|
def get_balance(self) -> list[BalanceItem]:
|
||||||
|
"""Per-symbol balance for the broker's single account (REQ-031).
|
||||||
|
|
||||||
|
Returns symbol+hldg_qty rows. Adapters MUST drop zero-qty rows so
|
||||||
|
D-2 leftover entries (KIS keeps `hldg_qty=0` for two days after a
|
||||||
|
full-sell) are reported as not-held (Design Decision 7).
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
# --- shared fee model (REQ-018, orbit calculate_fee pattern) -----------------
|
# --- shared fee model (REQ-018, orbit calculate_fee pattern) -----------------
|
||||||
# Conservative KR equities approximation (bps). Brokers can override, but we
|
# Conservative KR equities approximation (bps). Brokers can override, but we
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ from uuid import UUID
|
|||||||
|
|
||||||
from trex_core.application.intents import BuyIntent, IntentService, SellIntent
|
from trex_core.application.intents import BuyIntent, IntentService, SellIntent
|
||||||
from trex_core.application.messaging import RpcRequest, RpcResponse
|
from trex_core.application.messaging import RpcRequest, RpcResponse
|
||||||
|
from trex_core.application.portfolio import (
|
||||||
|
PauseStore,
|
||||||
|
SnapshotStore,
|
||||||
|
View,
|
||||||
|
build_snapshot_envelope,
|
||||||
|
)
|
||||||
from trex_core.domain.slot import SlotRepository, SlotService
|
from trex_core.domain.slot import SlotRepository, SlotService
|
||||||
|
|
||||||
_log = logging.getLogger(__name__)
|
_log = logging.getLogger(__name__)
|
||||||
@@ -89,32 +95,68 @@ def make_intent_rpc_handler(intent_service: IntentService):
|
|||||||
|
|
||||||
# ---- snapshot RPC handler ---------------------------------------------------
|
# ---- snapshot RPC handler ---------------------------------------------------
|
||||||
|
|
||||||
def make_snapshot_rpc_handler(slot_repo: SlotRepository, slot_service: SlotService):
|
def make_snapshot_rpc_handler(
|
||||||
"""Build the RPC handler for `trex.snapshot.req`.
|
slot_repo: SlotRepository,
|
||||||
|
slot_service: SlotService,
|
||||||
|
*,
|
||||||
|
snapshot_store: Optional[SnapshotStore] = None,
|
||||||
|
pause_store: Optional[PauseStore] = None,
|
||||||
|
default_total_slots: int = 10,
|
||||||
|
):
|
||||||
|
"""Build the RPC handler for `trex.snapshot.req` (REQ-028).
|
||||||
|
|
||||||
Payload shape (stub for BAT-007 — dcy-005 logical contract is not yet
|
When `snapshot_store` + `pause_store` are provided (production wiring),
|
||||||
REQ-normalized; this returns a transport-shape evidence equivalent of
|
returns the BAT-008 envelope:
|
||||||
`GET /v1/slots` + `GET /v1/capacity`):
|
|
||||||
{
|
{
|
||||||
"strategy_id": "<filter or null>",
|
"assets": list of {symbol, asset_id, cash_balance,
|
||||||
"view": "open" | "all" (default "all"),
|
asset_quantity, active_strategy_id,
|
||||||
"trading_date": "YYYY-MM-DD" (optional, default today UTC)
|
strategy_params},
|
||||||
|
"view": "all" | "buyable" | "held",
|
||||||
|
"available_slots": int,
|
||||||
|
"degraded": bool,
|
||||||
|
"reason": DegradedReason value or null,
|
||||||
|
"message": sanitized text or null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Without those, falls back to the BAT-007 slot+capacity stub shape so
|
||||||
|
older callers and existing tests don't break.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def handler(req: RpcRequest) -> RpcResponse:
|
def handler(req: RpcRequest) -> RpcResponse:
|
||||||
body = req.payload or {}
|
body = req.payload or {}
|
||||||
view = str(body.get("view") or "all")
|
|
||||||
strategy_filter = _opt_str(body.get("strategy_id"))
|
|
||||||
day = _parse_date(body.get("trading_date")) if body.get("trading_date") else _today()
|
day = _parse_date(body.get("trading_date")) if body.get("trading_date") else _today()
|
||||||
|
|
||||||
|
# --- BAT-008 envelope path (snapshot store wired) ---
|
||||||
|
if snapshot_store is not None and pause_store is not None:
|
||||||
|
requested_view = str(body.get("view") or "all").lower()
|
||||||
|
try:
|
||||||
|
view = View(requested_view)
|
||||||
|
except ValueError:
|
||||||
|
view = View.ALL
|
||||||
|
strategy_key = _opt_str(body.get("strategy_key"))
|
||||||
|
cap = slot_service.compute_slot_capacity(day, default_total_slots)
|
||||||
|
envelope = build_snapshot_envelope(
|
||||||
|
store=snapshot_store,
|
||||||
|
pause_store=pause_store,
|
||||||
|
slot_repo=slot_repo,
|
||||||
|
available_slots=cap.idle_count,
|
||||||
|
view=view,
|
||||||
|
strategy_key=strategy_key,
|
||||||
|
trading_date=day,
|
||||||
|
)
|
||||||
|
return RpcResponse(payload=envelope.to_dict())
|
||||||
|
|
||||||
|
# --- 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(day)
|
||||||
if view == "open":
|
if view_raw == "open":
|
||||||
slots = [s for s in slots if s.status.value in ("pending_buy", "holding", "pending_sell")]
|
slots = [s for s in slots if s.status.value in ("pending_buy", "holding", "pending_sell")]
|
||||||
if strategy_filter is not None:
|
if strategy_filter is not None:
|
||||||
slots = [s for s in slots if s.strategy_id == strategy_filter]
|
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(day, max(1, len(slot_repo.list_slots(day))))
|
||||||
return RpcResponse(payload={
|
return RpcResponse(payload={
|
||||||
"view": view,
|
"view": view_raw,
|
||||||
"strategy_id": strategy_filter,
|
"strategy_id": strategy_filter,
|
||||||
"trading_date": day.isoformat(),
|
"trading_date": day.isoformat(),
|
||||||
"slots": [_slot_to_dict(s) for s in slots],
|
"slots": [_slot_to_dict(s) for s in slots],
|
||||||
@@ -130,6 +172,48 @@ def make_snapshot_rpc_handler(slot_repo: SlotRepository, slot_service: SlotServi
|
|||||||
return handler
|
return handler
|
||||||
|
|
||||||
|
|
||||||
|
# ---- pause/resume RPC handler (REQ-032 AC-4) --------------------------------
|
||||||
|
|
||||||
|
def make_pause_rpc_handler(pause_store: PauseStore):
|
||||||
|
"""RPC handler for the `trex.pause.req` control queue.
|
||||||
|
|
||||||
|
Payload: {"action": "pause" | "resume" | "list", "portfolio_id": "..."}
|
||||||
|
Response: {"status": "ok"|"error", "paused": [list-of-portfolio_id], "reason"?: str}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def handler(req: RpcRequest) -> RpcResponse:
|
||||||
|
body = req.payload or {}
|
||||||
|
action = str(body.get("action") or "").lower()
|
||||||
|
pid = _opt_str(body.get("portfolio_id"))
|
||||||
|
actor = str(body.get("actor") or "operator")
|
||||||
|
try:
|
||||||
|
if action == "pause":
|
||||||
|
if not pid:
|
||||||
|
return _err("portfolio_id required")
|
||||||
|
pause_store.pause(pid, actor=actor)
|
||||||
|
elif action == "resume":
|
||||||
|
if not pid:
|
||||||
|
return _err("portfolio_id required")
|
||||||
|
pause_store.resume(pid)
|
||||||
|
elif action == "list":
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
return _err(f"unknown action: {action!r}")
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return _err(f"pause store error: {exc.__class__.__name__}")
|
||||||
|
return RpcResponse(payload={
|
||||||
|
"status": "ok",
|
||||||
|
"paused": sorted(pause_store.list_paused()),
|
||||||
|
"action": action,
|
||||||
|
})
|
||||||
|
|
||||||
|
return handler
|
||||||
|
|
||||||
|
|
||||||
|
def _err(reason: str) -> RpcResponse:
|
||||||
|
return RpcResponse(payload={"status": "error", "reason": reason})
|
||||||
|
|
||||||
|
|
||||||
# ---- event payload builder --------------------------------------------------
|
# ---- event payload builder --------------------------------------------------
|
||||||
|
|
||||||
def slot_transition_event_payload(slot, *, event_type: str) -> dict:
|
def slot_transition_event_payload(slot, *, event_type: str) -> dict:
|
||||||
|
|||||||
@@ -0,0 +1,361 @@
|
|||||||
|
"""Portfolio snapshot read + selection (REQ-027/028/029/030/031, dcy-005).
|
||||||
|
|
||||||
|
Application-layer port for reading portfolio-hub universe + in-process
|
||||||
|
snapshot store + asset-selection service. Domain stays free of HTTP and
|
||||||
|
RabbitMQ deps (NFR-1).
|
||||||
|
|
||||||
|
The snapshot is split between:
|
||||||
|
- in-memory `SnapshotStore` universe + cash/position + warnings (process-local;
|
||||||
|
warning is automatic/temporary, reset on restart)
|
||||||
|
- durable `PauseStore` portfolio_id -> paused (Postgres; manual/persistent,
|
||||||
|
survives restart and daily refresh — REQ-032)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from dataclasses import dataclass, field, replace
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Optional, Protocol
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- portfolio-hub
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PortfolioItem:
|
||||||
|
"""One asset in the daemon's portfolio universe (REQ-027 AC-1).
|
||||||
|
|
||||||
|
Mirrors portfolio-hub's `list_portfolios_v1` PortfolioItem with **no price
|
||||||
|
field** — pricing is the strategy module's concern, not core's.
|
||||||
|
"""
|
||||||
|
portfolio_id: str # == asset_id, dcy-004 OQ-1
|
||||||
|
symbol: str
|
||||||
|
cash_balance: float
|
||||||
|
asset_quantity: float # integer shares semantically; float for tolerance
|
||||||
|
active_strategy_id: Optional[str] = None
|
||||||
|
strategy_params: dict = field(default_factory=dict) # opaque, pass-through
|
||||||
|
|
||||||
|
|
||||||
|
class PortfolioHubReadError(Exception):
|
||||||
|
"""Base class for portfolio-hub read failures."""
|
||||||
|
|
||||||
|
|
||||||
|
class PortfolioHubUnavailableError(PortfolioHubReadError):
|
||||||
|
"""portfolio-hub unreachable / 5xx / timeout — degraded signal upstream."""
|
||||||
|
|
||||||
|
|
||||||
|
class PortfolioHubReadPort(Protocol):
|
||||||
|
"""Read-side contract for portfolio-hub universe (REQ-027)."""
|
||||||
|
|
||||||
|
def get_portfolios(self, daemon_id: str) -> list[PortfolioItem]:
|
||||||
|
"""Return all portfolios where `managed_by_daemon_id == daemon_id`."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def whoami(self) -> str:
|
||||||
|
"""Resolve daemon_id from the credentials/token (REQ-027 AC-4).
|
||||||
|
|
||||||
|
Called once at startup so `daemon_id` lookup never depends on
|
||||||
|
`TREX_DAEMON_ID` env (single source of truth = auth token).
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- selection enums
|
||||||
|
|
||||||
|
class View(str, Enum):
|
||||||
|
"""Snapshot view modes (REQ-029, dcy-005 OQ-11/OQ-12)."""
|
||||||
|
ALL = "all" # universe ∪ holding slots
|
||||||
|
BUYABLE = "buyable" # universe − occupied slots
|
||||||
|
HELD = "held" # holding slots only
|
||||||
|
|
||||||
|
|
||||||
|
class DegradedReason(str, Enum):
|
||||||
|
"""Machine-readable degraded reason codes (REQ-028 AC-2, dcy-005 OQ-5)."""
|
||||||
|
PORTFOLIO_HUB_UNAVAILABLE = "portfolio_hub_unavailable"
|
||||||
|
SNAPSHOT_FETCH_FAILED = "snapshot_fetch_failed"
|
||||||
|
CASH_POSITION_STALE = "cash_position_stale"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- snapshot store
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SnapshotAsset:
|
||||||
|
"""A portfolio item augmented with overlays (warning, sequence)."""
|
||||||
|
item: PortfolioItem
|
||||||
|
warning: bool = False
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SnapshotStore:
|
||||||
|
"""In-memory snapshot — universe baseline + warning overlay.
|
||||||
|
|
||||||
|
Two-phase: callers `swap(new_universe)` to atomically replace the universe
|
||||||
|
after a fetch, and `set_warning(portfolio_id, on)` to toggle the automatic
|
||||||
|
warning overlay (REQ-031). pause overlay lives in PauseStore (REQ-032).
|
||||||
|
|
||||||
|
Thread-safe; the snapshot RPC handler can read concurrently with the
|
||||||
|
scheduler refresh.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._assets: dict[str, SnapshotAsset] = {}
|
||||||
|
self._degraded: Optional[DegradedReason] = None
|
||||||
|
self._degraded_message: Optional[str] = None
|
||||||
|
self._last_refresh_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
# --- universe ---
|
||||||
|
def swap(self, items: list[PortfolioItem]) -> None:
|
||||||
|
"""Replace the entire universe atomically (immutable swap, Decision 6)."""
|
||||||
|
now = datetime.now(tz=timezone.utc)
|
||||||
|
with self._lock:
|
||||||
|
prior_warnings = {pid for pid, a in self._assets.items() if a.warning}
|
||||||
|
new_assets: dict[str, SnapshotAsset] = {}
|
||||||
|
for item in items:
|
||||||
|
# Carry forward warning if the same portfolio was warned before;
|
||||||
|
# reconciliation will clear it on the next successful check.
|
||||||
|
warn = item.portfolio_id in prior_warnings
|
||||||
|
new_assets[item.portfolio_id] = SnapshotAsset(
|
||||||
|
item=item, warning=warn, updated_at=now,
|
||||||
|
)
|
||||||
|
self._assets = new_assets
|
||||||
|
self._degraded = None
|
||||||
|
self._degraded_message = None
|
||||||
|
self._last_refresh_at = now
|
||||||
|
|
||||||
|
def list_assets(self) -> list[SnapshotAsset]:
|
||||||
|
with self._lock:
|
||||||
|
return list(self._assets.values())
|
||||||
|
|
||||||
|
def get(self, portfolio_id: str) -> Optional[SnapshotAsset]:
|
||||||
|
with self._lock:
|
||||||
|
return self._assets.get(portfolio_id)
|
||||||
|
|
||||||
|
# --- warning overlay (automatic, in-memory only) ---
|
||||||
|
def set_warning(self, portfolio_id: str, on: bool) -> None:
|
||||||
|
with self._lock:
|
||||||
|
a = self._assets.get(portfolio_id)
|
||||||
|
if a is None or a.warning is on:
|
||||||
|
return
|
||||||
|
self._assets[portfolio_id] = replace(a, warning=on)
|
||||||
|
|
||||||
|
def warnings(self) -> set[str]:
|
||||||
|
with self._lock:
|
||||||
|
return {pid for pid, a in self._assets.items() if a.warning}
|
||||||
|
|
||||||
|
# --- option D cash/position (REQ-030 AC-2/AC-3 — wired but no-op until
|
||||||
|
# portfolio-hub#8 ships) ---
|
||||||
|
def update_cash_position(
|
||||||
|
self,
|
||||||
|
portfolio_id: str,
|
||||||
|
cash_balance: float,
|
||||||
|
asset_quantity: float,
|
||||||
|
) -> None:
|
||||||
|
with self._lock:
|
||||||
|
a = self._assets.get(portfolio_id)
|
||||||
|
if a is None:
|
||||||
|
return
|
||||||
|
self._assets[portfolio_id] = replace(
|
||||||
|
a, item=replace(a.item, cash_balance=cash_balance,
|
||||||
|
asset_quantity=asset_quantity),
|
||||||
|
updated_at=datetime.now(tz=timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- degraded signal (REQ-028 AC-2) ---
|
||||||
|
def set_degraded(self, reason: DegradedReason, message: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._degraded = reason
|
||||||
|
self._degraded_message = _sanitize(message)
|
||||||
|
|
||||||
|
def clear_degraded(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._degraded = None
|
||||||
|
self._degraded_message = None
|
||||||
|
|
||||||
|
def degraded_state(self) -> tuple[Optional[DegradedReason], Optional[str]]:
|
||||||
|
with self._lock:
|
||||||
|
return self._degraded, self._degraded_message
|
||||||
|
|
||||||
|
def last_refresh_at(self) -> Optional[datetime]:
|
||||||
|
with self._lock:
|
||||||
|
return self._last_refresh_at
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- pause store
|
||||||
|
|
||||||
|
class PauseStore(Protocol):
|
||||||
|
"""Durable pause overlay — `portfolio_id` PK (REQ-032)."""
|
||||||
|
|
||||||
|
def list_paused(self) -> set[str]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def pause(self, portfolio_id: str, *, actor: str = "operator") -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
def resume(self, portfolio_id: str) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class InMemoryPauseStore:
|
||||||
|
"""Process-local pause store for tests + CI."""
|
||||||
|
|
||||||
|
def __init__(self, initial: Optional[set[str]] = None) -> None:
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._paused: set[str] = set(initial or set())
|
||||||
|
|
||||||
|
def list_paused(self) -> set[str]:
|
||||||
|
with self._lock:
|
||||||
|
return set(self._paused)
|
||||||
|
|
||||||
|
def pause(self, portfolio_id: str, *, actor: str = "operator") -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._paused.add(portfolio_id)
|
||||||
|
|
||||||
|
def resume(self, portfolio_id: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._paused.discard(portfolio_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- selection
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SnapshotEnvelope:
|
||||||
|
"""REQ-028 response envelope (transport-agnostic)."""
|
||||||
|
assets: list[dict]
|
||||||
|
view: str
|
||||||
|
available_slots: int
|
||||||
|
degraded: bool
|
||||||
|
reason: Optional[str]
|
||||||
|
message: Optional[str] = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"assets": list(self.assets),
|
||||||
|
"view": self.view,
|
||||||
|
"available_slots": self.available_slots,
|
||||||
|
"degraded": self.degraded,
|
||||||
|
"reason": self.reason,
|
||||||
|
"message": self.message,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _asset_to_dict(item: PortfolioItem) -> dict:
|
||||||
|
"""REQ-027 AC-1 / REQ-028 AC-3 — strict no-price field shape."""
|
||||||
|
return {
|
||||||
|
"symbol": item.symbol,
|
||||||
|
"asset_id": item.portfolio_id,
|
||||||
|
"cash_balance": item.cash_balance,
|
||||||
|
"asset_quantity": item.asset_quantity,
|
||||||
|
"active_strategy_id": item.active_strategy_id,
|
||||||
|
"strategy_params": dict(item.strategy_params or {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize(message: str) -> str:
|
||||||
|
"""Strip credentials/URLs/tokens — REQ-028 AC-2 sanitization."""
|
||||||
|
if not message:
|
||||||
|
return ""
|
||||||
|
out = message
|
||||||
|
for needle in ("authorization:", "Bearer ", "token=", "appsecret="):
|
||||||
|
idx = out.lower().find(needle.lower())
|
||||||
|
if idx >= 0:
|
||||||
|
out = out[:idx] + "<redacted>"
|
||||||
|
break
|
||||||
|
# Truncate to keep response payload bounded.
|
||||||
|
return out[:200]
|
||||||
|
|
||||||
|
|
||||||
|
def build_snapshot_envelope(
|
||||||
|
*,
|
||||||
|
store: SnapshotStore,
|
||||||
|
pause_store: PauseStore,
|
||||||
|
slot_repo,
|
||||||
|
available_slots: int,
|
||||||
|
view: View = View.ALL,
|
||||||
|
strategy_key: Optional[str] = None,
|
||||||
|
trading_date=None,
|
||||||
|
) -> SnapshotEnvelope:
|
||||||
|
"""Compose base(view) − mask(pending·warning·pause) − strategy_key (REQ-029).
|
||||||
|
|
||||||
|
`slot_repo` is the SlotRepository used to read holding/pending state.
|
||||||
|
`trading_date` defaults to UTC today (matches the daemon's preallocate).
|
||||||
|
"""
|
||||||
|
# Read core authoritative slot state once.
|
||||||
|
day = trading_date or datetime.now(tz=timezone.utc).date()
|
||||||
|
slots = slot_repo.list_slots(day)
|
||||||
|
holding_pids: set[str] = set()
|
||||||
|
pending_pids: set[str] = set()
|
||||||
|
holding_qty: dict[str, float] = {}
|
||||||
|
for s in slots:
|
||||||
|
if s.portfolio_id is None:
|
||||||
|
continue
|
||||||
|
if s.status.value == "holding":
|
||||||
|
holding_pids.add(s.portfolio_id)
|
||||||
|
holding_qty[s.portfolio_id] = float(s.qty or 0.0)
|
||||||
|
elif s.status.value in ("pending_buy", "pending_sell"):
|
||||||
|
pending_pids.add(s.portfolio_id)
|
||||||
|
|
||||||
|
assets = store.list_assets()
|
||||||
|
paused = pause_store.list_paused()
|
||||||
|
warned = {a.item.portfolio_id for a in assets if a.warning}
|
||||||
|
|
||||||
|
# Build the base set (view).
|
||||||
|
by_pid = {a.item.portfolio_id: a for a in assets}
|
||||||
|
base_pids: list[str]
|
||||||
|
if view is View.HELD:
|
||||||
|
base_pids = sorted(holding_pids)
|
||||||
|
elif view is View.BUYABLE:
|
||||||
|
if available_slots <= 0:
|
||||||
|
base_pids = []
|
||||||
|
else:
|
||||||
|
# universe − occupied slots (pending OR holding occupy)
|
||||||
|
occupied = holding_pids | pending_pids
|
||||||
|
base_pids = [a.item.portfolio_id for a in assets
|
||||||
|
if a.item.portfolio_id not in occupied]
|
||||||
|
else: # ALL
|
||||||
|
base_pids = list({a.item.portfolio_id for a in assets} | holding_pids)
|
||||||
|
base_pids.sort()
|
||||||
|
|
||||||
|
# Apply masks: pending OR warning OR pause.
|
||||||
|
masked = pending_pids | warned | paused
|
||||||
|
|
||||||
|
# Filter and shape.
|
||||||
|
out: list[dict] = []
|
||||||
|
for pid in base_pids:
|
||||||
|
if pid in masked:
|
||||||
|
continue
|
||||||
|
if pid in by_pid:
|
||||||
|
item = by_pid[pid].item
|
||||||
|
elif pid in holding_pids:
|
||||||
|
# holding slot for an asset NOT in the current universe (e.g. fetch
|
||||||
|
# gap) — synthesize a minimal item using slot data so the response
|
||||||
|
# still carries authoritative position info (REQ-029 slot overlay
|
||||||
|
# priority over portfolio-hub presence).
|
||||||
|
slot = next((s for s in slots if s.portfolio_id == pid), None)
|
||||||
|
item = PortfolioItem(
|
||||||
|
portfolio_id=pid,
|
||||||
|
symbol=(slot.symbol if slot else "") or "",
|
||||||
|
cash_balance=0.0,
|
||||||
|
asset_quantity=float((slot.qty if slot else 0.0) or 0.0),
|
||||||
|
active_strategy_id=(slot.strategy_id if slot else None),
|
||||||
|
strategy_params={},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
if strategy_key is not None and item.active_strategy_id != strategy_key:
|
||||||
|
continue
|
||||||
|
d = _asset_to_dict(item)
|
||||||
|
# holding slot quantity wins for held assets (slot-overlay priority).
|
||||||
|
if pid in holding_qty:
|
||||||
|
d["asset_quantity"] = holding_qty[pid]
|
||||||
|
out.append(d)
|
||||||
|
|
||||||
|
reason_code, message = store.degraded_state()
|
||||||
|
return SnapshotEnvelope(
|
||||||
|
assets=out,
|
||||||
|
view=view.value,
|
||||||
|
available_slots=max(0, int(available_slots)),
|
||||||
|
degraded=reason_code is not None,
|
||||||
|
reason=reason_code.value if reason_code is not None else None,
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Position reconciliation between portfolio-hub and broker (REQ-031).
|
||||||
|
|
||||||
|
The broker account is single (invariant B) so portfolio-hub quantities are
|
||||||
|
aggregated to `symbol` before being compared with `BrokerPort.get_balance()`.
|
||||||
|
Mismatches flip the asset's `warning` overlay; a subsequent successful match
|
||||||
|
clears it automatically (in-memory only — pause is the durable channel for
|
||||||
|
manual holds).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from trex_core.application.broker_port import BrokerPort
|
||||||
|
from trex_core.application.portfolio import SnapshotStore
|
||||||
|
|
||||||
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def reconcile_positions(*, store: SnapshotStore, broker: BrokerPort) -> dict[str, str]:
|
||||||
|
"""Run one reconciliation pass.
|
||||||
|
|
||||||
|
Returns a `{portfolio_id: 'warning' | 'cleared' | 'ok'}` map for telemetry.
|
||||||
|
The map is also reflected onto `store` via `set_warning(...)`.
|
||||||
|
"""
|
||||||
|
broker_qty: dict[str, int] = {
|
||||||
|
item.symbol: int(item.hldg_qty) for item in broker.get_balance()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Group portfolio-hub quantities by symbol so we can compare to broker's
|
||||||
|
# per-symbol aggregate.
|
||||||
|
by_symbol: dict[str, list[tuple[str, float]]] = {}
|
||||||
|
assets = store.list_assets()
|
||||||
|
for a in assets:
|
||||||
|
sym = a.item.symbol
|
||||||
|
by_symbol.setdefault(sym, []).append((a.item.portfolio_id, a.item.asset_quantity))
|
||||||
|
|
||||||
|
decisions: dict[str, str] = {}
|
||||||
|
for symbol, entries in by_symbol.items():
|
||||||
|
hub_total = int(sum(qty for _, qty in entries))
|
||||||
|
broker_total = broker_qty.get(symbol, 0)
|
||||||
|
match = hub_total == broker_total
|
||||||
|
for pid, _ in entries:
|
||||||
|
asset = store.get(pid)
|
||||||
|
was_warned = bool(asset and asset.warning)
|
||||||
|
if not match:
|
||||||
|
if not was_warned:
|
||||||
|
_log.warning(
|
||||||
|
"position warning raised: portfolio_id=%s symbol=%s "
|
||||||
|
"hub=%d broker=%d", pid, symbol, hub_total, broker_total,
|
||||||
|
)
|
||||||
|
store.set_warning(pid, True)
|
||||||
|
decisions[pid] = "warning"
|
||||||
|
else:
|
||||||
|
if was_warned:
|
||||||
|
store.set_warning(pid, False)
|
||||||
|
decisions[pid] = "cleared"
|
||||||
|
else:
|
||||||
|
decisions[pid] = "ok"
|
||||||
|
return decisions
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Daily snapshot refresh scheduler (REQ-030, dcy-005 FR-6).
|
||||||
|
|
||||||
|
Wakes at the configured KST time, fetches universe from PortfolioHubReadPort,
|
||||||
|
swaps the SnapshotStore, then runs reconciliation. Option D `*_after` writes
|
||||||
|
(REQ-030 AC-2/AC-3) are wired through `SnapshotStore.update_cash_position`
|
||||||
|
but disabled at the ledger layer until portfolio-hub#8 ships.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from datetime import datetime, time, timedelta, timezone
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
from trex_core.application.broker_port import BrokerPort
|
||||||
|
from trex_core.application.portfolio import (
|
||||||
|
DegradedReason,
|
||||||
|
PortfolioHubReadPort,
|
||||||
|
PortfolioHubUnavailableError,
|
||||||
|
SnapshotStore,
|
||||||
|
)
|
||||||
|
from trex_core.application.reconciliation import reconcile_positions
|
||||||
|
|
||||||
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_KST = timezone(timedelta(hours=9))
|
||||||
|
|
||||||
|
|
||||||
|
class SnapshotScheduler:
|
||||||
|
"""Background-thread-friendly scheduler. Use `run_once()` for tests."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
read_port: PortfolioHubReadPort,
|
||||||
|
store: SnapshotStore,
|
||||||
|
broker: BrokerPort,
|
||||||
|
schedule_kst: time = time(hour=7, minute=30),
|
||||||
|
clock: Optional[Callable[[], datetime]] = None,
|
||||||
|
) -> None:
|
||||||
|
self._read = read_port
|
||||||
|
self._store = store
|
||||||
|
self._broker = broker
|
||||||
|
self._schedule_kst = schedule_kst
|
||||||
|
self._clock = clock or (lambda: datetime.now(tz=timezone.utc))
|
||||||
|
self._daemon_id: Optional[str] = None
|
||||||
|
self._last_fired_date: Optional[datetime] = None
|
||||||
|
|
||||||
|
# ---- callable from a daemon thread loop --------------------------------
|
||||||
|
def run_forever(self, stop: threading.Event, *, tick_sec: float = 60.0) -> None:
|
||||||
|
# Boot fetch: do an immediate refresh so the first snapshot is alive
|
||||||
|
# before the scheduled time, then enter the cadence.
|
||||||
|
self.run_once()
|
||||||
|
while not stop.wait(tick_sec):
|
||||||
|
try:
|
||||||
|
self._maybe_tick()
|
||||||
|
except Exception: # noqa: BLE001 - loop must survive transient errors
|
||||||
|
_log.exception("snapshot scheduler tick failed")
|
||||||
|
|
||||||
|
def _maybe_tick(self) -> None:
|
||||||
|
now_kst = self._clock().astimezone(_KST)
|
||||||
|
target = datetime.combine(now_kst.date(), self._schedule_kst, tzinfo=_KST)
|
||||||
|
if now_kst < target:
|
||||||
|
return
|
||||||
|
last = self._last_fired_date
|
||||||
|
if last is not None and last.date() == now_kst.date():
|
||||||
|
return # already fired today
|
||||||
|
self.run_once()
|
||||||
|
|
||||||
|
# ---- the actual refresh -----------------------------------------------
|
||||||
|
def run_once(self) -> bool:
|
||||||
|
"""Fetch + swap + reconcile. Returns True on success, False if degraded."""
|
||||||
|
if self._daemon_id is None:
|
||||||
|
try:
|
||||||
|
self._daemon_id = self._read.whoami()
|
||||||
|
except PortfolioHubUnavailableError as exc:
|
||||||
|
self._store.set_degraded(
|
||||||
|
DegradedReason.PORTFOLIO_HUB_UNAVAILABLE, str(exc)
|
||||||
|
)
|
||||||
|
_log.warning("whoami failed: %s", exc)
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
items = self._read.get_portfolios(self._daemon_id)
|
||||||
|
except PortfolioHubUnavailableError as exc:
|
||||||
|
self._store.set_degraded(
|
||||||
|
DegradedReason.PORTFOLIO_HUB_UNAVAILABLE, str(exc)
|
||||||
|
)
|
||||||
|
_log.warning("get_portfolios failed: %s", exc)
|
||||||
|
return False
|
||||||
|
except Exception as exc: # noqa: BLE001 - shape/parsing failure
|
||||||
|
self._store.set_degraded(
|
||||||
|
DegradedReason.SNAPSHOT_FETCH_FAILED, exc.__class__.__name__
|
||||||
|
)
|
||||||
|
_log.exception("snapshot fetch shape failure")
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._store.swap(items)
|
||||||
|
try:
|
||||||
|
reconcile_positions(store=self._store, broker=self._broker)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
_log.exception("reconciliation pass failed")
|
||||||
|
self._last_fired_date = self._clock()
|
||||||
|
return True
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""trex-core CLI entrypoints (REQ-032).
|
||||||
|
|
||||||
|
Subcommands:
|
||||||
|
pause <portfolio_id> — durably pause a portfolio (Postgres)
|
||||||
|
resume <portfolio_id> — clear the pause
|
||||||
|
paused — list paused portfolio_ids
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from trex_core.adapters.portfolio.pause_store import PostgreSQLPauseStore
|
||||||
|
from trex_core.runtime.config import DaemonConfig
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(prog="trex-core", description="trex-core admin CLI")
|
||||||
|
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||||
|
|
||||||
|
p_pause = sub.add_parser("pause", help="Pause a portfolio_id")
|
||||||
|
p_pause.add_argument("portfolio_id")
|
||||||
|
p_pause.add_argument("--actor", default="cli")
|
||||||
|
|
||||||
|
p_resume = sub.add_parser("resume", help="Resume a portfolio_id")
|
||||||
|
p_resume.add_argument("portfolio_id")
|
||||||
|
|
||||||
|
sub.add_parser("paused", help="List paused portfolio_ids")
|
||||||
|
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
cfg = DaemonConfig.from_env()
|
||||||
|
store = PostgreSQLPauseStore(cfg.dsn)
|
||||||
|
|
||||||
|
if args.cmd == "pause":
|
||||||
|
store.pause(args.portfolio_id, actor=args.actor)
|
||||||
|
print(f"paused {args.portfolio_id}")
|
||||||
|
elif args.cmd == "resume":
|
||||||
|
store.resume(args.portfolio_id)
|
||||||
|
print(f"resumed {args.portfolio_id}")
|
||||||
|
elif args.cmd == "paused":
|
||||||
|
for pid in sorted(store.list_paused()):
|
||||||
|
print(pid)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
sys.exit(main())
|
||||||
@@ -48,6 +48,14 @@ class RabbitMQConfig:
|
|||||||
timeout: float = 5.0
|
timeout: float = 5.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SnapshotScheduleConfig:
|
||||||
|
"""Daily snapshot refresh schedule (REQ-030)."""
|
||||||
|
|
||||||
|
kst_time: str = "07:30"
|
||||||
|
tick_sec: float = 60.0
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class DaemonConfig:
|
class DaemonConfig:
|
||||||
dsn: str
|
dsn: str
|
||||||
@@ -62,6 +70,8 @@ class DaemonConfig:
|
|||||||
# REQ-022 (BAT-007): RabbitMQ transport config. Required by the new
|
# REQ-022 (BAT-007): RabbitMQ transport config. Required by the new
|
||||||
# sync+thread daemon — HTTP API has been removed (REQ-025).
|
# sync+thread daemon — HTTP API has been removed (REQ-025).
|
||||||
rabbitmq: Optional[RabbitMQConfig] = None
|
rabbitmq: Optional[RabbitMQConfig] = None
|
||||||
|
# REQ-030 (BAT-008): daily snapshot refresh.
|
||||||
|
snapshot_schedule: SnapshotScheduleConfig = SnapshotScheduleConfig()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_env() -> "DaemonConfig":
|
def from_env() -> "DaemonConfig":
|
||||||
@@ -98,6 +108,10 @@ class DaemonConfig:
|
|||||||
url=mq_url,
|
url=mq_url,
|
||||||
timeout=float(os.environ.get("TREX_RABBITMQ_TIMEOUT", "5")),
|
timeout=float(os.environ.get("TREX_RABBITMQ_TIMEOUT", "5")),
|
||||||
)
|
)
|
||||||
|
snapshot_schedule = SnapshotScheduleConfig(
|
||||||
|
kst_time=os.environ.get("TREX_SNAPSHOT_SCHEDULE_KST", "07:30").strip() or "07:30",
|
||||||
|
tick_sec=float(os.environ.get("TREX_SNAPSHOT_TICK_SEC", "60")),
|
||||||
|
)
|
||||||
return DaemonConfig(
|
return DaemonConfig(
|
||||||
dsn=dsn,
|
dsn=dsn,
|
||||||
total_slots=int(os.environ.get("TREX_TOTAL_SLOTS", "10")),
|
total_slots=int(os.environ.get("TREX_TOTAL_SLOTS", "10")),
|
||||||
@@ -107,4 +121,5 @@ class DaemonConfig:
|
|||||||
portfolio_hub=portfolio_hub,
|
portfolio_hub=portfolio_hub,
|
||||||
ledger_interval_sec=float(os.environ.get("TREX_LEDGER_INTERVAL_SEC", "1.0")),
|
ledger_interval_sec=float(os.environ.get("TREX_LEDGER_INTERVAL_SEC", "1.0")),
|
||||||
rabbitmq=rabbitmq,
|
rabbitmq=rabbitmq,
|
||||||
|
snapshot_schedule=snapshot_schedule,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import signal
|
|||||||
import threading
|
import threading
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from datetime import time as time_cls
|
||||||
|
|
||||||
from trex_core.adapters.broker.paper import PaperBroker
|
from trex_core.adapters.broker.paper import PaperBroker
|
||||||
from trex_core.adapters.ledger import InMemoryLedger, PortfolioHubLedger
|
from trex_core.adapters.ledger import InMemoryLedger, PortfolioHubLedger
|
||||||
from trex_core.adapters.messaging import (
|
from trex_core.adapters.messaging import (
|
||||||
@@ -33,6 +35,11 @@ from trex_core.adapters.persistence.postgresql import (
|
|||||||
PostgreSQLLedgerOutbox,
|
PostgreSQLLedgerOutbox,
|
||||||
PostgreSQLSlotRepository,
|
PostgreSQLSlotRepository,
|
||||||
)
|
)
|
||||||
|
from trex_core.adapters.portfolio import (
|
||||||
|
PortfolioHubReadAdapter,
|
||||||
|
PostgreSQLPauseStore,
|
||||||
|
StubPortfolioRead,
|
||||||
|
)
|
||||||
from trex_core.application.broker_port import BrokerPort
|
from trex_core.application.broker_port import BrokerPort
|
||||||
from trex_core.application.execution import ExecutionService
|
from trex_core.application.execution import ExecutionService
|
||||||
from trex_core.application.intents import IntentService
|
from trex_core.application.intents import IntentService
|
||||||
@@ -41,9 +48,15 @@ from trex_core.application.ledger_port import LedgerPort
|
|||||||
from trex_core.application.messaging import MessagingTransport
|
from trex_core.application.messaging import MessagingTransport
|
||||||
from trex_core.application.messaging_handlers import (
|
from trex_core.application.messaging_handlers import (
|
||||||
make_intent_rpc_handler,
|
make_intent_rpc_handler,
|
||||||
|
make_pause_rpc_handler,
|
||||||
make_snapshot_rpc_handler,
|
make_snapshot_rpc_handler,
|
||||||
slot_transition_event_payload,
|
slot_transition_event_payload,
|
||||||
)
|
)
|
||||||
|
from trex_core.application.portfolio import (
|
||||||
|
PortfolioHubReadPort,
|
||||||
|
SnapshotStore,
|
||||||
|
)
|
||||||
|
from trex_core.application.snapshot_scheduler import SnapshotScheduler
|
||||||
from trex_core.domain.slot import SlotService
|
from trex_core.domain.slot import SlotService
|
||||||
from trex_core.runtime.config import DaemonConfig
|
from trex_core.runtime.config import DaemonConfig
|
||||||
|
|
||||||
@@ -86,6 +99,23 @@ def _build_transport(config: DaemonConfig) -> MessagingTransport:
|
|||||||
return RabbitMQTransport(mq.url, timeout=mq.timeout)
|
return RabbitMQTransport(mq.url, timeout=mq.timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_portfolio_read(config: DaemonConfig) -> PortfolioHubReadPort:
|
||||||
|
"""portfolio-hub HTTP read adapter when configured; otherwise stub (REQ-027)."""
|
||||||
|
pf = config.portfolio_hub
|
||||||
|
if pf is None:
|
||||||
|
return StubPortfolioRead()
|
||||||
|
return PortfolioHubReadAdapter(
|
||||||
|
pf.base_url, token=pf.token or None, timeout=pf.timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_kst_time(spec: str) -> time_cls:
|
||||||
|
parts = spec.split(":")
|
||||||
|
h = int(parts[0])
|
||||||
|
m = int(parts[1]) if len(parts) > 1 else 0
|
||||||
|
return time_cls(hour=h, minute=m)
|
||||||
|
|
||||||
|
|
||||||
class Daemon:
|
class Daemon:
|
||||||
"""Thread-orchestrating daemon (no HTTP)."""
|
"""Thread-orchestrating daemon (no HTTP)."""
|
||||||
|
|
||||||
@@ -104,6 +134,17 @@ class Daemon:
|
|||||||
self.outbox = PostgreSQLLedgerOutbox(config.dsn)
|
self.outbox = PostgreSQLLedgerOutbox(config.dsn)
|
||||||
self.ledger_writer = LedgerWriter(self.outbox, _build_ledger(config))
|
self.ledger_writer = LedgerWriter(self.outbox, _build_ledger(config))
|
||||||
|
|
||||||
|
# BAT-008: portfolio-hub read + snapshot store + durable pause + scheduler.
|
||||||
|
self.snapshot_store = SnapshotStore()
|
||||||
|
self.pause_store = PostgreSQLPauseStore(config.dsn)
|
||||||
|
self.portfolio_read = _build_portfolio_read(config)
|
||||||
|
self.snapshot_scheduler = SnapshotScheduler(
|
||||||
|
read_port=self.portfolio_read,
|
||||||
|
store=self.snapshot_store,
|
||||||
|
broker=self.broker,
|
||||||
|
schedule_kst=_parse_kst_time(config.snapshot_schedule.kst_time),
|
||||||
|
)
|
||||||
|
|
||||||
# Event publisher transport — one dedicated instance for the
|
# Event publisher transport — one dedicated instance for the
|
||||||
# execution-loop thread (pika is not thread-safe).
|
# execution-loop thread (pika is not thread-safe).
|
||||||
self.event_publisher: MessagingTransport = _build_transport(config)
|
self.event_publisher: MessagingTransport = _build_transport(config)
|
||||||
@@ -160,9 +201,32 @@ class Daemon:
|
|||||||
transport.declare_topology()
|
transport.declare_topology()
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
pass
|
pass
|
||||||
handler = make_snapshot_rpc_handler(self.slot_repo, self.slot_service)
|
handler = make_snapshot_rpc_handler(
|
||||||
|
self.slot_repo, self.slot_service,
|
||||||
|
snapshot_store=self.snapshot_store,
|
||||||
|
pause_store=self.pause_store,
|
||||||
|
default_total_slots=self.config.total_slots,
|
||||||
|
)
|
||||||
transport.consume_rpc_forever(topology.SNAPSHOT_REQ_QUEUE, handler, self.stop)
|
transport.consume_rpc_forever(topology.SNAPSHOT_REQ_QUEUE, handler, self.stop)
|
||||||
|
|
||||||
|
def _pause_rpc_loop(self) -> None:
|
||||||
|
transport = _build_transport(self.config)
|
||||||
|
self._transports.append(transport)
|
||||||
|
try:
|
||||||
|
transport.declare_topology()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
handler = make_pause_rpc_handler(self.pause_store)
|
||||||
|
transport.consume_rpc_forever(topology.PAUSE_REQ_QUEUE, handler, self.stop)
|
||||||
|
|
||||||
|
def _snapshot_scheduler_loop(self) -> None:
|
||||||
|
try:
|
||||||
|
self.snapshot_scheduler.run_forever(
|
||||||
|
self.stop, tick_sec=self.config.snapshot_schedule.tick_sec,
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
_log.exception("snapshot scheduler loop crashed")
|
||||||
|
|
||||||
# ------------------------------------------------------------ lifecycle
|
# ------------------------------------------------------------ lifecycle
|
||||||
def setup(self) -> None:
|
def setup(self) -> None:
|
||||||
apply_migrations(self.config.dsn)
|
apply_migrations(self.config.dsn)
|
||||||
@@ -181,6 +245,8 @@ class Daemon:
|
|||||||
("trex-ledger", self._ledger_loop),
|
("trex-ledger", self._ledger_loop),
|
||||||
("trex-intent-rpc", self._intent_rpc_loop),
|
("trex-intent-rpc", self._intent_rpc_loop),
|
||||||
("trex-snapshot-rpc", self._snapshot_rpc_loop),
|
("trex-snapshot-rpc", self._snapshot_rpc_loop),
|
||||||
|
("trex-pause-rpc", self._pause_rpc_loop),
|
||||||
|
("trex-snapshot-scheduler", self._snapshot_scheduler_loop),
|
||||||
]
|
]
|
||||||
for name, target in workers:
|
for name, target in workers:
|
||||||
t = threading.Thread(target=target, name=name, daemon=True)
|
t = threading.Thread(target=target, name=name, daemon=True)
|
||||||
|
|||||||
Reference in New Issue
Block a user