Files
trex-core/docs/interface-contract.md
T
genichinandClaude Opus 4.8 e4be4d60c2 feat(slot): dcy-021 포지션 사이징 — 보유 슬롯 증액·부분 매도 (BAT-031)
자산 영구 슬롯(dcy-020)의 수량 가변 전제 위에 보유 포지션 수량 조정 구현.
명령형 계약(전략이 cash+quantity 로 qty+price 계산), core 는 실행 substrate,
수량 정본 = 브로커 잔고 재동기화로 "주문 수량 vs 보유 수량" 충돌 해소(별도 필드 없음).

- state.py: holding→pending_buy 전이 추가(증액) → 13 transitions (REQ-057)
- service.py: PENDING_SELL 진입 qty=caller as-is(None=전량 sentinel, 값=부분 주문) (REQ-062)
- intents.py: 증액 admission(holding 슬롯 매수=increase, M 캡 미적용), SellIntent.qty 부분 매도 (REQ-061)
- execution.py: _balance_map; 매도 dispatch=브로커잔고(전량)/min(주문,잔고)(부분);
  매수 체결 avg_price 가중평균+브로커 정본 resync; 매도 체결 잔고>0→holding/0→inactive;
  증액 롤백 holding 복귀 (REQ-066/062)
- messaging_handlers.py: sell payload qty 파싱(부분 매도)
- baseline 4문서(data-model/runtime-flows/interface-contract/project-structure) 정합 (REQ-067)

검증: 단위 378 / 통합(PG+RabbitMQ) 422 passed, ruff clean. run-the-app 사이징 e2e:
매수 10@1000 → 증액 +5@1200 → 15@1066.67(가중평균) → 부분 매도 6 → 9@1066.67(cost basis) → 전량→inactive.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 18:53:37 +09:00

