Files
trex-core/docs/interface-contract.md
T
genichinandClaude Opus 4.8 d4c5fcbba1 feat(alarm): trex-core 알람 emitter + trex.alerts exchange (dcy-033 BAT-049, Implemented)
AlarmEmitter(application, best-effort 비차단)+trex.alerts topic exchange 가산·격리+8 CRITICAL code 배선. 608 passed, opus QC PASS, 팀장 e2e(텔레그램 HTTP200) 통과. REQ-098~101 Implemented.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5
2026-06-21 19:25:55 +09:00

560 lines
39 KiB
Markdown

# Trex Core Interface Contract
- Baseline Mode: mixed
- Baseline Seed: .stage-pilot/bootstrap/baseline.yaml
- Status: draft
- Owner: project maintainers
- Last Updated (KST): 2026-06-21 (BAT-049 — dcy-033 알람 채널 trex.alerts 추가)
- 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.slots.req` :: 전략 모듈/운영자의 슬롯 읽기 RPC (request-reply, BAT-035 — REQ-028 `trex.snapshot.req` 대체)
- 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) + cancel(pending 취소 백스톱, dcy-022/BAT-032)
- 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.slots.req` | durable | 슬롯 RPC 요청 큐 (read-only) — REQ-028 `trex.snapshot.req` 대체(BAT-035) |
| 큐 | `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.slots.req` | `correlation_id`, `reply_to=amq.rabbitmq.reply-to` | reply-to 큐로 `{slots[], degraded, reason, message}` (REQ-073/074, BAT-035) |
| 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?}`. **rejected sell** 응답은 additive `retryable: bool` 포함(REQ-007 AC-7/issue #27 — core 소유 reason→retryable, 미인식 기본 true, `bad_request:*` false; admitted/processing/buy 는 미포함). 체결/종료는 응답 아닌 이벤트로 |
| 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.slots.req` 요청 payload (REQ-073 AC-1, BAT-035)
```
{ "filter": "all" | "inactive" | "pending" | "holding" | "error", # default "all"
"spy": true | false # default false; true = watchdog 갱신 안 함(운영자 조회)
}
```
- `filter`: 서버 측 슬롯 status 필터. `"inactive"` 는 buyable 슬롯(universe에 바인딩된 매수 가능 슬롯). 미인식 값은 `"all"` 로 처리.
- `spy=true`: 응답에 포함된 holding 슬롯의 워치독 `last_seen_at` 을 갱신하지 않는다(운영자 CLI 조회가 전략 생존 신호로 오인되지 않도록 — REQ-034 CHG-01).
코어가 reply-to 큐로 보내는 응답 payload:
```json
{
"slots": [
{ "slot_id": "str (UUID)",
"status": "inactive | pending_buy | pending_sell | holding | error",
"symbol": "str | null",
"qty": "float | null", // slot mirror (REQ-062)
"asset_quantity": "float | null", // hub authority (REQ-062)
"avg_price": "float | null",
"entry_price": "float | null",
"portfolio_id": "str | null",
"forced_close_at": "ISO-8601 | null",
"universe_detached": "bool",
"cash_balance": "float | null", // hub authority
"cash_estimated": "bool", // true = pending ledger outbox delta 반영
"warned": "bool", // qty != asset_quantity 시 true (REQ-062)
"watchdog": { // null if watchdog store unavailable
"expired": "bool",
"last_seen": "ISO-8601 | null"
},
"paused": "bool"
}
],
"degraded": "bool",
"reason": "portfolio_hub_unavailable | snapshot_fetch_failed | cash_position_stale | null",
"message": "str | null" // sanitized — no credentials/raw exceptions
}
```
**필드 소스 테이블** (REQ-074 AC-1):
| 필드 | 권위 소스 | 비고 |
|---|---|---|
| `slot_id`, `status`, `avg_price`, `entry_price`, `forced_close_at`, `universe_detached` | slot (core) | 슬롯 row 직접 |
| `qty` | slot (core) | 슬롯 mirror — broker position |
| `symbol`, `portfolio_id` | slot (core) | 자산 바인딩 |
| `asset_quantity` | hub (SnapshotStore) | portfolio-hub 권위값 (REQ-062) |
| `cash_balance` | hub (SnapshotStore) | portfolio-hub 권위값; `cash_estimated=true` 시 ledger outbox delta 추정값 |
| `cash_estimated` | ledger outbox | pending delta 있을 때 true (REQ-029) |
| `warned` | 파생 | `qty != asset_quantity` (REQ-074 AC-2) |
| `watchdog` | WatchdogStore | 만료 여부 + last_seen; null=store 미연결 |
| `paused` | PauseStore | durable pause overlay (REQ-032) |
- **strategy 필드 없음** (REQ-074 AC-5): `strategy_id`, `sell_strategy_id`, `strategy_params`, `active_strategy_id` 는 응답에 포함되지 않는다.
- `spy=false`(기본): holding 슬롯 응답 후 `WatchdogStore.update(slot_id)` 호출 — 전략 폴링 = 생존 신호.
- `spy=true`: watchdog 갱신 건너뜀 — 운영자 CLI(`universe`/`slots`) 가 항상 사용.
### 운영자 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.slots.req[.{instance}]` | `{filter:"inactive", spy:true}` | inactive 슬롯 목록 조회(워치독 억제) |
| `slots [--status all\|holding\|buyable\|pending\|error]` | `trex.slots.req[.{instance}]` | `{filter:"<status>", spy:true}` — `buyable`→`filter:"inactive"` | 슬롯 상태 목록(server-side filter, 워치독 억제, BAT-035) |
| `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) |
| `report [--json]` | (RPC 아님) | — | direct-DB 시점 집계: 현재 보유 / 오늘(KST) 체결·실현 PnL(`ledger_outbox` net 매매 현금흐름, 수수료·세금 반영) / 대기 주문. 신규 RPC 없음(REQ-088/dcy-029 OQ-1) |
- 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 종료.
### 청산 사유 어휘 — reason + actor (REQ-086 / dcy-029 FR-3)
슬롯 청산은 **두 축**으로 노출된다(신규 reason 값·필드 없음; 기존 `reason`·`actor`를 함께 본다):
- **조건축 `reason`** — 안전망(safety-sell) 자율청산 family: `stop_loss` · `take_profit` · `forced_close_at` · `market_close_liquidation`. 운영자 매도: `operator_exit`. 비상정지: `emergency_stop`. 정상 체결: `buy_filled`/`sell_filled`/`sell_filled_partial`.
- **주체축 `actor`** — `trex-safety-sell`(안전망 watchdog 소유 자율청산) · `execution_loop`(집행 루프 체결/롤백) · `operator`(운영자) · 전략(전략 SellIntent).
핵심: `stop_loss`/`take_profit`/`forced_close_at` 는 모두 **watchdog expired(전략 침묵 → 안전망 소유)** 에서만 발화하므로, `actor=trex-safety-sell` 가 동반될 때의 `reason=stop_loss` 는 "전략의 손절"이 아니라 **안전망 자율청산**이다. 로그(`safety-sell fired ... reason= actor=`), `slot_transition_log`, 운영 `report` 가 reason+actor 를 함께 노출해 오해(2026-06-15 064400)를 제거한다.
### `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.snapshot.req` → `trex.slots.req` 로 교체됨(BAT-035).
- **`trex.direct`(topic exchange)**: 특정 인스턴스 지정 전달. routing key = `{instance_key}.{channel_type}`(`channel_type ∈ {intent, slots, 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 마이그레이션 시 큐 재선언 절차 필요.
## Interface: `trex.slots.strategy_state.set` RPC (BAT-042 / dcy-028, REQ-080)
- Type: external-service (strategy daemon ↔ core write RPC)
- Consumers / Producers: 전략 데몬(produce) ↔ core 핸들러(consume). per-instance 큐(`{base}.{instance_key}`), direct reply-to, JSON.
- Purpose: 슬롯의 opaque strategy-state 를 occupancy_seq+version CAS 로 write. read 는 `trex.slots.req` 에 piggyback(별도 RPC 아님).
### Inputs
- `{slot_id, strategy_id, expected_version:int, expected_occupancy_seq:int, data:str}`. `correlation_id = "{slot_id}:{strategy_id}:{expected_version}"`(멱등), delivery persistent. `data` 는 opaque UTF-8(core 무해석), byte size cap(기본 4KB ceiling, 운영 권장 1~2KB).
### Outputs / Outcomes
- 성공: `{ok:true, slot_id, strategy_id, version:<new>}`.
- 실패 reason 전체 집합: `occupancy_changed`(+`occupancy_seq`) / `version_conflict`(+`version`) / `slot_inactive` / `state_too_large` / `bad_request:<detail>`. 소비자는 미인식 reason 에 fail-closed.
### Compatibility Rules
- read 표면(`trex.slots.req`)의 각 슬롯 행에 additive 로 `occupancy_seq`(int)와, 보유 시 `strategy_state` 맵(`{<strategy_id>:{version,data,updated_at}}`)이 실린다(REQ-073/074 CHG). 기존 소비자는 unknown 필드 무시로 하위호환. `strategy_state` 는 strategy_* reasoning 필드(strategy_id 등, REQ-074 AC-5 제외 대상)와 구분되는 opaque 상태 맵이다.
## 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 경로 계약이 확정된다.
## Interface: `trex.alerts` (알람 채널, dcy-033)
- Type: amqp-messaging (topic exchange)
- Consumers / Producers: 모든 모듈(trex-core/orb/tsl/kdh)(발행자) ↔ trex-notifier(단일 소비자)
- Purpose: 제품군 CRITICAL 알람 발신 전용 채널. `trex.events`와 독립 분리. best-effort 비차단.
- Stability: experimental (BAT-049)
### Exchange 토폴로지
| 종류 | 이름 | 타입/속성 | 용도 |
|---|---|---|---|
| 익스체인지 | `trex.alerts` | topic, durable=True | 알람 envelope 발행 전용 독립 채널 |
- **발행자**: 모든 모듈(trex-core, trex-orb-v1, trex-tsl-v1, kis-data-hub). 각 모듈이 자신의 RabbitMQ 연결에서 직접 발행.
- **소비자**: trex-notifier 단독. 자신의 큐를 `alarm.#`(또는 severity별 `alarm.critical.#`) 패턴으로 바인딩. 큐 선언·바인딩은 trex-notifier 소유.
- **발행 속성**: `mandatory=False` — notifier 큐 미연결(unroutable) 시 silent drop. core 발행 경로 비차단.
- **기존 exchange 불변**: `trex.events`(topic), `trex.events.dlq`(fanout), `trex.direct`(topic), `trex.broadcast`(fanout) 등 기존 AMQP 엔티티는 이 채널 추가로 변경되지 않는다(가산·격리 원칙).
### Routing Key 규약
```
alarm.{severity}.{source}
```
- `severity` ∈ `critical` | `warn` | `info` (소문자 리터럴)
- `source` = 발신 모듈 식별자 (예: `trex-core`, `trex-orb-v1`, `trex-tsl-v1`, `kis-data-hub`)
- 예시: `alarm.critical.trex-core`, `alarm.warn.trex-tsl-v1`
### Alarm Envelope JSON 스키마 (schema_version="1")
```json
{
"source": "trex-core",
"severity": "critical",
"code": "kill_switch_fired",
"title": "긴급 청산 발동: global scope, liquidated=3",
"fields": {"scope": "global", "liquidated": 3},
"occurred_at": "2026-06-21T09:30:00+09:00",
"schema_version": "1"
}
```
| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
| `source` | str | 필수 | 발신 모듈 식별자. 소비자(trex-notifier)의 연쇄 분석 기준. |
| `severity` | `"critical"` \| `"warn"` \| `"info"` | 필수 | 등급 (소문자 리터럴 3개). |
| `code` | str | 필수 | 알람 식별 코드. ALARM-INVENTORY.md의 code 키 채택. |
| `title` | str | 필수 | 사람이 읽는 1행 요약. 자격증명·내부 예외 본문 제외. |
| `fields` | dict[str, any] | 선택 | 구조화 컨텍스트. opaque — core/notifier 내용 강제 파싱 없음. 빈 dict `{}` 허용. |
| `occurred_at` | ISO-8601 datetime | 필수 | 발생 시점. 권장: KST+09:00 offset-aware. UTC도 수용. |
| `schema_version` | str | 필수 | 현재 `"1"`. 소비자는 미인식 버전 무시·WARN. |
### trex-core CRITICAL code 목록 (schema_version="1")
trex-core가 `source="trex-core"`, `severity="critical"`로 발행하는 초기 배선 code:
| code | 발생 지점 | 조건 |
|---|---|---|
| `fill_price_unresolvable` | `execution.py` | 체결가·지정가 모두 미해결 → 슬롯 PENDING 고착 |
| `sell_skipped_no_balance` | `execution.py` | 매도 dispatch skip (브로커 잔고 0) |
| `core_holding_broker_flat` | `reconciliation.py` | 슬롯 보유인데 브로커 잔고 0 (정산 불일치) |
| `ledger_delivery_failed` | `ledger_outbox.py` | 원장 영구 실패/dead-letter/409 충돌 |
| `kill_switch_fired` | `killswitch.py` | 긴급 청산 발동 |
| `publish_unavailable_after_reconnect` | `adapters/messaging/rabbitmq.py` | 재연결 후에도 이벤트 발행 불가 |
| `dlq_publish_failed` | `adapters/messaging/rabbitmq.py` | DLQ 발행 실패 |
| `portfolio_hub_unavailable` | `portfolio.py` / `snapshot_scheduler.py` | 포트폴리오/현금 정보 부실화 |
| `schema_verification_failed` | `reset.py` | 리셋 후 schema 검증 실패 |
| `migration_failed` | `reset.py` | 마이그레이션 실패 |
### Update Trigger (알람 envelope 스키마)
- 알람 envelope 스키마를 변경(필드 추가 포함)하면 이 섹션을 갱신하고 `schema_version`을 올리거나 additive 변경 여부를 명시한다.
- breaking 변경은 `schema_version` 주 버전 증가 필수.
- additive 변경(신규 선택 필드 추가)은 `schema_version` 유지 가능하되 이 섹션에 변경 이력 기술.
- 다른 모듈(orb/tsl/kdh)은 이 계약 섹션을 단일 출처로 참조한다. `trex_core` import 없이 envelope 구성에 필요한 모든 정보가 이 섹션에 포함된다.
## 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.
- **알람 envelope 스키마 변경** 시 `Interface: trex.alerts` 섹션 Update Trigger 참조.
## 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-15 :: BAT-043 (issue #27, REQ-007 CHG-20260615-01) — rejected SellIntent 응답에 additive `retryable: bool`(core 소유 reason→retryable 매핑, 미인식 기본 true, `bad_request:*`/terminal false). 손절매 재시도 안전 경로 decoupling. 완전 additive(admitted/processing/buy·기존 필드 불변).
- 2026-06-15 :: BAT-042 (dcy-028) — slot strategy-state 계약: 신규 write RPC `trex.slots.strategy_state.set`(occupancy_seq+version CAS, reject reason 집합 `occupancy_changed`/`version_conflict`/`slot_inactive`/`state_too_large`/`bad_request`), `trex.slots.req` 행에 additive `occupancy_seq`+`strategy_state` 맵(REQ-073 AC-7/REQ-074 AC-6, opaque UTF-8+size cap, strategy_* reasoning 필드와 구분). close(`→INACTIVE`) 시 state 자동 삭제 (REQ-079~082).
- 2026-06-11 :: BAT-035 (dcy-025) — `trex.snapshot.req` → `trex.slots.req` lockstep 교체: 요청 `{filter, spy}`, 응답 `{slots[], degraded, reason, message}`. 슬롯 row 에 hub `asset_quantity`/`cash_balance` + `warned`/`cash_estimated`/`watchdog`/`paused` 병합(REQ-073/074). `trex.snapshot.req` envelope(`assets[]`/`available_slots`/view enum) 제거(REQ-028 Deprecated). strategy 필드 제거(REQ-074 AC-5). CLI `universe`/`slots` → `trex.slots.req`(spy=true). `trex.direct` channel_type `snapshot` → `slots`.
- 2026-06-11 :: BAT-036 (dcy-024) — `safety_sell_enabled: bool=True` per-instance TOML 플래그 추가(REQ-048 CHG-01). `false` 시 `trex-safety-sell` worker 미기동 + WARNING 로그(REQ-005 CHG-01).
- 2026-06-21 :: BAT-049 (dcy-033) — 알람 채널 `trex.alerts` (topic, durable) 신규 추가. 알람 envelope 표준 JSON 스키마(7필드: source/severity/code/title/fields/occurred_at/schema_version, schema_version="1"), routing key `alarm.{severity}.{source}`, 발행자(모든 모듈)/소비자(trex-notifier), mandatory=False best-effort 정책. trex-core CRITICAL code 10종 초기 배선. 기존 wire 계약 불변(가산·격리). (REQ-098~101)
- 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)