Files
trex-core/docs/runtime-flows.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

313 lines
37 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 Runtime Flows
- Baseline Mode: mixed
- Baseline Seed: .stage-pilot/bootstrap/baseline.yaml
- Status: draft
- Owner: project maintainers
- Last Updated (KST): 2026-06-10 (BAT-031 — dcy-021 포지션 사이징)
- Source Discovery / Batch: bootstrap-baseline (pre-Discovery); 데몬/HTTP 흐름 = dcy-002 / BAT-002
- Primary Runtime: daemon (HTTP API) + library (slot 도메인)
## Project Summary
- trex 제품군의 공용 베이스로, 첫 도메인으로 trex-orbit 의 일일 거래 슬롯 생명주기(slot) 순수 도메인을 storage-agnostic 하게 이식한 코어다.
## Purpose
- This document is the current approved execution-flow baseline for the project.
- It is a cross-cutting reference document for Discovery, REQ, Batch Design, Batch Verification, and implementation updates.
## Scope
- Covers representative domain-service flows (library 형태이므로 외부 명령이 아니라 in-process API 호출 흐름).
- Does not duplicate full acceptance criteria or low-level implementation details.
## Covered Entry Points
- `trex-core-daemon` :: 슬롯 코어 데몬 (lifecycle + RabbitMQ 통신, BAT-002/BAT-007)
- `trex-core-reset` :: DB 를 자산 중심 스키마로 wipe + 재초기화 (REQ-064, 운영자 실행 — 데이터 보존 cutover 대체; 파괴적·DB 전역, dry-run 기본)
- RabbitMQ `trex.snapshot.req` :: 전략 모듈의 스냅샷 RPC (request-reply, BAT-007)
- RabbitMQ `trex.intent.req` :: 전략 모듈의 매수/매도 admission RPC (request-reply, BAT-007)
- RabbitMQ `trex.events` :: 체결/슬롯 상태 이벤트 topic exchange (pub/sub, BAT-007)
- SlotService :: 슬롯 생명주기 도메인 서비스 (transit / sell / thresholds / capacity — 자산 중심, BAT-030)
- SlotRepository (Protocol) :: 저장소 구현체 주입 경계
- MessagingTransport (Protocol) :: 메시지 transport 추상화 (RabbitMQ / InMemory stub)
## Flow: `데몬 lifecycle` (BAT-002, BAT-007)
1. `trex-core-daemon --config <path>.toml` 기동 → `DaemonConfig.from_config(toml)` = `from_env()`(TREX_POSTGRESQL_DSN/`TREX_RABBITMQ_URL` 등) + TOML(`instance_key`, `portfolio_hub_token`, `trading_timezone`, `max_active_slots`) 병합. `--config` 필수(없으면 오류 종료, BAT-013/REQ-048). `instance_key` 로 큐명·라우팅·DB 행 스코프 분리(REQ-056).
2. setup: `apply_migrations(dsn)`(멱등; 010 inactive CHECK·011 컬럼 drop 포함) → `execution.reconcile()`(REQ-014) → 이벤트 publisher 의 `declare_topology()`. (일일 preallocation 은 BAT-030 에서 폐기 — 슬롯 생성은 universe reconcile 이 담당.)
3. start: 7개 백그라운드 스레드 동시 기동
- `trex-execution` :: 브로커 dispatch/poll 루프 (REQ-013/014; 매도 수량 브로커 정본 REQ-062)
- `trex-ledger` :: ledger outbox 드레인 (REQ-019)
- `trex-intent-rpc` :: `trex.intent.req` 소비자 (prefetch=1, FIFO, dedup via intent_log; `update_thresholds` 액션 포함 REQ-040)
- `trex-snapshot-rpc` :: `trex.snapshot.req` 소비자 (응답 holding 슬롯마다 워치독 `last_seen_at` 갱신 REQ-034)
- `trex-control-rpc` :: `trex.control.req` 소비자 — pause/resume/list + kill_switch/resume_kill_switch (REQ-032/043)
- `trex-snapshot-scheduler` :: 일일 portfolio-hub 스냅샷 refresh + **universe 슬롯 reconcile**(REQ-058) + holding 불일치 reconcile(REQ-062)
- `trex-safety-sell` :: 자율 안전 매도 트리거 루프 (forced_close / stop-loss·take-profit / 장 마감, REQ-037/038/039)
4. shutdown: `SIGINT`/`SIGTERM` → `stop.set()` → 모든 스레드 join + 모든 transport `close()`. HTTP 표면 0, 별도 포트 바인딩 없음.
## Flow: `스냅샷 RPC` (RabbitMQ `trex.snapshot.req`, BAT-007)
1. 전략 모듈이 RPC 요청을 `trex.snapshot.req` 큐로 publish — payload 에 `view`/`strategy_id`/`trading_date`, properties 에 `correlation_id` + `reply_to`(direct reply-to).
2. `trex-snapshot-rpc` 스레드가 메시지 수신 → `make_snapshot_rpc_handler(slot_repo, slot_service)` 로 슬롯 목록 + 가용성 계산.
3. 응답을 `reply_to` 큐로 publish (`correlation_id` 일치) → manual ack.
4. read-only 이므로 dedup 불요; 재요청 시 동일 응답을 다시 생성한다.
## Flow: `매수/매도 intent admission RPC` (RabbitMQ `trex.intent.req`, BAT-007)
1. 전략 모듈이 RPC 요청을 `trex.intent.req` 큐로 publish — payload `{intent_type, intent_id, ...}`, properties `correlation_id` = intent 멱등키, `reply_to`.
2. `trex-intent-rpc` 스레드(단일, prefetch=1)가 메시지 수신 → `IntentService.submit_buy/sell` 호출.
3. `intent_log.try_insert(intent_id, ...)` 가 dedup 게이트 — 중복 메시지는 기존 decision 재반환(슬롯 무전이).
4. 신규면 admission(자산 중심, REQ-059/061): **slot_id 정본 키**(미스 시 `unknown_slot`) 또는 **portfolio_id dual-accept**(미스 시 `unknown_portfolio`; 둘 다 없으면 `slot_id_or_portfolio_required`) 로 대상 영구 자산 슬롯 resolve → 이미 active(holding/pending) 면 `already_active` → symbol resolve(슬롯 `portfolio_id` 기준 universe) → `count_active >= max_active_slots(M)` 면 `capacity_full`(FCFS·무치환) → `SlotService.transit_slot(inactive→pending_buy)` → Postgres durable commit. (buy payload 에 client symbol·trading_date 없음)
5. admission 응답(`status, slot_id?, reason?`)을 `reply_to` 큐로 publish → manual ack.
6. ack-after-commit 시퀀스: durable commit 이 ack 보다 먼저이므로 ack 전 크래시 시 재전달돼도 (3) 의 dedup 으로 재실행되지 않는다.
7. 체결/종료는 응답이 아니라 후속 이벤트(아래)로 전달 — admission 동기 + fill 비동기 2단계 시맨틱.
## Flow: `슬롯 이벤트 발행/구독` (RabbitMQ `trex.events`, BAT-007)
1. 체결: execution 루프가 `pending_buy→holding`(또는 `pending_sell→closed`)을 commit + ledger outbox enqueue (REQ-018/019).
2. 같은 스레드에서 `event_publisher.publish_event(rk, payload)` 호출.
- routing key: `{event_type}.{strategy_id}.{symbol}` (예: `slot_filled_buy.A.005930`).
- payload: 현재 상태 + `sequence_at`(슬롯 `updated_at` iso) 운반 → 수신자는 last-wins 적용 가능 (순서 보증 불요).
- publisher confirms 활성, 발행 실패는 로그 + DLQ 라우팅(`trex.events.dlq`). publish 실패가 슬롯 전이를 롤백하지 않는다.
3. 전략별 durable queue(`trex.events.strategy.{strategy_id}`)는 `*.{strategy_id}.#` 패턴으로 바인딩 → broker 단 필터로 자기 자산 이벤트만 수신.
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: `universe 슬롯 reconcile` (BAT-028/030 / REQ-058 — 일일 preallocation 대체)
`trex-snapshot-scheduler` 가 universe fetch 직후 같은 패스에서 영구 자산 슬롯 집합을 universe 에 정합시킨다 (일일 preallocation/dcy-014 는 BAT-030 에서 폐기).
1. 신규 자산(portfolio_id 에 슬롯 없음) → `inactive` 영구 슬롯 생성(멱등 — `ON CONFLICT (instance_key, portfolio_id)` partial unique).
2. universe 이탈 + inactive → soft-retire(`retired_at` set; 행 보존, 운영 뷰 `universe_detached`). 이탈 + holding/pending → tombstone(그대로 유지 — safety-sell 경로 보존).
3. 가드: universe 빈 응답이면 reconcile skip(degrade 가드); 직전 대비 ≥50% 축소면 retire 보류(`allow_retire=False`, 대량 소실 가드 — dcy-020 OQ-2 D3).
4. 이어서 `reconcile_core_holdings`(REQ-062): 브로커 잔고 ground truth 로 core holding 검증 — core holding/브로커 0 → slot `error` 격리+WARN, 수량 불일치 → WARN(상태 불변), 브로커 보유/슬롯 없음 → WARN(unmanaged). 자동 청산 없음.
## 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.control.req` 큐에 `{action: pause|resume|list, portfolio_id, actor}` 발행 → `make_control_rpc_handler` 가 pause action 을 같은 store 로 위임 → `{status, paused[], action}` 응답. (BAT-012: 구 `trex.pause.req` → `trex.control.req` 확장)
3. 응답 산출: `build_snapshot_envelope` 가 `pause_store.list_paused()` 를 매 요청마다 읽음 → `mask(pending | warning | pause)` 합성. resume 후 **다음 요청부터 즉시 복귀**(별도 reload 불필요).
4. 영속: daemon 재시작 + 일일 refresh 후에도 `snapshot_pause` row 유지(universe swap 은 pause 를 만지지 않음).
## Flow: `운영자 CLI` (BAT-020 / dcy-016, REQ-032)
운영자 CLI(`trex-core`)는 조회·매매를 데몬 RabbitMQ RPC 로 통일해 단일 writer admission 과 불변식을 보존한다(DB 직접 쓰기 없음). pause/resume/paused 만 기존 direct-DB 유지(OQ-3).
1. 인스턴스 라우팅: `--config <toml>`(canonical, REQ-048 `instance_key`) 또는 `--instance <key>`(단축·override), 미지정 시 `default`. 큐명은 `_instance_queue` 규칙(`{base}` 또는 `{base}.{instance}`). 모든 출력에 대상 인스턴스 표시(R-2).
2. `universe`/`slots`: `trex.snapshot.req` RPC(`actor="operator"`/`touch_watchdog=false`) → 응답 envelope 의 assets 출력. **워치독 억제**로 운영자 조회가 전략 생존 신호로 오인되지 않는다(REQ-034, R-4). `slots --status holding`→`view=held`.
3. `enter <portfolio_id>`/`exit <slot_id>`: `trex.intent.req` buy/sell RPC. buy 는 portfolio_id 필수(symbol 서버 resolve, dcy-011), sell 은 slot_id + optional price(시장가 기본, dcy-012). admission 응답(`status/slot_id/reason`)을 표시, rejected 면 비0 종료.
4. enter/exit 안전장치(OQ-2): 확인 프롬프트 기본 ON(대상 인스턴스·broker·요약, 기본 No) → `--yes`(paper 생략) → **live 는 `--yes --live` 이중 ack**(`TREX_KIS_ALLOW_LIVE` 배포 게이트 위 액션 게이트) → `--dry-run` 미제출 미리보기. `--json` 비대화형은 enter/exit 에 `--yes` 필수(미지정 시 자동 제출 없이 에러).
5. 실패 표시: RPC 타임아웃·데몬 미가동·라우팅 오류는 명확한 에러(비0 종료)로 표시(NFR-4). RPC 통일이라 데몬·RabbitMQ 미가동 시 조회 불가는 의도된 트레이드오프(R-3).
## 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(max_active_slots)` → `available_slots = available_count`(= M 설정 시 `max(0, M − active)`, 미설정 시 inactive 수; REQ-059).
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 필드는 없음**.
5. 추정 현금 오버레이(dcy-018/REQ-029, OQ-4): 미정산(pending) ledger outbox 행이 있는 자산은 `cash_balance += pending cash delta`(매수 −, 매도 +) + `cash_estimated:true` 라벨로 노출 → option D write-back 도착 시 권위값으로 수렴(pending 제거). best-effort(실패 시 응답 불변). 수량은 슬롯 오버레이(REQ-029)가 정확값이라 추정 불요.
## Flow: `per-slot 워치독 갱신` (BAT-010/011 / REQ-034, REQ-040)
전략 모듈의 생존 신호를 추적해 자율 안전 매도가 정상 운용 슬롯에 개입하지 않게 한다.
1. `trex-snapshot-rpc` 가 `trex.snapshot.req` 를 처리할 때 응답에 포함된 모든 `holding` 슬롯에 대해 `WatchdogStore.update(slot_id)`(`last_seen_at = now`)를 호출한다. 전략 모듈이 스냅샷을 폴링하는 한 그 슬롯은 "활성"으로 판정된다.
2. `update_thresholds` intent(REQ-040)도 수신 시 해당 슬롯 `last_seen_at` 을 리셋한다(임계값 갱신 = 생존 신호).
3. `WatchdogStore.is_expired(slot_id, TREX_WATCHDOG_TIMEOUT)` 가 True 이고 grace period(`TREX_WATCHDOG_GRACE_PERIOD`, 데몬 부팅 직후) 밖이면 그 슬롯은 워치독 만료 → forced_close/가격 트리거 대상이 된다.
4. in-memory store 라 데몬 재시작 시 초기화되며 grace period 가 자동 재시작된다(부팅 직후 오탐 방지).
5. 가시화(dcy-017/REQ-034): 스냅샷 RPC 응답에 holding 슬롯의 워치독 상태 `watchdog: [{slot_id, portfolio_id, symbol, expired, last_seen}]` 를 read-only 로 동봉한다(best-effort — 실패 시 응답 불변, NFR-1). `trex-core slots` 가 `expired=true` 슬롯을 **WATCHDOG-OWNED(전략 이탈)** 로 표시한다. 운영자 조회는 `actor="operator"`/`touch_watchdog=false`(dcy-016)라 이 노출이 워치독을 리셋하지 않는다(비침습). Redis projection·push 알림은 미채택(OQ-1).
## Flow: `forced_close_at 만기 자동 매도` (BAT-011 / REQ-037)
`trex-safety-sell` worker 가 `TREX_FORCED_CLOSE_CHECK_INTERVAL` 주기로 수행.
1. 오늘 `holding` 슬롯 중 워치독 만료(grace 밖)이고 `forced_close_at <= now(UTC)` 인 슬롯을 선별.
2. 대상 슬롯에 `SlotService.sell(actor="trex-safety-sell", reason="forced_close_at")` 호출 → `holding→pending_sell`(REQ-036). 이후 체결은 집행 루프가 처리.
3. 워치독 활성 슬롯·`forced_close_at=None` 슬롯은 제외. 이미 `pending_sell` 이면 idempotent(중복 매도 없음). KIS 호출 없음(DB + in-memory 비교만).
## Flow: `stop-loss·take-profit 가격 체크` (BAT-011 / REQ-038)
`trex-safety-sell` worker 가 `TREX_STOP_LOSS_POLL_INTERVAL` 주기로 수행.
1. `holding` + 워치독 만료 + (`stop_loss_price` 또는 `take_profit_price` 설정) 슬롯만 대상(워치독 활성 슬롯은 KIS 호출조차 안 함).
2. `BrokerPort.get_current_price(symbol)` → KIS 주식현재가 시세 `inquire-price`(tr `FHKST01010100`, 실전/모의 공용, BAT-011 CHG-04 정정)의 `stck_prpr`. paper 는 프로그램된 가격(미설정 시 None).
3. `현재가 <= stop_loss_price` → 손절(`reason="stop_loss"`), `현재가 >= take_profit_price` → 익절(`reason="take_profit"`)으로 `SlotService.sell()`. 가격 미수신(None) 시 skip(오탐 방지, 다음 tick 재시도).
4. 쿼터 가드: 시간당 호출 상한 `N × (3600 / interval)` 추정이 임계 초과 시 경고 로그(REQ-038 AC-8).
## Flow: `장 마감 강제 청산` (BAT-011 / REQ-039)
`TREX_MARKET_CLOSE_LIQUIDATION_TIME`(HH:MM KST, optional) 설정 시에만 동작.
1. `trex-safety-sell` worker 가 매 tick 현재 KST 시각을 평가 → 설정 시각 도달 + 같은 거래일 미발동(`last_liquidation_date` 가드)이면 발동.
2. 발동 시 **워치독·grace 무관** 모든 `holding` 슬롯에 `SlotService.sell(reason="market_close_liquidation")`. 이미 `pending_sell` 은 idempotent.
3. 미설정이면 동작하지 않음. in-memory 가드라 재시작 시 같은 날 재발동 가능(설계상 허용 — REQ-039 Notes).
## Flow: `safety-sell 재시도 쿨다운` (BAT-014 / REQ-052)
영속 거부(휴장/잔고/규제)로 동일 슬롯에 매도가 무한 재제출되는 것을 막는 per-slot 쿨다운. `forced_close`/`stop_loss`/`take_profit` 트리거에 적용(`market_close` 제외 — `_last_liquidation_date`로 하루 1회 보호).
1. `run_once` tick 시작 시 `_holding()`에 없는 슬롯의 쿨다운 상태(`_last_attempt`/`_attempt_count`)를 정리(FR-4).
2. 트리거가 `_safe_sell` 호출 직전, 슬롯이 `now - last_attempt_at < TREX_SAFETY_SELL_MIN_RETRY_INTERVAL`(기본 600s)이면 **건너뜀**(stop-loss/take-profit 은 KIS 시세 poll 도 skip).
3. 쿨다운 만료 후 트리거 조건이 여전히 충족되면 재시도 + `last_attempt_at` 갱신 + `attempt_count` 증가(성공 여부 무관).
4. `attempt_count >= TREX_SAFETY_SELL_MAX_RETRY_WARN_COUNT`(기본 3) 도달 시 재시도 직전 운영자 `WARNING` 로그(slot_id/count/reason/last_attempt_at).
5. 트리거 조건 해소(가격 회복 등)·체결 완료(슬롯 holding 이탈) 시 재시도/카운트 증가 없음. 데몬 재시작 시 쿨다운 초기화(재시작 직후 1회 재시도 허용).
## Flow: `safety-sell 가시화` (BAT-021 / dcy-017, REQ-037)
전략 이탈로 슬롯이 안전망(워치독) 소유로 넘어가거나 실제 청산이 발동하는 결과 기반 신호를 무성에서 가시화한다(issue #12). best-effort — 어떤 로깅/메트릭 실패도 워치독 판정·청산을 막지 않는다(NFR-1).
1. 워치독 소유 진입/이탈: `run_once` 가 매 tick `_update_ownership` 로 holding+워치독 만료(grace 밖) 슬롯 집합을 직전 tick 과 비교 → **진입(전략 이탈)=WARN**, **이탈(복귀)=INFO** + 카운터(`owned:enter`/`owned:exit`). grace 중엔 판정 안 함.
2. 발동 가시화(OQ-2 차등): **WARN** = `forced_close_at`·`stop_loss` 발동 / **INFO** = `take_profit`·`market_close_liquidation` 발동 + 카운터(`fire:{reason}`). 모든 로그는 `slot_id`·`portfolio_id`·`reason`·`phase`·`instance_key` 구조화 필드 포함.
3. per-tick 스킵/no-fire 평가는 로그하지 않고 카운터만(30초 주기 로그 스팸 방지; 영속 거부 반복 경고는 REQ-052 가 별도 담당).
4. WatchdogStore 가 in-memory 라 소유 *상태* 는 재시작 시 소실(폴링 재개 시 재구성); 발동 *로그* 는 이력으로 남는다(R-3). 메트릭은 in-process 카운터(`SafetySellService.counters`).
## Flow: `kill-switch 비상 정지` (BAT-012 / REQ-043/045/046)
운영자가 `trex.control.req` 로 비상 정지를 발동해 범위별 holding 을 즉시 청산하고 당일 전략을 차단한다.
1. `trex-control-rpc` 가 `{action:"kill_switch", scope, portfolio_id?}` 수신 → `KillSwitchService.fire()`.
2. 대상 범위(portfolio 또는 전역)의 `holding` 슬롯을 `SlotService.sell(actor="kill-switch", reason="emergency_stop")` 로 순차 전이(`holding→pending_sell`). 이미 pending_sell 은 idempotent(REQ-036). 이후 체결은 집행 루프가 처리.
3. 발동 범위를 `killswitch_exclusion(portfolio_id|'*', trading_date)` 에 day-scoped 로 표시(REQ-044) + 발동 이력 로깅(actor/scope/처리 수).
4. masking: 이후 snapshot RPC 응답 산출이 `mask = pending|warning|pause|killswitch_today` 로 대상 자산을 제외 → 당일 추가 매수 차단. 전역(`'*'`)이면 전체 universe 마스킹.
5. 자동 복귀: `killswitch_exclusion` 은 `trading_date` 키라 다음 거래일 조회에서 빠짐(별도 동작 불필요). 재시작해도 같은 거래일이면 영속 유지.
6. 수동 해제: `{action:"resume_kill_switch", scope, portfolio_id?}` → `KillSwitchService.resume()` 가 day-scoped row 삭제 → 다음 응답부터 정상 노출. **이미 청산된 슬롯은 비가역**(매도 되돌리지 않음).
7. 독립성: kill-switch 는 자율 안전 매도 worker(dcy-007)와 스레드/상태 공유 없음. 동일 슬롯 동시 sell() 은 REQ-036 idempotency 로 1회만 전이.
## Flow: `multi-instance 라우팅` (BAT-013 / REQ-049/050)
다중 인스턴스를 동일 RabbitMQ 에 붙여도 split-brain 없이 운영하기 위한 큐/exchange 분리.
1. 각 인스턴스는 `--config inst-N.toml`(`instance_key`)로 기동 → 수신 큐명이 `{base}.{instance_key}` 로 분리(`default` 는 리터럴 유지, 하위호환).
2. `declare_topology(instance_key)`: `trex.direct`(topic) + `trex.broadcast`(fanout) 선언 + 인스턴스 intent/snapshot/control 큐를 `trex.direct` 에 `{instance_key}.{channel}` 로 바인딩 + control 큐를 `trex.broadcast` 에 dual 바인딩.
3. **directed**: strategy/운영자가 `trex.direct` 에 `inst-1.intent` 로 publish → `trex.intent.req.inst-1` 에만 전달(다른 인스턴스 큐 미수신). 인스턴스별 WatchdogStore/SafetySellService 가 자기 큐만 소비 → split-brain 방지(NFR-3).
4. **broadcast**: `trex.broadcast` 에 publish → 모든 인스턴스 control 큐 동시 전달(global kill-switch 등). 중복 처리는 idempotency 로 무해.
5. 기존 reply-to RPC 직접 큐 경로(default)는 그대로 동작 — 추가 바인딩은 비파괴적(FR-5 무회귀).
## Flow: `broker degrade 분기` (BAT-007 / REQ-024)
1. transport connection drop → 어댑터가 `_mark_disconnected()` + 백오프(1s→2s→4s→8s→16s→30s cap) 재연결.
2. application 레이어는 `transport.health()` 가 `disconnected/reconnecting` 인 동안 신규 발행/RPC 가 `TransportUnavailableError` 로 빠르게 실패한다 → 전략은 매매 보류.
3. core 자율 안전 매도(stop-loss·take-profit / forced_close / 장 마감 청산)는 transport 와 무관하게 `trex-safety-sell` worker 가 수행한다(아래 safety-sell 흐름; dcy-007 REQ-037~041 구현, BAT-010/011). 이전 "A-5 미구현" 항목은 dcy-007 로 해소됨.
4. 로컬 버퍼링 없음 — broker 복구 후 옛 intent 가 자동 재생되지 않는다(전략이 신선 상태로 재판단).
## Flow: `주문 집행 루프` (백그라운드, BAT-004)
데몬 lifecycle 에서 시작되는 단일 백그라운드 execution 루프가 주기적으로(설정 간격) 수행:
1. dispatch: `list_undispatched_pending(instance_key)`(status pending_* AND `broker_order_id IS NULL`; 인스턴스 스코프, REQ-056) → `BrokerPort.submit_order` → `mark_dispatched(broker_order_id)`(멱등 가드). 거부 시 `pending_buy→inactive` / `pending_sell→holding` 롤백.
- **매도 수량 = 브로커 잔고 정본**(dcy-020/REQ-062, D3): 매도 dispatch 는 `broker.get_balance()` 의 해당 symbol `hldg_qty` 로 수량을 정한다(슬롯 미러 `qty` fallback 없음). 브로커 0 이면 주문 미제출 + WARN + `sell_skipped_no_balance` 카운터(divergence 는 reconcile 경로로 표면화). skip 된 매도가 큐를 막지 않도록 리스트 기반 dispatch.
- 가격/주문유형 결정(dcy-012/REQ-013, issue #10): `_dispatch` 는 side 분기 없이 슬롯 `limit_price` **한 필드**만 읽는다 — 값이 있으면 지정가(`order_type="00"`, 그 가격), 없으면 시장가(`order_type="01"`). `limit_price` 는 pending 진입 시 set 되고 pending 이탈(→holding/→closed) 시 clear 되므로, 매수 지정가(과거 `entry_price`)가 매도 주문에 재사용되지 않는다. 자율 매도(safety-sell/kill-switch)는 가격 미전달 → 시장가로 즉시 체결(미체결 `pending_sell` 갇힘 방지, issue #9 연계).
2. poll: `list_dispatched_pending(instance_key)`(broker_order_id NOT NULL) → `BrokerPort.poll_fill` → 체결 시 `pending_buy→holding`(avg_price/qty) / `pending_sell→inactive`(점유 초기화·자산 정체성 보존), 거부 시 롤백. 전이 후 `clear_dispatch`.
- 외부 취소 감지(dcy-013/REQ-012/013, issue #9): 계좌에서 직접 취소된 주문을 KIS `poll_fill` 이 `cncl_yn="Y"` 로 파싱한다. **체결 0 취소** → `FillStatus(state="cancelled")` → 기존 롤백 경로(`pending_sell→holding`, `clear_dispatch`)로 자동 복구(영구 갇힘 제거). **부분 체결 후 취소**(`is_partial_cancel`) → 체결분 원장 1회 기록 + 슬롯 qty 차감(매도: 잔여 held, cost basis 유지 / 매수: 체결분만) + `holding` 복귀(OQ-1 A안, 과매도·이중기록 방지).
- pending stale 워치독(dcy-013/REQ-014 FR-3, Alert-only): `accepted`/`partially_filled`(취소 아님)로 다음 tick 대기 중인 슬롯이 `TREX_PENDING_STALE_THRESHOLD`(초, 0=비활성)를 초과해 머물면 WARN 로그 + `ExecutionResult.stale_pending` 카운터로 가시화한다. **상태는 변경하지 않는다**(auto-remediation 후속) — 취소 감지 누락(R-1) 등 미지 원인의 무한 갇힘이 silent 로 남지 않게 하는 안전망.
3. reconcile(재기동): 서빙 전 `list_dispatched_pending` 재폴링으로 크래시 후 복구(중복 주문 0). 미디스패치(broker_order_id NULL) 슬롯은 다음 dispatch 가 1회만 송신.
- 모든 슬롯 쓰기는 SlotService(advisory lock) 단일 경로(불변식 A). 브로커 주문/취소는 BrokerPort 단일 세션만(불변식 B).
- 슬롯 `limit_price` 생애주기(dcy-012/REQ-002): INACTIVE→PENDING_BUY(매수 지정가 set) → PENDING_BUY→HOLDING(clear) → HOLDING→PENDING_SELL(매도 지정가 set 또는 None=시장가) → PENDING_SELL→INACTIVE(clear). 롤백(→INACTIVE) 전이에서도 clear. 외부 취소 후 holding 복귀에서도 clear 되어 재매도는 시장가.
- 어댑터: paper(결정적 async 더블) / KIS(모의투자 검증, 실계좌 게이팅).
## Flow: `체결 → ledger outbox` (BAT-006)
집행 루프 fill 전이 직후 `ExecutionService` 가 ledger 기록을 outbox 에 적재해 슬롯 전이 critical path 와 ledger write 를 분리한다(원장 기록 단일 주체 = 집행 경로, NFR-3).
- ledger skip 가시화(dcy-011/REQ-018, issue #11): `_record_ledger` 가 `portfolio_id` 또는 `execution_id` 누락으로 skip 하면 무성 반환하지 않고 **WARN 로그 + `ExecutionResult.ledger_skipped` 카운터**로 노출한다(슬롯 전이는 그대로 진행). portfolio_id 필수화(REQ-007)로 주원인은 제거됐고, 잔여 누락은 운영 점검 쿼리(REQ-055: closed 슬롯 ↔ `ledger_outbox` anti-join, `find_ledger_gap_slots`)로 영구 탐지한다.
1. fill 감지: `BrokerPort.poll_fill` 이 `state="filled"` 와 결정적 `execution_id`(paper: `paper:{broker_order_id}:{filled_qty}` / KIS: `kis-daily-ccld:{ord_dt}:{ord_gno_brno}:{odno}:{tot_ccld_qty}`), 계산된 `fee`(BrokerPort `calculate_fee(side, amount)`), `tax=0` 을 운반.
2. 슬롯 전이: `pending_buy→holding`(avg_price/qty) 또는 `pending_sell→closed` 를 advisory lock 안에서 먼저 commit. 이 단계가 critical path.
3. ledger enqueue: 슬롯의 `portfolio_id`(== ledger `asset_id`, OQ-1 결정) 가 있으면 `LedgerTrade(asset_id, order_id, execution_id, side, qty, price, fee, tax, executed_at)` 을 `LedgerOutbox.enqueue` 로 적재. 행 식별자는 `correlation_id = {asset_id}:{order_id}:{execution_id}`(로컬 dedup). `portfolio_id` 부재면 ledger 기록 skip 후 슬롯 전이는 정상 유지.
4. `clear_dispatch` 로 `broker_order_id` 해제.
## Flow: `ledger writer` (백그라운드, BAT-006)
데몬 lifespan 에서 별도 백그라운드 워커가 ledger outbox 를 drain 한다. 슬롯 전이와 분리되어 eventually-consistent.
1. `LedgerOutbox.list_pending(limit)` 으로 pending work item 을 FIFO 로 가져온다. **백오프 중(`next_retry_at > now`)인 행은 건너뛴다**(dcy-015/REQ-019 head-of-line 보호, NFR-3).
2. row 의 현재 상태가 이미 `completed` 면 (e.g. 동시/재처리) `duplicate_completed` 로 마킹하고 어댑터를 호출하지 않는다.
3. `LedgerPort.record_trade(trade)` 호출 → `LedgerWriteOutcome{result, cash_balance_after?, asset_quantity_after?}`.
- `result=NEW` → outcome `completed`, row status `completed`.
- `result=DUPLICATE_OK`(어댑터 server-side dedup) → outcome `duplicate_completed`, row status `completed`, ledger 중복 0.
- **option D write-back(dcy-018/REQ-019/030)**: `new`·`duplicate` outcome 의 `cash_balance_after`/`asset_quantity_after`(None 아님)를 `SnapshotStore.update_cash_position(asset_id, …)` 로 반영(best-effort — 실패해도 ledger outcome 무차단, NFR-1). `*_after` 가 **절대 권위값**이라 duplicate 도 멱등. 응답 누락(구 hub)·409/422 는 write-back skip.
- `LedgerConflictError`(portfolio-hub 409) → outcome `conflict_blocked`, row status `conflict_blocked` (자동 재시도 중단, 운영자 해소 대상).
- `LedgerPermanentError`(portfolio-hub **404/400/422**, dcy-015/REQ-017, issue #13) → outcome `permanent_failure`, row status **`delivery_failed`**(터미널, 재시도 0). 재시도로 해소 불가한 클라이언트 오류(미등록 포트폴리오 등) → 즉시 중단 + WARN.
- `LedgerRetryableError`(timeout/5xx/network/408/429) → **지수 백오프 + jitter**(`delay = min(base × 2^(attempts-1), cap)`, `next_retry_at` 설정) 후 다음 due tick 에 재시도. **`max_attempts` 초과 시** outcome `dead_lettered`, row status **`delivery_failed`**(dead-letter, 터미널) + WARN. 무한 1초 재시도 제거.
4. 터미널 실패 가시화(dcy-015/REQ-018): `delivery_failed`(permanent/dead-letter)는 `WriterStats.delivery_failed` 카운터 + WARN 로그 + 구분 status 로 노출되어 정상 `pending` 과 구별된다(무성 영속 실패 금지). dead-letter 는 폐기가 아니라 운영자 재처리 가능 상태이며 `find_ledger_gap_slots`(REQ-055)·skip 가시화와 연계해 누락을 추적한다.
5. 백오프 파라미터는 env 설정: `TREX_LEDGER_RETRY_BASE_SEC`(기본 1.0)/`TREX_LEDGER_RETRY_CAP_SEC`(기본 300)/`TREX_LEDGER_MAX_ATTEMPTS`(기본 8).
6. portfolio-hub 장기 장애에도 outbox 가 적재되어 무유실. 재시작 시 PostgreSQL `ledger_outbox.status='pending'` 행을 그대로 이어 처리한다(터미널 `delivery_failed`/`conflict_blocked` 는 제외).
## Shared Components
- SlotRepository Protocol :: 저장소 추상화(자산 중심: list/count/reconcile 모두 instance_key 스코프). 구현체(InMemorySlotRepository, PostgreSQLSlotRepository)를 주입.
- slot_advisory_lock_id(instance_key) :: 인스턴스 스코프 advisory lock 식별자 — 공유 DB 의 인스턴스 간 직렬화 격리(REQ-057 NFR-1).
- transactional_advisory_lock :: 단일 트랜잭션 + advisory lock 컨텍스트(외부 I/O 금지).
## Flow: `slot 상태 전이 (transit_slot)` (BAT-030 자산 중심)
1. 호출자가 `transit_slot(slot_id, from_state, to_state, reason, actor, **fields)` 를 호출한다.
2. (from_state, to_state) 가 전이 패밀리(7 base[dcy-021 holding→pending_buy 증액 포함] + *→error + error→{inactive,holding} = 13)에 속하는지 먼저 검증한다(아니면 SlotTransitionError).
3. 인스턴스 스코프 advisory lock(`slot_advisory_lock_id(instance_key)`) 획득 후 슬롯을 fetch 한다(없으면 SlotNotFoundError).
4. 현재 status == from_state 인지 확인한다(아니면 SlotStateMismatchError — 레이스 감지).
5. to_state 의미에 맞춰 필드를 계산하고 슬롯 행을 update 한다 — **INACTIVE 진입 시 포지션·주문 점유 필드 초기화 + 자산 정체성(instance_key/portfolio_id/symbol/retired_at) 보존**.
6. slot_transition_log 에 전이 기록 1행을 append 하고 갱신된 슬롯을 반환한다.
7. 외부 I/O(브로커 등)는 lock 밖에서 호출자가 수행한다(lock 보유 중 금지).
## Flow Constraints
- 모든 상태 변경 API 는 advisory-locked 단일 트랜잭션 안에서 수행한다.
- lock 보유 중에는 외부 I/O 를 수행하지 않는다.
- slot_transition_log 는 append-only 다(UPDATE/DELETE 금지).
- 상태 전이는 read(현재 상태)-검증-write 가 동일 트랜잭션에서 원자적으로 이뤄져 레이스를 차단한다.
## Current Gaps / Planned Changes
- 실제 영속화(PostgreSQL) 흐름은 미구현 — 현재는 in-memory repository 로만 구동.
- CLI/데몬 런타임 흐름 없음(library). 상위 애플리케이션 통합 흐름은 영속화 단계 이후 정의.
- 강제 청산/EOD 같은 상위 운영 흐름은 이번 순수 도메인 범위 밖.
## Update Triggers
- Update this document when a command entry point changes.
- Update this document when a runtime orchestration path changes.
- Update this document when a batch changes the approved flow baseline.
## Change Log
- 2026-06-03 :: bootstrap-baseline 으로 초기 흐름 baseline 생성(slot 도메인 in-process API 흐름 기준).
- 2026-06-03 :: BAT-001 이식 완료 반영 — preallocate/transit 흐름이 `trex_core.domain.slot` 실제 코드로 구현됨(REQ-004). 영속화/CLI 흐름은 여전히 미구현(범위 밖).
- 2026-06-03 :: BAT-002 — 데몬 lifecycle + 매수/매도 intent HTTP 흐름 추가, Primary Runtime=daemon+library(REQ-005~009). 브로커 집행/safety floor 흐름은 후속 Discovery.
- 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-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 배포 후 별도 구현).
- 2026-06-10 :: BAT-030 (dcy-020 마감) — 자산 중심 전환 완료: 일일 preallocation 폐기(→universe reconcile), intent admission slot_id 정본 + M 캡, 집행 매도 수량 브로커 정본 + 인스턴스 스코프 dispatch, 5-state(inactive) 전이, DB 재초기화 도구(`trex-core-reset`, 데이터 보존 cutover 대체 — REQ-064 CHG-20260610-03) (REQ-056~065).
## Flow: `포지션 사이징` (BAT-031 / dcy-021, REQ-066)
보유(holding) 자산 슬롯의 수량을 명령형으로 조정한다. 전략이 스냅샷 cash+quantity 로 qty+price 를 계산해 보내고 core 는 실행만 한다(target 미저장).
1. **증액**: 보유 슬롯에 매수 intent(qty+price) → admission 이 `holding→pending_buy`(M 캡 미적용, active 수 불변) → dispatch 매수(qty=주문) → 체결 `pending_buy→holding`: 보유 qty=**브로커 잔고**, avg_price=가중평균(`(old_qty×old_avg+filled×fill)/new_total`, old_qty=브로커총−체결). 거부 시 브로커 잔고>0 → holding 복귀(기존 보유 유지), 0 → inactive.
2. **부분 감액**: 보유 슬롯에 매도 intent(qty+price) → `holding→pending_sell`(slot.qty=주문) → dispatch 매도 `min(주문, 브로커잔고)` → 체결 후 브로커 잔고>0 → `pending_sell→holding`(잔량, cost basis 유지) / 0 → `pending_sell→inactive`. 전량 매도(qty 미지정, safety-sell/kill-switch)는 slot.qty=None → dispatch=브로커 잔고.
3. **부분 체결**: 실제 체결분만 ledger(REQ-018/019)·슬롯 반영(브로커 정본 resync), 미체결 잔량은 core 추적 안 함 — 전략이 다음 스냅샷 기준 재발행.
- 2026-06-10 :: BAT-031 (dcy-021) — 포지션 사이징(증액·부분 감액) 흐름 추가, transit 13 전이.