feat: add portfolio snapshot read layer

This commit is contained in:
2026-06-06 09:33:00 +09:00
parent 56e977a7ad
commit ca757009f5
51 changed files with 3565 additions and 51 deletions
@@ -0,0 +1,196 @@
---
name: run-trex-core-daemon
description: "Launch the trex-core daemon against the real PostgreSQL + RabbitMQ infrastructure and drive it via the messaging surfaces (snapshot RPC, intent admission RPC, slot events). Use when asked to run trex-core, smoke-test the daemon, confirm a change works end-to-end, or reproduce the BAT-007 transport lifecycle."
---
# Run trex-core (sync + thread daemon over RabbitMQ)
The daemon (BAT-007) has **no HTTP surface**. All strategy-facing traffic goes
over RabbitMQ — snapshot RPC on `trex.snapshot.req`, intent admission RPC on
`trex.intent.req`, slot lifecycle events on the `trex.events` topic exchange.
"Run" means: boot the daemon in the background, drive it with messages, look
at the responses + the events.
## Prerequisites
The daemon needs three live services. Verify before launching:
```bash
.venv/bin/python - <<'PY'
import os, psycopg, pika
from dotenv import load_dotenv
load_dotenv(".env")
psycopg.connect(os.environ["TREX_POSTGRESQL_DSN"], connect_timeout=3).close()
print("postgres OK")
p = pika.URLParameters(os.environ["TREX_RABBITMQ_URL"]); p.socket_timeout = 3
pika.BlockingConnection(p).close()
print("rabbitmq OK")
PY
```
If either fails, fix the infra first — the daemon will not boot without
`TREX_POSTGRESQL_DSN` and will boot silently degraded if `TREX_RABBITMQ_URL`
is missing (stub `InMemoryTransport` — strategy modules can't reach it).
### Required env (`.env`, gitignored)
| Variable | Required | Notes |
|---|---|---|
| `TREX_POSTGRESQL_DSN` | yes | psycopg DSN |
| `TREX_RABBITMQ_URL` | yes for real use | e.g. `amqp://admin:admin@localhost:5672/` |
| `TREX_BROKER` | no | `paper` (default) or `kis` |
| `TREX_TOTAL_SLOTS` | no | default 10 |
| `TREX_EXECUTION_INTERVAL_SEC` | no | default 1.0 |
| `TREX_LEDGER_INTERVAL_SEC` | no | default 1.0 |
| `TREX_PORTFOLIO_HUB_BASE_URL` | no | if set, real portfolio-hub ledger; else in-memory stub |
The daemon preallocates slots for **UTC today** (`datetime.now(tz=timezone.utc).date()`).
KST users near midnight: check `date -u` before driving the daemon — the
trading_date you query must match what was preallocated.
## Launch
Background, redirect log:
```bash
.venv/bin/trex-core-daemon &> /tmp/trex-core.log &
DAEMON_PID=$!
```
The daemon does not bind any TCP port; "ready" means **four worker threads
running and all RabbitMQ topology declared**. Poll for the topology to
appear:
```bash
for i in {1..15}; do
.venv/bin/python - <<'PY' && break
import pika, sys
p = pika.URLParameters("amqp://admin:admin@localhost:5672/"); p.socket_timeout = 2
c = pika.BlockingConnection(p)
try:
ch = c.channel()
# passive=True: succeeds only if the daemon already declared it
ch.queue_declare("trex.intent.req", durable=True, passive=True)
ch.queue_declare("trex.snapshot.req", durable=True, passive=True)
ch.exchange_declare("trex.events", exchange_type="topic", durable=True, passive=True)
print("daemon ready")
sys.exit(0)
except Exception as exc:
print(f"not ready: {exc}"); sys.exit(1)
finally:
c.close()
PY
sleep 0.5
done
```
`/tmp/trex-core.log` has the pika INFO chatter — first connection lines mean
boot succeeded; absence of `ERROR`/exception traceback means topology declare
worked.
## Drive
The canonical end-to-end smoke is in `smoke.py` — copy/adapt it. It exercises:
1. **snapshot RPC** (view=all) — confirms the daemon answers.
2. **buy intent RPC** with a fresh `correlation_id` — admitted, slot →
`pending_buy`.
3. **duplicate buy** with the *same* `correlation_id` — identical response,
no double transition (intent_log dedup gate, REQ-023 AC-1).
4. **wait one execution loop tick** — paper broker fills, slot → `holding`,
`slot_filled_buy.{strategy_id}.{symbol}` event published.
5. **sell intent RPC** on the holding slot — slot → `closed`,
`slot_filled_sell.{strategy_id}.{symbol}` event published.
Run it:
```bash
.venv/bin/python .claude/skills/run-trex-core-daemon/smoke.py
```
Sample output (paper broker, `strategy_id=STRAT-A`):
```
=== 1) snapshot RPC ===
slots=10 capacity={idle=10, holding=0, pending=0}
=== 2) buy intent ===
admitted, slot_id=b2d60fa3-…
=== 3) duplicate buy ===
identical: True
=== 4) wait for fill ===
slot status=holding qty=10 avg_price=60000.0
=== 5) sell intent ===
admitted; event rk=slot_filled_sell.STRAT-A.005930 status=closed qty=10
DONE
```
### Subscribing to events (timing caveat)
If you subscribe to `trex.events` *after* the publish happens, the message
is gone — topic exchanges drop unroutable publishes. Production strategy
modules have their per-strategy queue bound from boot, so they don't miss
anything; ad-hoc subscribers need to declare + bind their queue **before**
triggering the intent. `smoke.py` does this correctly (declares queue, then
sleeps 0.6s, then runs the RPC).
## Stop
```bash
kill -TERM $DAEMON_PID
# or if you've lost the PID:
pkill -f trex-core-daemon
```
The daemon installs SIGINT/SIGTERM handlers that set the stop event,
join all worker threads, and `close()` every RabbitMQ connection — last
log line should be `Normal shutdown` from pika. Exit code 0.
## Inspecting state
The daemon's source of truth is PostgreSQL `daily_slot`:
```bash
.venv/bin/python - <<'PY'
import os, psycopg
from dotenv import load_dotenv
load_dotenv(".env")
with psycopg.connect(os.environ["TREX_POSTGRESQL_DSN"]) as c:
with c.cursor() as cur:
cur.execute("""
SELECT trading_date, status, COUNT(*)
FROM daily_slot GROUP BY trading_date, status ORDER BY trading_date DESC, status
""")
for r in cur.fetchall(): print(r)
PY
```
Ledger outbox state (BAT-006):
```bash
.venv/bin/python - <<'PY'
import os, psycopg
from dotenv import load_dotenv
load_dotenv(".env")
with psycopg.connect(os.environ["TREX_POSTGRESQL_DSN"]) as c:
with c.cursor() as cur:
cur.execute("SELECT status, last_outcome, COUNT(*) FROM ledger_outbox GROUP BY 1,2 ORDER BY 1,2")
for r in cur.fetchall(): print(r)
PY
```
## Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| `slots returned: 0` from snapshot | querying wrong `trading_date` | use UTC today, see `date -u` |
| intent gets `rejected: no_idle_slot` | no preallocated slots for that day OR all 10 in flight | check `daily_slot` rows; restart daemon to repreallocate next UTC day |
| `RpcTimeoutError` on the client | daemon not up, or consumer thread crashed | check `/tmp/trex-core.log` for exception trace |
| `TransportUnavailableError` | RabbitMQ down | the daemon does NOT buffer locally (REQ-024) — fix the broker, then re-issue |
| fill event missed by ad-hoc subscriber | queue bound *after* publish | bind queue + sleep ≥0.5s before triggering the intent |
| import-time `ModuleNotFoundError: pika` | dev deps not installed | `.venv/bin/pip install -e ".[dev]"` |
## Related docs
- `docs/interface-contract.md` — full RabbitMQ topology + message payloads
- `docs/runtime-flows.md` — flow diagrams (snapshot RPC, intent RPC, events, degrade branch)
- `docs/batches/bat-007_20260605_strategy-module-rabbitmq-transport/` — the batch that introduced this runtime shape
@@ -0,0 +1,186 @@
#!/usr/bin/env python3
"""End-to-end smoke for trex-core (BAT-007).
Assumes the daemon is already running (`.venv/bin/trex-core-daemon &`).
Drives it through every RabbitMQ surface:
1. snapshot RPC (view=all) — daemon answers
2. buy intent RPC — admission + slot transition
3. duplicate buy (same correlation_id) — intent_log dedup gate (REQ-023 AC-1)
4. wait for execution tick + fill event
5. sell intent RPC — slot → closed + sell event
Trading_date defaults to UTC today (matches what the daemon preallocates).
"""
from __future__ import annotations
import json
import os
import sys
import threading
import time
import uuid
from datetime import date, datetime, timezone
from dotenv import load_dotenv
load_dotenv(".env")
import pika # noqa: E402
from trex_core.adapters.messaging import topology # noqa: E402
from trex_core.adapters.messaging.rabbitmq import RabbitMQTransport # noqa: E402
URL = os.environ.get("TREX_RABBITMQ_URL", "amqp://admin:admin@localhost:5672/")
TODAY = datetime.now(tz=timezone.utc).date()
STRATEGY = "STRAT-A"
SYMBOL = "005930"
def _subscribe_events(received: list, stop: threading.Event, queue: str) -> threading.Thread:
"""Background event consumer bound BEFORE we trigger intents."""
def loop() -> None:
t = RabbitMQTransport(URL, timeout=5.0)
try:
t.consume_events_forever(
queue,
topology.strategy_event_binding_pattern(STRATEGY),
lambda rk, p: received.append((rk, p)),
stop,
)
finally:
t.close()
th = threading.Thread(target=loop, name="smoke-events", daemon=True)
th.start()
return th
def _cleanup_queue(queue: str) -> None:
try:
c = pika.BlockingConnection(pika.URLParameters(URL))
try:
c.channel().queue_delete(queue)
finally:
c.close()
except Exception:
pass
def _print_slots(slots: list) -> None:
for s in slots:
print(f" slot {s['slot_id'][:8]}.. status={s['status']:<12} "
f"symbol={s['symbol']} strategy_id={s['strategy_id']} qty={s['qty']} "
f"avg_price={s['avg_price']}")
def main() -> int:
ev_queue = f"trex.events.strategy.{STRATEGY}.smoke.{uuid.uuid4().hex[:6]}"
ev_received: list[tuple[str, dict]] = []
ev_stop = threading.Event()
ev_thread = _subscribe_events(ev_received, ev_stop, ev_queue)
time.sleep(0.6) # let queue bind before triggering events
client = RabbitMQTransport(URL, timeout=5.0)
try:
# 1) snapshot RPC ---------------------------------------------------
print(f"=== 1) snapshot RPC (view=all, day={TODAY.isoformat()}) ===")
resp = client.call_rpc(
topology.SNAPSHOT_REQ_QUEUE,
{"view": "all", "trading_date": TODAY.isoformat()},
correlation_id=f"snap-{uuid.uuid4().hex[:8]}",
timeout=5.0,
)
print(f"slots={len(resp['slots'])} capacity={resp['capacity']}")
if resp["capacity"]["idle_count"] == 0:
print("\n!! no idle slots for today — restart the daemon to preallocate, "
"or check `date -u`")
return 2
# 2) buy intent RPC ------------------------------------------------
print(f"\n=== 2) buy intent RPC ({SYMBOL}, qty=10@60000, "
f"strategy={STRATEGY}, portfolio=PF-smoke) ===")
cid = f"smoke-buy-{uuid.uuid4().hex[:8]}"
resp = client.call_rpc(
topology.INTENT_REQ_QUEUE,
{
"intent_type": "buy", "intent_id": cid,
"trading_date": TODAY.isoformat(), "symbol": SYMBOL,
"qty": 10, "entry_price": 60000,
"strategy_id": STRATEGY, "portfolio_id": "PF-smoke",
},
correlation_id=cid, timeout=5.0,
)
print(f"admission: {json.dumps(resp, ensure_ascii=False)}")
if resp["status"] != "admitted":
print("\n!! buy not admitted — aborting"); return 3
# 3) duplicate -----------------------------------------------------
print(f"\n=== 3) duplicate buy (same correlation_id={cid[-8:]}…) ===")
resp_dup = client.call_rpc(
topology.INTENT_REQ_QUEUE,
{
"intent_type": "buy", "intent_id": cid,
"trading_date": TODAY.isoformat(), "symbol": SYMBOL,
"qty": 10, "strategy_id": STRATEGY, "portfolio_id": "PF-smoke",
},
correlation_id=cid, timeout=5.0,
)
print(f"identical: {resp == resp_dup} (REQ-023 AC-1 intent_log dedup)")
# 4) wait for fill + event -----------------------------------------
print(f"\n=== 4) wait for execution tick + fill event (paper broker) ===")
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
resp = client.call_rpc(
topology.SNAPSHOT_REQ_QUEUE,
{"view": "open", "trading_date": TODAY.isoformat()},
correlation_id=f"snap-{uuid.uuid4().hex[:8]}", timeout=5.0,
)
holdings = [s for s in resp["slots"] if s["status"] == "holding"]
if holdings:
_print_slots(holdings)
break
time.sleep(0.5)
else:
print("\n!! no holding slot appeared in 10s — execution loop stuck?"); return 4
print(f"buy events captured so far: "
f"{[rk for rk, _ in ev_received if rk.startswith('slot_filled_buy.')]}")
# 5) sell intent ---------------------------------------------------
sid = holdings[0]["slot_id"]
scid = f"smoke-sell-{uuid.uuid4().hex[:8]}"
print(f"\n=== 5) sell intent on slot {sid[:8]}.. ===")
resp_sell = client.call_rpc(
topology.INTENT_REQ_QUEUE,
{"intent_type": "sell", "intent_id": scid, "slot_id": sid},
correlation_id=scid, timeout=5.0,
)
print(f"admission: {json.dumps(resp_sell, ensure_ascii=False)}")
# Wait for the sell fill event
deadline = time.monotonic() + 10
while time.monotonic() < deadline and not any(
rk.startswith("slot_filled_sell.") for rk, _ in ev_received
):
time.sleep(0.3)
print(f"\nall events captured ({len(ev_received)}):")
for rk, payload in ev_received:
print(f" rk={rk} status={payload.get('status'):<8} "
f"qty={payload.get('qty')} sequence_at={payload.get('sequence_at')}")
print("\nDONE")
return 0
finally:
ev_stop.set()
try:
client.close()
except Exception:
pass
ev_thread.join(timeout=2.0)
_cleanup_queue(ev_queue)
if __name__ == "__main__":
sys.exit(main())
+8
View File
@@ -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 전환.
+1
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+1
View File
@@ -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
} }
+74
View File
@@ -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
+61
View File
@@ -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"]
+88
View File
@@ -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"]
+1
View File
@@ -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",
+38
View File
@@ -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(":")
+17
View File
@@ -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]
+6
View File
@@ -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."""
+1
View File
@@ -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')
);
+6
View File
@@ -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"]
+107
View File
@@ -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()
+44
View File
@@ -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
+21
View File
@@ -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
+96 -12
View File
@@ -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:
+361
View File
@@ -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,
)
+59
View File
@@ -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
+103
View File
@@ -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
+47
View File
@@ -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())
+15
View File
@@ -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,
) )
+67 -1
View File
@@ -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)