523 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Trex Core Interface Contract
- Baseline Mode: mixed
- Baseline Seed: .stage-pilot/bootstrap/baseline.yaml
- Status: draft
- Owner: project maintainers
- Last Updated (KST): 2026-06-08 (BAT-023)
- Source Discovery / Batch: bootstrap-baseline (pre-Discovery); HTTP API = dcy-002 / BAT-002
- Primary Runtime: daemon (HTTP API) + library (slot 도메인)
## Project Summary
- trex 제품군의 공용 베이스로, 첫 도메인으로 trex-orbit 의 일일 거래 슬롯 생명주기(slot) 순수 도메인을 storage-agnostic 하게 이식한 코어다.
## Primary Domain
- 자동매매 일일 거래 슬롯 생명주기 도메인 (equity trading slot lifecycle)
## Purpose
- This document captures the approved external and cross-boundary interface contracts for the project.
- It is a cross-cutting reference for REQ drafting, batch design, implementation, verification, and handoff.
## Scope
- Covers the HTTP `/v1` API(슬롯 코어 데몬, BAT-002), in-process domain-service APIs, and the persistence boundary Protocol.
- Does not duplicate full business requirements already owned by REQ documents.
- Does not replace low-level code references or generated API artifacts.
## Interface Summary
- RabbitMQ `trex.snapshot.req` :: 전략 모듈의 스냅샷 RPC (request-reply, BAT-007/BAT-008 envelope)
- RabbitMQ `trex.intent.req` :: 전략 모듈의 매수/매도 admission RPC (request-reply, BAT-007)
- RabbitMQ `trex.events` :: 체결/슬롯 상태 이벤트 topic exchange (pub/sub, BAT-007)
- RabbitMQ `trex.control.req` :: 운영자 제어 RPC — pause/resume/list + kill_switch/resume_kill_switch (request-reply, BAT-012; 구 `trex.pause.req` 확장)
- `trex-core` CLI :: 운영 명령 — pause/resume/paused(direct-DB, BAT-008) + universe/slots/enter/exit(데몬 RPC, dcy-016/BAT-020)
- SlotService :: 슬롯 생명주기 도메인 서비스(전이/사전할당/가용성/재진입 카운트)
- SlotRepository (Protocol) :: 도메인 ↔ 저장소 경계 계약
- BrokerPort (Protocol) :: 단일 브로커 세션 경계(submit/poll/cancel) + `OrderRequest`/`OrderAck`/`FillStatus`
- LedgerPort (Protocol) :: 외부 원장(portfolio-hub) 기록 경계 + `LedgerTrade`/`LedgerWriteResult`/예외 (BAT-006)
- LedgerOutbox (Protocol) + LedgerWriter :: 체결 → 원장 기록의 outbox/재시도/멱등 (BAT-006)
- 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 표면이 전부다.
## Interface: `RabbitMQ messaging` (전략 모듈 ↔ trex-core, BAT-007)
- Type: amqp-messaging
- Consumers / Producers: trex-core daemon ↔ 전략 모듈(외부 소비자). RabbitMQ broker(`TREX_RABBITMQ_URL`) 가 transport.
- Purpose: HTTP `/v1` API 를 대체하는 메시지 기반 통신. 코어가 단독 admission + 슬롯 전이(단일 writer 불변식 A) 를 유지.
- Stability: experimental (BAT-007)
### Topology
| 종류 | 이름 | 타입/속성 | 용도 |
|---|---|---|---|
| 큐 | `trex.snapshot.req` | durable | 스냅샷 RPC 요청 큐 (read-only) |
| 큐 | `trex.intent.req` | durable, `x-dead-letter-exchange=trex.events.dlq` | intent admission RPC 요청 큐 |
| 익스체인지 | `trex.events` | topic, durable | 체결/슬롯 상태 이벤트 발행 |
| 익스체인지 | `trex.events.dlq` | fanout, durable | 처리 실패/unroutable 메시지 격리 |
| 큐 | `trex.events.dlq` | durable | DLQ 수집기 큐 (`trex.events.dlq` 익스체인지 바인딩) |
| 큐 (전략별) | `trex.events.strategy.{strategy_id}` | durable, `x-message-ttl=3.6e6`, `x-max-length=10000`, `x-overflow=drop-head` | 전략 모듈별 이벤트 수신 큐 (`*.{strategy_id}.#` 바인딩) |
### Patterns
| 패턴 | 표면 | 요청 속성 | 응답 |
|---|---|---|---|
| 스냅샷 RPC | `trex.snapshot.req` | `correlation_id`, `reply_to=amq.rabbitmq.reply-to` | reply-to 큐로 envelope(`assets[]`/`slots[]`/`available_slots`/…); stub 경로는 `{view, strategy_id, slots[], capacity{}}` |
| intent RPC | `trex.intent.req` | `correlation_id` = intent 멱등키, `reply_to=amq.rabbitmq.reply-to`, `delivery_mode=2` | reply-to 큐로 `{intent_id, status:admitted|rejected, slot_id?, reason?}`. 체결/종료는 응답 아닌 이벤트로 |
| update_thresholds | `trex.intent.req` | `action="update_thresholds"`, `reply_to` | reply-to 큐로 `{status:updated|ignored, slot_id, reason?}` (REQ-040) |
| 이벤트 pub/sub | `trex.events` (topic) | routing key `{event_type}.{strategy_id}.{symbol}` | 전략 큐가 자기 `strategy_id` 패턴으로 바인딩 |
### Message contracts
#### `trex.snapshot.req` 요청 payload
```
{ "view": "open" | "all", "strategy_id": str | null,
"trading_date": "YYYY-MM-DD"? # optional — kill-switch 당일 제외 마스크 기준일만 (BAT-030)
}
```
응답 payload는 슬롯 목록과 capacity 를 운반한다. view 모드와 strategy_id 는 payload(요청 매핑 원칙 — REQ-021). BAT-030: 슬롯 조회는 instance 스코프(영구 슬롯)라 `trading_date` 로 필터하지 않는다.
#### `trex.intent.req` 요청 payload (buy)
```
{ "intent_type": "buy",
"intent_id": "<correlation_id 와 동일>",
"slot_id": str(UUID), # CANONICAL key (dcy-020/REQ-061) — snapshot slots[] round-trip
"portfolio_id": str?, # legacy dual-accept 매핑(점진 전환용)
"qty": float?, "allocated_capital": float?,
"entry_price": float?, "strategy_id": str?, "sell_strategy_id": str?,
"forced_close_at": "ISO-8601"?,
"stop_loss_price": number?, "take_profit_price": number? }
```
- **`slot_id` 정본(dcy-020/REQ-061, BAT-029/030)**: 매수의 정식 키 — 전략 모듈은 snapshot `slots[]` 의 `slot_id` 를 그대로 회신한다. 미스 시 `unknown_slot` 거부. `portfolio_id` 는 legacy dual-accept(미스 시 `unknown_portfolio`); 둘 다 없으면 `slot_id_or_portfolio_required`. `trading_date` 는 계약에서 제거(BAT-030).
- admission(REQ-059): 대상 영구 자산 슬롯이 active(holding/pending) 면 `already_active`; instance active 수 ≥ `max_active_slots`(M) 면 `capacity_full`(FCFS·무치환). **`symbol` 은 client payload 에 없다** — core 가 슬롯의 `portfolio_id` 로 universe 에서 resolve 한다.
- `stop_loss_price`/`take_profit_price`(선택, REQ-035/038): 매수 시 설정하면 워치독 만료 후 safety-sell 가격 트리거의 기준이 된다. 미포함 시 NULL(트리거 비활성).
- `forced_close_at`(선택, REQ-066/037): 시간 기반 자동청산(만기) 절대시각. 운영자 CLI `enter --forced-close-at <ISO8601>`(절대시간)로 설정하며(REQ-032 CHG-20260609-01, issue #17, BAT-024), 미지정 시 payload 에 미포함(NULL). 설정 시 forced-close 만기 매도 루프(REQ-037)가 그 시각에 시장가 청산한다. 상대시간 옵션은 제공하지 않는다.
sell payload 는 `{"intent_type":"sell", "intent_id":..., "slot_id":..., "reason":..., "sell_strategy_id":..., "limit_price": float?}`.
- **`limit_price` 선택(dcy-012/REQ-007)**: 매도 지정가. **지정 시 지정가 매도(`ORD_DVSN="00"`, 그 가격)**, **미지정 시 시장가 매도(`ORD_DVSN="01"`)**. 자율 매도(safety-sell/kill-switch)는 가격을 싣지 않아 항상 시장가로 체결된다. 매수가(과거 `entry_price`)는 매도 가격으로 **재사용하지 않는다**(issue #10 — 슬롯 `limit_price` 는 pending 이탈 시 clear). buy payload 의 `entry_price` 필드명은 본 주기에서 유지(하위호환).
#### `trex.intent.req` `update_thresholds` 액션 (BAT-011 / REQ-040)
```
{ "action": "update_thresholds",
"slot_id": str,
"stop_loss_price": number | null?, # 생략=유지, null=해제
"take_profit_price": number | null?, # 생략=유지, null=해제
"forced_close_at": "ISO-8601" | null?, # 생략=유지, null=해제
"actor": str? }
```
- 매수 이후 손절·익절·강제 만기 시각을 갱신한다. 세 필드는 독립적이며 payload 에 **키가 없으면 기존 값을 유지**, 명시적 `null` 이면 해제한다(REQ-040 AC-4/AC-5).
- `holding` 슬롯에서만 적용된다. 처리 성공 시 해당 슬롯의 워치독 `last_seen_at` 을 리셋한다(전략 모듈 생존 신호, REQ-034).
- 응답: `{status: "updated", slot_id}` / 비-holding 은 `{status: "ignored", slot_id, reason: "not_holding"}` / 미존재는 `{status: "ignored", slot_id, reason: "slot_not_found"}`.
#### `trex.events` payload (BAT-007)
```
{ "event_type": "slot_filled_buy"|"slot_filled_sell"|...,
"slot_id": str, "strategy_id": str?, "symbol": str?,
"status": "<SlotState 값>", "qty": float?, "avg_price": float?,
"portfolio_id": str?,
"sequence_at": "ISO-8601" } # BAT-030: trading_date 키 제거(영구 슬롯)
```
- `sequence_at` = 슬롯 `updated_at` 의 단조 증가 토큰. 수신자는 last-wins 적용 가능 → 순서 보증 불요.
- 이벤트의 `strategy_id`/`symbol` 은 routing key 에, 본문에도 함께 운반(이벤트 매핑 원칙 — REQ-021).
### Reliability rules (REQ-023)
- intent RPC: durable + persistent + manual ack-after-commit + `intent_log` correlation_id dedup. 단일 consumer + prefetch=1 → FIFO.
- 스냅샷 RPC: read-only, manual ack, 별도 dedup 불요.
- 이벤트: publisher confirms 활성, unroutable → DLQ. 페이로드는 현재 상태 + 단조 시퀀스(last-wins).
- per-strategy 이벤트 queue: TTL/max-length 초과 시 `drop-head` + 스냅샷 RPC reconcile 로 보정.
### Degrade (REQ-024)
- 어댑터 `TransportHealth` ∈ {connected, disconnected, reconnecting}. 어플리케이션 레이어는 disconnected 상태에서 `TransportUnavailableError` 로 빠르게 실패한다(버퍼링 0, 보수적 중단).
- 재연결은 백오프 1s→2s→4s→8s→16s→30s cap, 어댑터가 자체 구현.
- core 자율 안전 매도는 transport 와 무관하게 `trex-safety-sell` worker 가 동작한다(forced_close / stop-loss·take-profit / 장 마감, dcy-007 REQ-037~041, BAT-010/011 구현 완료).
### `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
"touch_watchdog": bool # optional, default true (dcy-016/REQ-021)
"actor": "operator" # optional; "operator" => touch_watchdog 강제 false
}
```
- **워치독 억제(dcy-016/REQ-021/034)**: 기본(`touch_watchdog` 미지정/true)은 응답에 포함된 holding 슬롯의 워치독 `last_seen_at` 을 갱신한다(전략 폴링=생존 신호, REQ-034). `touch_watchdog=false` 또는 `actor="operator"` 면 갱신을 건너뛴다 — 운영자 CLI 조회(`universe`/`slots`)가 죽은 전략을 살아있는 것으로 오인시켜 safety-sell 을 무력화하지 않도록(R-4). 운영자 CLI 는 항상 억제 플래그를 보낸다. 기본값은 현행 유지(전략 모듈 무변경, 하위호환).
코어가 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
"cash_estimated": true? # dcy-018/OQ-4 — cash_balance 가 미정산 추정값일 때만(라벨)
}, ...
],
"view": "all" | "buyable" | "held", # echo
"available_slots": int, # admission headroom = max(0, M − active); M 미설정 시 inactive 수 (REQ-059)
"degraded": bool, # REQ-028 AC-2
"reason": "portfolio_hub_unavailable" | "snapshot_fetch_failed"
| "cash_position_stale" | null,
"message": str | null # sanitized — no credentials/raw exceptions
}
```
**No `price` field on `assets[]` items** (REQ-028 AC-3 / REQ-027 AC-1) — strategy-facing universe.
- 슬롯 상태 (REQ-028 AC-4, BAT-022): envelope 은 strategy `assets[]`(universe) 와 **별도로** 데몬의 per-slot 상태를 운반한다. 운영자용(`trex-core slots`)이며 universe 와 명확히 구분된다.
```
"slots": [ # always a list (REQ-028 AC-4, never null)
{ "slot_id": str, # buy intent 의 canonical key (REQ-061)
"status": "inactive"|"pending_buy"|"holding"|"pending_sell"|"error",
"symbol": str | null,
"qty": number | null,
"avg_price": number | null, # 매수 체결가/평단
"entry_price": number | null, # pending 주문 지정가 (dcy-012 외부 호환명; 내부 limit_price)
"allocated_capital": number | null,
"strategy_id": str | null, "sell_strategy_id": str | null,
"portfolio_id": str | null, # == asset_id (영구 자산 바인딩)
"forced_close_at": "ISO-8601" | null,
"universe_detached": bool } # soft-retire/tombstone 마커 (REQ-058/063)
]
```
- BAT-007 stub 경로와 BAT-008 envelope 경로가 **동일 `slots[]` 스키마**(`portfolio.slot_to_dict`)를 낸다. `assets[]`(portfolio 단위, universe, no-price) 와 독립이며 strategy 소비자는 무시 가능(additive, 하위호환). `slots[]` 는 operator-facing 이라 `entry_price`/`avg_price` 가격을 노출한다(no-price 규칙은 `assets[]` 한정).
- 워치독 상태 (dcy-017/REQ-034, 선택 필드): 응답에 holding 슬롯의 워치독 상태를 read-only 로 동봉한다.
```
"watchdog": [ # best-effort; 실패 시 생략(NFR-1)
{ "slot_id": str, "portfolio_id": str | null, "symbol": str | null,
"expired": bool, # true => 전략 이탈(안전망 소유)
"last_seen": "ISO-8601" | null } # 마지막 스냅샷 폴링 시각
]
```
- `trex-core slots` 가 `expired=true` 를 **WATCHDOG-OWNED(전략 이탈)** 로 표시한다. 운영자 조회(`actor="operator"`/`touch_watchdog=false`)는 이 노출이 워치독을 리셋하지 않는다(비침습, dcy-016).
- 워치독 신호 (REQ-034): `trex.snapshot.req` 처리는 read-only 응답 산출에 더해, 응답에 포함된 모든 `holding` 슬롯의 워치독 `last_seen_at` 을 갱신한다. 즉 전략 모듈이 스냅샷을 폴링하는 것 자체가 해당 슬롯의 생존 신호이며, 폴링이 끊긴(워치독 만료) 슬롯만 safety-sell 트리거 대상이 된다. (dcy-016: `touch_watchdog=false`/`actor="operator"` 요청은 이 갱신을 건너뛴다 — 운영자 조회 제외.)
### 운영자 CLI ↔ RPC 매핑 (dcy-016 / REQ-032)
운영자 CLI 명령은 데몬 RabbitMQ RPC 로 통일된다(단일 writer admission 보존, DB 직접 쓰기 없음). 인스턴스 라우팅: `--config <toml>`(canonical, REQ-048) 또는 `--instance <key>`(단축·override), 미지정 시 `default`.
| CLI | RPC 큐 | payload | 비고 |
|---|---|---|---|
| `universe` | `trex.snapshot.req[.{instance}]` | `{view:"all", actor:"operator", touch_watchdog:false}` | 자산 목록 조회(워치독 억제) |
| `slots [--status all\|holding\|buyable\|pending]` | `trex.snapshot.req[.{instance}]` | `{view:"all", actor:"operator", touch_watchdog:false}` | envelope `slots[]`(실제 슬롯 상태) 표시(REQ-028 AC-4/REQ-032, BAT-022) — universe `assets[]` 아님. `--status` 는 슬롯 status 기준 client-side 필터 |
| `enter <portfolio_id> [--capital/--qty/--stop-loss/--take-profit/--forced-close-at ...]` | `trex.intent.req[.{instance}]` | buy intent(`portfolio_id` 필수, symbol 서버 resolve; `--forced-close-at <ISO8601>`→`forced_close_at`, REQ-032 CHG-20260609-01) | 안전장치 필수; `--forced-close-at` 형식은 제출 전 검증 |
| `exit <slot_id> [--price]` | `trex.intent.req[.{instance}]` | sell intent(`slot_id`, optional `limit_price`) | 가격 미지정 시 시장가 |
| `pause/resume/paused` | (RPC 아님) | — | direct-DB 유지(OQ-3) |
- enter/exit 안전장치(OQ-2): 확인 프롬프트 기본 ON, `--yes`(paper 생략), **live(`broker=kis`+allow_live)는 `--yes --live` 이중 ack**, `--dry-run` 미리보기. `--json` 은 비대화형이라 enter/exit 에 `--yes` 필수(미지정 시 자동 제출 없이 에러). 모든 명령 출력에 대상 인스턴스 표시, admission 응답(`status/slot_id/reason`)을 표시하며 rejected 면 비0 종료.
### `trex.control.req` 제어 RPC (BAT-012 / REQ-043, REQ-032)
운영자 제어 채널. `action` discriminator 로 분기한다(REQ-043). 기존 `trex.pause.req`(REQ-032)의 개념 확장이며, 단일 제어 큐 + action 패턴으로 기능마다 큐/consumer 를 늘리지 않는다.
action 목록: `pause` | `resume` | `list` (REQ-032) · `kill_switch` (REQ-045) · `resume_kill_switch` (REQ-046). 알 수 없는 action 은 `{status:"error", reason:"unknown_action:<a>"}`.
pause/resume/list 요청·응답:
```
{ "action": "pause" | "resume" | "list", "portfolio_id": str, "actor": str? }
→ { "status": "ok"|"error", "paused": [str,...], "action": echo, "reason"?: str }
```
- pause 는 `snapshot_pause`(portfolio_id PK, **영구**) upsert — daemon 재시작·일일 refresh 후 유지(REQ-032 AC-2). CLI `trex-core pause/resume/paused` 가 같은 store 를 본다.
kill_switch 요청·응답 (REQ-045):
```
{ "action": "kill_switch", "scope": "portfolio"|"global", "portfolio_id": str?, "actor": str? }
→ { "status": "ok", "scope": echo, "portfolio_id"?: str, "liquidated": int }
```
- scope 범위의 모든 `holding` 슬롯을 `SlotService.sell(reason="emergency_stop")` 로 즉시 청산하고, 해당 범위를 **당일 제외**(`killswitch_exclusion`)로 표시한다. scope=portfolio 인데 portfolio_id 누락 시 `{status:error, reason:portfolio_id_required}`.
resume_kill_switch 요청·응답 (REQ-046):
```
{ "action": "resume_kill_switch", "scope": "portfolio"|"global", "portfolio_id": str? }
→ { "status": "ok", "scope": echo, "portfolio_id"?: str }
```
- 해당 범위의 **당일 제외만 해제**(`killswitch_exclusion` row 삭제)해 정상 매매로 복귀. 이미 청산(매도)된 슬롯은 되돌리지 않는다(매도 비가역).
- snapshot masking 소스(REQ-046): `mask = pending | warning | pause | killswitch_today`. kill-switch 당일 제외는 `(portfolio_id, trading_date)` day-scoped — 다음 거래일 자동 복귀. `portfolio_id="*"` sentinel 은 전역(전체 universe) 마스킹.
- 하위호환: `trex.pause.req` 는 legacy alias. 운영 배포 시 제어 클라이언트는 `trex.control.req` 로 이전한다(rollout 결정, REQ-043 Notes).
### Multi-instance routing (BAT-013 / REQ-049/050)
다중 인스턴스 운영 시 큐명과 라우팅을 `instance_key` 로 분리한다(dcy-009).
- **큐명 규칙**: `{base}.{instance_key}`(예: `trex.intent.req.inst-1`). `instance_key = "default"` 는 기존 리터럴 큐명(`trex.intent.req` 등)을 그대로 사용한다(하위호환, FR-5).
- **`trex.direct`(topic exchange)**: 특정 인스턴스 지정 전달. routing key = `{instance_key}.{channel_type}`(`channel_type ∈ {intent, snapshot, control}`). 각 인스턴스 큐가 자신의 routing key 로 바인딩 → `inst-1.intent` publish 는 `trex.intent.req.inst-1` 에만 도달.
- **`trex.broadcast`(fanout exchange)**: 전역 제어. 각 인스턴스의 **control 큐가 dual 바인딩**되어, broadcast publish 는 모든 인스턴스 control 큐에 동시 전달(global kill-switch 등). 중복 전달은 핸들러 idempotency(REQ-036/advisory lock)로 무해.
- **기동**: 데몬은 `--config <path>.toml`(필수)로 `instance_key`/`portfolio_hub_token` 을 읽는다(`.env` 아님, REQ-048).
## 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
- Consumers / Producers: 상위 애플리케이션(예: trex-orbit 류 매매 데몬)의 매수/매도/주문 결정 경로 및 운영자 도구
- Purpose: 슬롯 상태 전이를 레이스-프리하게 수행하고 일일 슬롯 가용성/재진입 카운트를 계산한다.
- Stability: internal
### Inputs
- transit_slot(slot_id, from_state, to_state, reason, actor, **fields) :: advisory lock 하 원자 상태 전이 (INACTIVE 진입 시 점유 초기화 + 자산 정체성 보존)
- sell(slot_id, actor, reason, limit_price=None) :: holding 슬롯 자율 매도(멱등)
- update_thresholds(slot_id, ...) :: SL/TP/forced_close 부분 갱신
- compute_slot_capacity(max_active_slots=None) :: instance 스코프 가용성 스냅샷 (BAT-030)
### Outputs
- Slot :: 전이 후 슬롯 행 스냅샷
- SlotCapacity :: inactive/holding/pending/active/available 카운트 + total + scarcity_ratio
- slot_transition_log append :: 모든 전이의 append-only 감사 기록(부수 효과)
### Error Contract
- SlotTransitionError :: 허용되지 않은 (from_state, to_state) 전이
- SlotStateMismatchError :: read-write 사이 상태 변동(레이스 감지)
- SlotNotFoundError :: 존재하지 않는 slot_id
### Compatibility Rules
- 5 상태 / 13 전이 계약(REQ-057, dcy-021 holding→pending_buy 증액 포함)을 깨는 변경은 breaking 으로 취급한다.
- 상태 변경 API 는 transactional_advisory_lock(인스턴스 스코프 lock id) 안에서만 수행하고 lock 보유 중 외부 I/O 를 금지한다.
## Interface: `SlotRepository (Protocol)`
- Type: internal-service
- Consumers / Producers: SlotService(소비) ↔ 저장소 구현체(InMemorySlotRepository / 향후 PostgreSQLSlotRepository)
- Purpose: 도메인을 특정 저장소 기술에서 분리하는 영속화 경계 계약.
- Stability: internal
### Inputs
- transactional_advisory_lock(lock_id) :: 단일 트랜잭션 + advisory lock 컨텍스트 매니저
- list_slots(instance_key) / fetch_slot / update_slot :: daily_slot CRUD (instance 스코프)
- find_slot_by_portfolio / count_active / list_holding_slots / reconcile_universe :: 자산 중심 표면 (REQ-058/059/062)
- list_undispatched_pending / mark_dispatched / list_dispatched_pending / clear_dispatch :: 집행 큐 (instance 스코프)
- append_transition_log :: append-only 전이 로그
- count_slots_by_status(instance_key) :: 상태별 집계
### Outputs
- Slot / SlotTransitionRecord :: 도메인 모델
- dict[SlotState, int] :: 상태별 카운트
- dict[str, int] :: reconcile 결과 {created, retired, tombstoned}
### Error Contract
- SlotNotFoundError :: update 대상 row 부재
### Compatibility Rules
- 구현체는 advisory lock 직렬화와 append-only 전이 로그 의미를 반드시 보장한다.
- 구현체는 lock 보유 중 외부 I/O 를 수행하지 않는다.
## Interface: `LedgerPort` (BAT-006)
- Type: external-service (HTTP, portfolio-hub)
- Consumers / Producers: `LedgerWriter`(소비) → `PortfolioHubLedger` 어댑터 또는 `InMemoryLedger` stub
- Purpose: 체결을 외부 회계/포지션 원장에 기록한다. orbit `post_ledger_trade` 패턴.
- Stability: experimental (v1)
### Inputs
- record_trade(trade: LedgerTrade) :: 단일 체결을 원장에 기록 (`execution_id` 멱등) → `LedgerWriteOutcome` 반환
### `LedgerTrade` Fields
- asset_id :: str :: ledger path param (= 슬롯 `portfolio_id`, dcy-004 OQ-1)
- order_id :: str :: 로컬 주문 식별자 (`broker_order_id`)
- execution_id :: str :: 결정적 합성 (paper: `paper:{broker_order_id}:{filled_qty}`, KIS: `kis-daily-ccld:{ord_dt}:{ord_gno_brno}:{odno}:{tot_ccld_qty}`)
- side :: 'buy' | 'sell'
- qty / price :: float :: 체결 수량 / 평단
- fee :: float :: `BrokerPort.calculate_fee(side, qty*price)` 결과
- tax :: float :: 기본 0 (정밀 모델 후속)
- executed_at :: datetime?
- correlation_id (derived) :: `{asset_id}:{order_id}:{execution_id}` — 로컬 dedup 키
### Outputs / Outcomes
- `LedgerWriteOutcome` (dcy-018/REQ-017) :: `{result, cash_balance_after?, asset_quantity_after?}`
- `result = LedgerWriteResult.NEW` :: 새 원장 엔트리 적재
- `result = LedgerWriteResult.DUPLICATE_OK` :: 같은 execution_id 가 이미 기록되어 있음(서버 멱등)
- `cash_balance_after`/`asset_quantity_after` :: portfolio-hub#8 ledger write 응답 root flat 권위값(option D). `new`(201)·`duplicate`(200) 에 존재, 응답 누락 시 None(write-back skip). 409/422 는 예외(아래)라 outcome 미반환.
- write-back(REQ-019/030): `LedgerWriter` 가 `new`·`duplicate` outcome 의 `*_after`(None 아님)를 `SnapshotStore.update_cash_position(asset_id, cash, qty)` 로 반영(best-effort). 절대 권위값이라 duplicate 도 멱등.
- 스냅샷 응답 추정 현금(REQ-029/OQ-4): write-back 전 공백 동안 자산에 `cash_balance += 미정산 pending 합` + `cash_estimated: true` 플래그(라벨). write-back 도착 시 권위값으로 수렴(pending 제거).
### Error Contract
- `LedgerConflictError` :: portfolio-hub 409 — 운영자 해소 대상(자동 재시도 중단)
- `LedgerPermanentError` :: portfolio-hub **404/400/422**(dcy-015/REQ-017, issue #13) — 재시도로 해소 불가(미등록 포트폴리오·malformed). outbox 가 즉시 터미널 `delivery_failed` 처리(재시도 0)
- `LedgerRetryableError` :: 5xx / timeout / network / 408 / 429 — outbox 가 **지수 백오프 + jitter** 로 재시도, `max_attempts` 초과 시 `delivery_failed`(dead-letter)
### portfolio-hub Endpoint (어댑터 구현)
| HTTP | URL | Body | 응답 |
|---|---|---|---|
| POST | `/v1/assets/{asset_id}/ledger/trades` | `side, qty, price, fee, tax, executed_at, execution_id, order_id` | 2xx OK(`duplicate:true` 면 server-side dedup), 409 conflict, 5xx/network → retryable |
## Interface: `LedgerOutbox` Protocol + `LedgerWriter` (BAT-006)
- Type: internal-service (durable work-item store + drainer)
- Consumers / Producers: `ExecutionService`(생산자, fill 시 enqueue) → `LedgerWriter`(소비자, 백그라운드 워커)
- Purpose: 체결 → 원장 기록을 슬롯 전이와 분리해 무유실 + 멱등으로 처리한다. correlation_id 로 outbox 적재 dedup.
- Stability: internal
### Inputs
- enqueue(trade: LedgerTrade) :: `correlation_id` 키로 멱등 적재
- list_pending(limit) :: FIFO pending work item
- get(correlation_id) :: 단일 row 조회
- mark_outcome(correlation_id, outcome, error?) :: outcome 기록 + 상태 전이
### Outcomes (`OutboxOutcome`)
- `completed` :: 어댑터 NEW
- `duplicate_completed` :: 어댑터 DUPLICATE_OK 또는 로컬 dedup 히트
- `retryable_failure` :: 다음 tick 재시도 (status 는 `pending` 유지)
- `conflict_blocked` :: 409, 자동 재시도 중단(운영자 해소)
### WorkItem Status
- pending → completed (성공/중복)
- pending → conflict_blocked (409)
- pending → pending (retryable, attempts++)
### Persistence
- PostgreSQL: `ledger_outbox(correlation_id PK, asset_id, order_id, execution_id, side, qty, price, fee, tax, executed_at, status, attempts, last_outcome, last_error, created_at, updated_at)` — `ON CONFLICT (correlation_id) DO NOTHING` 이 outbox dedup 게이트.
## `FillStatus` 확장 (BAT-006 / REQ-018)
- `state, filled_qty, avg_price, raw` 외에 다음 필드를 운반한다.
- `execution_id :: str?` :: 결정적 합성(브로커별 규칙). filled 상태에서 필수.
- `fee :: float` :: BrokerPort `calculate_fee` 결과(기본 0).
- `tax :: float` :: 기본 0(정밀 모델 후속).
- `cancelled :: bool` :: 외부 취소 감지(dcy-013/REQ-012, issue #9). 기본 False.
- `state` 값과 집행 처리(dcy-013):
- `accepted` / `partially_filled`(취소 아님) :: 다음 tick 대기. (FR-3) `TREX_PENDING_STALE_THRESHOLD`(초, 0=비활성) 초과 시 Alert-only WARN+카운터(상태 미변경).
- `filled` :: 전량 체결 → `pending_buy→holding` / `pending_sell→inactive` + 원장 1건.
- `cancelled`(체결 0 외부 취소) :: `is_terminal_reject` → 롤백(`pending_sell→holding` / `pending_buy→inactive`), `clear_dispatch` → 자동 재매도 가능.
- `partially_filled` + `cancelled=True`(부분 체결 후 외부 취소) :: `is_partial_cancel` → 체결분 원장 1회 기록 + 슬롯 qty 차감(매도: 잔여 held / 매수: 체결분만) + `holding` 복귀. 매도는 cost basis(`avg_price`) 유지.
- KIS 매핑: `inquire-daily-ccld` 응답 row 의 `cncl_yn="Y"` 를 취소로 해석한다(미지/부재 값은 보수적으로 비취소 처리, stale 워치독이 backstop).
## Interface: `MessagingTransport` (Protocol, BAT-007)
- Type: internal-service (transport boundary)
- Consumers / Producers: 데몬 런타임(소비) ↔ 구현체(`RabbitMQTransport` 운영용 / `InMemoryTransport` 결정론 stub)
- Purpose: 메시지 transport 추상화. 도메인은 transport/AMQP 의존 0(NFR-1).
- Stability: experimental (BAT-007)
### Inputs
- declare_topology() :: 익스체인지/큐/바인딩 선언(멱등)
- publish_event(routing_key, payload, mandatory=True) :: `trex.events` 발행. publisher confirms; unroutable → DLQ
- call_rpc(queue, payload, *, correlation_id, timeout) :: 클라이언트 RPC (직접 reply-to 큐 대기)
- consume_rpc_forever(queue, handler, stop) :: 서버측 RPC 소비 루프(stop 까지 블로킹, 백오프 재연결)
- consume_events_forever(queue, binding_pattern, handler, stop) :: 이벤트 구독 루프
### Outputs / Outcomes
- `health() -> TransportHealth` :: `connected | disconnected | reconnecting`
- DLQ 라우팅 :: 처리 실패/unroutable 메시지는 `trex.events.dlq` exchange/queue 로 격리
### Error Contract
- `TransportUnavailableError` :: connected 가 아닌 상태에서 `publish_event`/`call_rpc` 호출 시 즉시 실패(REQ-024 — 로컬 버퍼링 금지)
- `RpcTimeoutError` :: 설정된 timeout 안에 reply 없음
### Compatibility Rules
- pika BlockingConnection 은 thread-safe 가 아니므로 consumer/publisher 마다 자기 connection/channel 을 가져야 한다.
- 토폴로지 선언은 멱등하지만 attribute(durable, args) 가 기존 큐와 다르면 RabbitMQ 가 PRECONDITION_FAILED 로 거부 → batch 마이그레이션 시 큐 재선언 절차 필요.
## Shared Constraints
- 도메인은 외부 의존(브로커, DB 드라이버 등)을 직접 호출하지 않는다(순수 도메인 경계).
- 모든 상태 변경은 단일 advisory lock(SLOT_ADVISORY_LOCK_ID)으로 직렬화한다.
## Current Gaps / Planned Changes
- 영속화 구현체(PostgreSQL)와 그에 딸린 SQL 스키마/마이그레이션은 이번 이관 범위 밖이며 향후 도입 예정.
- 외부 HTTP API / CLI 표면 없음(trex-orbit 의 `trex slot` CLI 미이식).
- 패키지 네임스페이스 결정(plan.md OQ-1)에 따라 import 경로 계약이 확정된다.
## Update Triggers
- Update this document when an externally visible API, event, CLI, or file contract changes.
- Update this document when a batch changes compatibility, validation, or error behavior.
- Update this document when a new external consumer or producer is introduced.
## Change Log
- 2026-06-03 :: bootstrap-baseline 으로 초기 인터페이스 계약 baseline 생성(SlotService + SlotRepository Protocol).
- 2026-06-03 :: BAT-002 — HTTP `/v1` API 계약(데몬, 6 엔드포인트) + 멱등/단일writer 불변식 추가(REQ-007/008/009).
- 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-05 :: BAT-007 — HTTP `/v1` API 인터페이스 제거(adapters/api + fastapi/uvicorn 의존 정리), RabbitMQ messaging 계약 추가(스냅샷 RPC `trex.snapshot.req`, intent RPC `trex.intent.req`, 이벤트 topic `trex.events`, 전략별 durable queue + TTL/max-length, DLQ, publisher confirms, ack-after-commit), `MessagingTransport` Protocol + `TransportHealth` 노출 (REQ-021~026).
- 2026-06-06 :: BAT-008 — `trex.snapshot.req` envelope 계약 정의(`assets[]/view/available_slots/degraded/reason/message`, no price field), `trex.pause.req` 제어 RPC 추가, `PortfolioHubReadPort`/`SnapshotStore`/`PauseStore`/`SnapshotScheduler` 인터페이스, `BrokerPort.get_balance()` 확장, `trex-core` CLI 추가 (REQ-027~033; REQ-030 옵션 D 는 portfolio-hub#8 배포 후).
- 2026-06-10 :: BAT-030 (dcy-020 마감) — buy intent 계약 키 `slot_id` 정본(+`portfolio_id` dual-accept, `trading_date` 제거), 거부 어휘(`unknown_slot`/`already_active`/`capacity_full`/`slot_id_or_portfolio_required`), `slots[]` 5-state + `universe_detached`(trading_date 키 제거), 이벤트 payload trading_date 제거, `available_slots`=admission headroom, SlotService/SlotRepository 자산 중심 계약, 매도 체결 `pending_sell→inactive` (REQ-056~065).
- 2026-06-10 :: BAT-031 (dcy-021 포지션 사이징) — 매수 intent 가 **holding 슬롯 대상이면 증액**(qty+price 추가 매수; M 캡 미적용); 매도 intent 에 **`qty`(부분 매도, 선택)** 추가 — 지정 시 `min(qty, 브로커잔고)` 부분 매도(잔량 holding), 미지정 시 전량(→inactive). 상태머신 13 전이(holding→pending_buy). avg_price 증액 가중평균·부분 매도 cost basis 유지. (REQ-066/057/061/062)