Files
trex-core/docs/runtime-flows.md
T

59 KiB
Raw Blame History

Trex Core Runtime Flows

  • Baseline Mode: mixed
  • Baseline Seed: .stage-pilot/bootstrap/baseline.yaml
  • Status: draft
  • Owner: project maintainers
  • Last Updated (KST): 2026-07-15 (BAT-057 — operator broker truth hotfix baseline alignment)
  • Source Discovery / Batch: bootstrap-baseline (pre-Discovery); 데몬/HTTP 흐름 = dcy-002 / BAT-002; operator broker truth = dcy-037 / BAT-057
  • 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.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)
  • SlotService :: 슬롯 생명주기 도메인 서비스 (transit / sell / thresholds / capacity — 자산 중심, BAT-030)
  • SlotRepository (Protocol) :: 저장소 구현체 주입 경계
  • MessagingTransport (Protocol) :: 메시지 transport 추상화 (RabbitMQ / InMemory stub)

Flow: 데몬 lifecycle (BAT-002, BAT-007, BAT-047)

  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). 각 틱 시작 시 event_publisher.keepalive_tick() 호출 (REQ-092 — pika 스레드 안전 규칙: 발행 연결 소유 스레드에서만 I/O). 틱 간격(기본 1s) < heartbeat/2(15s) → 서버 측 idle 종료 예방.
    • trex-ledger :: ledger outbox 드레인 (REQ-019)
    • trex-intent-rpc :: trex.intent.req 소비자 (prefetch=1, FIFO, dedup via intent_log; update_thresholds 액션 포함 REQ-040)
    • trex-slots-rpc :: trex.slots.req 소비자 (spy=false 시 holding 슬롯 워치독 last_seen_at 갱신 REQ-034; safety_sell_enabled=false 시 trex-safety-sell 미기동 BAT-036)
    • 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.slots.req, BAT-035 — REQ-028 trex.snapshot.req 대체)

  1. 전략 모듈 또는 운영자가 RPC 요청을 trex.slots.req 큐로 publish — payload 에 filter(status 필터) + spy(워치독 억제 여부), properties 에 correlation_id + reply_to.
  2. trex-slots-rpc 스레드가 메시지 수신 → make_slots_rpc_handler(slot_repo, ...) 로 슬롯 목록 산출.
  3. spy=false(기본) 면 holding 슬롯 WatchdogStore.update(slot_id) 호출(전략 폴링 = 생존 신호, REQ-034).
  4. 응답 {slots[], degraded, reason, message} 를 reply_to 큐로 publish → manual ack.
  5. 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, BAT-047)

  1. 체결: execution 루프가 pending_buy→holding(또는 pending_sell→closed)을 commit + ledger outbox enqueue (REQ-018/019).
  2. 같은 스레드에서 event_publisher.publish_event(rk, payload) 호출 (REQ-091 재시도 정책 포함, BAT-047).
    • routing key: {event_type}.{strategy_id}.{symbol} (예: slot_filled_buy.A.005930).
    • payload: 현재 상태 + sequence_at(슬롯 updated_at iso) 운반 → 수신자는 last-wins 적용 가능 (순서 보증 불요).
    • publisher confirms 활성. is_open=True를 맹신하지 않음 — 발행 실패 시(half-open 포함) 강제 재연결(close+_connect(timeout=2s)) 후 1회 재시도. 재시도도 실패하면 TransportUnavailableError 전파.
    • unroutable(구독자 없음): UnroutableError → _publish_dlq로 trex.events.dlq(durable)에 보존. DLQ 유입 시 ERROR 로그 발행(REQ-093).
    • publish 최종 실패는 ERROR 로그(routing key + slot_id + strategy_id 포함)로 경보 — 슬롯 전이 롤백 없음(NFR-3).
  3. idle keepalive: trex-execution 스레드가 각 틱 시작 시 event_publisher.keepalive_tick()(process_data_events(time_limit=0))을 호출 → heartbeat 주기 내 I/O 보장 → 서버 측 idle 종료 예방 (1차 방어선, REQ-092). 발행 재시도는 2차 복구 방어선(REQ-091).
  4. 전략별 durable queue(trex.events.strategy.{strategy_id})는 *.{strategy_id}.# 패턴으로 바인딩 → broker 단 필터로 자기 자산 이벤트만 수신.
  5. 전략 down 중 적체된 메시지는 catch-up 으로 복구; TTL(1h) / max-length(10k) 초과는 drop-head + 후속 스냅샷 RPC reconcile.

DLQ 관측 경로 (trex.events.dlq, REQ-093)

  • unroutable 이벤트 → _publish_dlq(reason="unroutable") → ERROR 로그 + trex.events.dlq(durable) 유입.
  • 발행 최종 실패 → _make_event_emitter ERROR 로그(경보). DLQ 유입 없음(연결 자체가 없는 경우).
  • _publish_dlq 재발행 자체 실패 시 → ERROR 로그(DLQ publish failed rk=...).
  • 운영자 확인: 로그 level=ERROR message=~"DLQ" 또는 RabbitMQ 관리 UI queue depth trex.events.dlq 조회.

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/121): 브로커 잔고 ground truth 로 core holding 검증 — core holding/브로커 0 → slot error 격리+WARN, 수량 불일치 → WARN(상태 불변), 브로커 보유인데 대상 instance에 관리 slot이 없으면 unmanaged_position, 동일 instance에 inactive/error 등 non-holding 관리 slot이 있으면 managed_non_holding_divergence를 WARN으로 남긴다. 두 broker-only 분류는 상태·warning persistence·주문·청산·slot 생성의 자동 수정을 하지 않는다.

Broker provider and operator balance-read barrier (BAT-057 / REQ-119/120)

  1. daemon과 sync/verify CLI는 같은 broker provider를 통해 브로커를 얻는다. broker=kis 및 credential_fetch_enabled=true일 때만 portfolio-hub의 유효한 인증정보를 fetched source로 사용한다. daemon의 day-start refresh는 이미 같은 KST 날짜에 얻은 source가 있으면 이를 재사용한다.
  2. 명시적 TREX_KIS_BROKER_BREAK_GLASS는 fetched source를 건너뛰고 env KIS 설정을 사용하는 별도 source다. Paper와 credential_fetch_enabled=false KIS 경로는 env/config source이며 credential fetch를 하지 않는다.
  3. sync/verify는 실행당 잔고를 한 번 먼저 검증해 cache한 뒤 그 값만 사용한다. KIS 잔고 읽기 실패는 빈 잔고로 해석하거나 후속 DB/slot write로 진행하지 않고 command boundary에서 안전하게 실패한다. 성공한 빈 잔고([])만 검증된 flat balance다.

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.slots.req RPC(spy=true) → 응답 slots[] 출력. 워치독 억제(spy=true)로 운영자 조회가 전략 생존 신호로 오인되지 않는다(REQ-034 CHG-01, R-4). universe→filter=inactive; slots --status <val>→server-side filter=<val>(buyable→inactive). BAT-035.
  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-035 / REQ-073/074)

trex.slots.req 메시지 처리 (make_slots_rpc_handler).

  1. 요청 payload 디코드: {filter ∈ {all, inactive, pending, holding, error}, spy: bool}.
  2. slot_repo.list_slots(instance_key) → status 기준 서버 필터 적용(inactive/pending/holding/error; 미인식 = all).
  3. spy=false 면 slot_repo.list_holding_slots() 의 모든 holding 슬롯에 WatchdogStore.update(slot_id).
  4. 각 슬롯에 slot_to_dict(s, snapshot_store=..., watchdog_store=..., pause_store=..., watchdog_timeout_sec=...) 적용 → 병합 row 산출:
    • slot 코어 필드: slot_id/status/symbol/qty/avg_price/entry_price/portfolio_id/forced_close_at/universe_detached
    • hub 권위 필드: asset_quantity/cash_balance (SnapshotStore)
    • 파생: warned = (qty != asset_quantity) (REQ-074 AC-2)
    • 추정 현금(REQ-029 OQ-4): cash_estimated=True + cash_balance += pending ledger delta(best-effort)
    • 워치독: watchdog: {expired, last_seen} inline (WatchdogStore)
    • 오버레이: paused (PauseStore, REQ-032)
    • strategy 필드 없음 (REQ-074 AC-5)
  5. degraded 상태 동봉: SnapshotStore.degraded_state() → {degraded, reason, message}.
  6. 응답: {slots[], degraded, reason, message} — slots 는 항상 배열, null 금지.

Flow: per-slot 워치독 갱신 (BAT-010/011 / REQ-034, REQ-040)

전략 모듈의 생존 신호를 추적해 자율 안전 매도가 정상 운용 슬롯에 개입하지 않게 한다.

  1. trex-slots-rpc 가 trex.slots.req 를 처리할 때, 요청 spy=false(기본) 인 경우 모든 holding 슬롯에 대해 WatchdogStore.update(slot_id)(last_seen_at = now)를 호출한다. 전략 모듈이 슬롯 RPC를 폴링하는 한 그 슬롯은 "활성"으로 판정된다. spy=true 면 갱신 생략(운영자 조회 — REQ-034 CHG-01).
  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: 중장기(멀티데이) 인스턴스 병행 운영 (BAT-041 / dcy-027, REQ-076~078)

중장기 매매 전략용 인스턴스를 당일 인스턴스와 같은 인프라(PostgreSQL·RabbitMQ)에서 병행 운영하는 프로파일. 별도 도메인 없이 영구 자산 슬롯(dcy-020) + per-instance 설정 조합으로 구성한다(dcy-027 OQ-1 (a′)).

  1. 분리 기동: 중장기 데몬은 전용 instance_key(예: longterm) TOML 로 기동(config/longterm.example.toml 표준 레시피) — 슬롯(DB instance_key 스코프)·큐(trex.slots.req.longterm 등)가 당일 인스턴스와 무간섭(dcy-027 A-2, dcy-009 토폴로지).
  2. 프로파일 구성: safety_sell_enabled = false(자율 청산 워커 미기동, BAT-036) + watchdog_timeout_sec = 1800 이상(REQ-076 per-instance TOML — TOML 우선, 미설정 시 env TREX_WATCHDOG_TIMEOUT 폴백 기본 120s) + max_active_slots(REQ-060) + env TREX_MARKET_CLOSE_LIQUIDATION_TIME 미설정.
  3. 보유 유지: 영구 자산 슬롯은 날짜 키가 없어 holding 이 장 마감·일자 경계·재시작을 넘어 DB 에 유지된다 — 일일 리셋/강제 청산 없음.
  4. 보호 규칙 경계 (dcy-027 OQ-2): safety_sell_enabled=false 인스턴스는 코어 손절매 루틴을 일절 사용하지 않는다. stop-loss·트레일링 스톱·타임스톱 등 모든 보호 규칙은 전략 모듈의 전적 책임이며, 코어가 제공하는 유일한 백스톱은 운영자 CLI trex-core exit <slot>(수동 청산)다. 전략 모듈 장애 시 손실 포지션 방치 위험은 전략 설계 + 운영 절차(수동 CLI)로 완화한다.
  5. forced_close_at 미사용 (dcy-027 CONFIRM): 중장기 인스턴스에서 forced_close_at 은 의미가 없다 — 집행 주체(safety-sell 워커)가 OFF 이므로 구조적으로 발화 불가. 중장기 슬롯에 설정하지 않는다(타임스톱이 필요하면 전략 모듈이 sell intent 로 구현).
  6. watchdog 중장기 의미: 전략 폴링 주기 1~10분 전제(dcy-027 OQ-4)에서 timeout 1800s 는 watchdog.expired 를 유의미한 이탈 신호로 유지한다. last_seen 가시성(slots RPC 행)은 그대로. 워치독 소유권 진입 시 외부 알림(슬랙·이메일)은 후속 Discovery.

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 없음). 계좌 스냅샷 선택 기능이 켜진 경우에도 실시간 조회를 먼저 수행하며, transport_failed에 한해 동일계정·미만료 보유수량을 사용한다. 브로커 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 연계).
    • 슬리피지 보호 지정가 + KRX 호가단위 스냅(dcy-022/REQ-070, dcy-030/REQ-083): 슬롯에 slippage_pct 가 있고 limit_price 도 있으면, raw 실효가 = limit_price×(1±pct/100) 를 KRX 호가단위(REQ-083 domain/pricing.py)로 스냅해 제출한다. 방향 = 상한 준수: 매수 = floor_to_tick(raw)(slippage 상한 미초과), 매도 = ceil_to_tick(raw)(매도 하한 미초과). band<1tick 엣지에서 미체결을 막기 위해 매수 = max(floor_to_tick(raw), ceil_to_tick(limit_price)), 매도 = min(ceil_to_tick(raw), floor_to_tick(limit_price)) 로 entry 유효 호가를 보장한다. limit_price 없으면(시장가) 스냅 무시. 미지정 슬롯은 기존 동작(회귀 없음). 주식 전용(ETF/ETN·2,000원 미만 비주식 미지원, dcy-030 OQ-1). 이전 구현은 int(ceil/floor) 무스냅이라 호가단위 위반으로 KIS 거부(issue #28) — CHG-20260616-01 에서 스냅·방향(체결 보장→상한 준수) 정정.
  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-032/REQ-095/096, BAT-048): _resolve_fill_price(fs, slot) 헬퍼가 체결가를 결정. (1) fs.avg_price가 양수이면 그대로 사용(happy path — WARN 없음). (2) fs.avg_price=None 또는 ≤0이고 slot.limit_price>0이면 지정가로 백필 + reason=avg_price_backfill WARN 1건 발행(슬롯 평단·원장 price에 백필값 사용, 0 저장 방지). (3) 지정가마저 None/≤0이면 체결 처리를 skip(슬롯 전이·원장 기록 미수행) + reason=fill_price_unresolvable 강 WARN 발행 — 슬롯이 PENDING 상태로 남아 stale_pending 경보가 운영자 개입을 유도. 이 가드는 _on_filled·_on_partial_cancel·_record_ledger 세 경로에 공통 적용된다(reconcile() 경로도 동일 헬퍼를 탄다).
    • 외부 취소 감지(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 로 남지 않게 하는 안전망.
    • timeout 자동 취소(dcy-022/REQ-069, dcy-041): accepted/partially_filled 상태 슬롯에 timeout_sec가 있고 경과 시간이 초과하면 외부 호출 전에 cancel_state=unknown을 예약 저장한 뒤 취소를 한 번 보낸다. 즉시 결과는 requested|rejected|unknown으로 교체하지만 슬롯 상태는 변경하지 않는다. 같은 주문은 자동 재취소하지 않으며 다음 poll의 cancelled|filled만 최종 상태를 확정한다.
  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 되어 재매도는 시장가.
  • 실행 정책 생애주기(dcy-022/REQ-068): timeout_sec·slippage_pct 는 limit_price 와 동일한 생애주기(PENDING_* 진입 시 set, HOLDING/INACTIVE 탈출 시 clear). 슬롯의 updated_at 이 dispatch 시각 proxy 로 사용된다(mark_dispatched 가 갱신, stale-alert 와 동일 방법).
  • 어댑터: paper(결정적 async 더블) / KIS(모의투자 검증, 실계좌 게이팅).
  • venue 전달 경로(BAT-056/REQ-117/118, BAT-059/REQ-124, dcy-041): buy/sell intent venue(기본 AUTO) → pending Slot.venue → KIS 제출 시 KST 세션별 실제 KRX/NXT 확정 → OrderAck.venue와 주문번호 원자 저장 → 취소·재시작·Core outbox에서 실제 거래소 재사용. 허용값은 AUTO/KRX/NXT/SOR이며 invalid는 슬롯 전이 전 거부한다. HOLDING은 다음 자율매도를 위해 AUTO, INACTIVE는 하위 호환을 위해 KRX로 초기화한다.

계좌조회 단기 복구 (dcy-041 / GitHub #45)

  1. 기능을 켠 KIS 데몬은 시작 시 1회 동기 조회 후 계정당 한 백그라운드 소유자가 기본 300초 간격과 실패 백오프로 보유수량·주문가능금액을 갱신한다.
  2. 보유수량과 현금은 독립적으로 성공값과 시각을 저장한다. 계정 해시 변경, 인증·인가·형식·브로커 거부는 실패 폐쇄한다.
  3. 신규매수는 기본 600초 이내 현금값에서 기존 pending 매수 예약금액과 새 주문금액을 합산해 검사한다. 검사와 pending 전이는 같은 instance advisory lock 안에서 원자 수행하며 시장가처럼 금액을 확정할 수 없으면 차단한다.
  4. pending 매도는 실시간 보유조회 전송 실패일 때만 기본 3600초 이내 전략 소유 보유수량을 사용한다. 수동 보유분을 매도 권한으로 승격하지 않는다.
  5. 재시작 원장대조, sync, verify, Portfolio Hub 일일 정합은 캐시를 사용하지 않는다.

운영자 cancel 백스톱 (dcy-022/REQ-071)

timeout 미지정·취소 실패로 잔여 고착된 pending 슬롯을 처분:

  1. 운영자 trex-core cancel <slot_id> → trex.control.req 에 {action:"cancel_pending", slot_id} 전송.
  2. 제어 핸들러가 ExecutionService.cancel_pending_slot(slot_id) 호출 → 슬롯 pending 여부 확인 → broker.cancel_order 송신(슬롯 직접 전이 없음).
  3. 다음 집행 루프 poll 에서 is_terminal_reject/is_partial_cancel 경로로 rollback/부분 보존 수렴(단일 writer 불변식 A 보존).
  • 비-pending(holding/inactive) 슬롯 → {status:"ignored", reason:"not_pending"}. 미존재 → {status:"error", reason:"slot_not_found"}.

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(calculate_fee(side, amount))·tax(calculate_tax(side, amount))를 운반. fee/tax 는 단일 정책 지점(broker_port.py)에서 env 로 관리한다(dcy-026/REQ-018 CHG-20260612-01): TREX_KIS_FEE_BPS(기본 1.5 = 0.015%, 매수·매도 공통)·TREX_KRX_TAX_SELL_BPS(기본 20.0 = 0.2%, 매도 증권거래세; 매수 tax=0). paper·KIS 가 동일 정책을 공유하며, KIS daily-ccld 응답은 per-fill fee/tax 필드를 제공하지 않아 정책 추정값을 사용한다(향후 노출 endpoint 도입 시 브로커 정본 우선).
  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 는 제외).

Flow: 구조화 로깅 / 관측성 (BAT-045·046 / dcy-029, REQ-084~088)

엔트리포인트(daemon.py/reset.py)가 runtime/logging_setup.configure_logging()으로 root 로깅을 단일 핸들러로 구성(logging.basicConfig 대체):

  • 포맷: 모든 라인 <ISO8601 KST> <LEVEL> <logger> [cid=<id>] <msg>. TREX_LOG_FORMAT=json 이면 Loki 적재용 한 줄 JSON(ts/level/logger/msg/correlation_id + extra). KST 는 코드에서 +09:00 고정(환경 TZ 비의존, REQ-084).
  • correlation id: contextvar + 로깅 필터. 키 = slot_id(intent→order→fill 조인). execution 루프가 dispatch/poll 각 슬롯 처리 시작 시 set_correlation_id(slot_id) → 한 슬롯의 submit/fill/reject 로그가 같은 cid 공유(REQ-084).
  • 주문 결과 로깅(REQ-085): 집행 루프가 event=order_submit(INFO, side/symbol/slot_id/qty/limit_price), event=order_fill(INFO, fill_price/filled_qty/fee/tax), event=order_reject를 안정 키로 남겨 grep/Loki 로 전수 추출. 슬롯 전이/원장 동작은 불변(additive).
  • 거부 ERROR 가시성(REQ-087): dispatch/poll 거부 롤백 시 event=order_reject를 ERROR 레벨로 — 조용한 롤백 제거(issue #28 이 수 시간 묻혔던 원인). 정상 체결은 INFO. 알림 전송 채널은 Deferred(OQ-3).
  • 청산 reason+actor(REQ-086): safety-sell fired: ... reason=<r> actor=trex-safety-sell 로 조건축(reason)과 주체축(actor)을 함께 노출. stop_loss/take_profit/forced_close_at 는 watchdog expired 에서만 발화하므로 actor 동반으로 "안전망 자율청산 vs 전략 손절"을 구분(신규 reason 값·필드 없음). slot_transition_log 는 이미 actor 기록.

Flow: 운영 조회 리포트 (trex-core report) (BAT-046 / dcy-029, REQ-088; BAT-052 / dcy-035, REQ-110/111)

운영자 trex-core report [--json] — direct-DB + portfolio-hub WAC 경로(RPC 없음):

  1. daily_slot 에서 보유(status=holding: symbol/qty/avg_price) + 대기(pending_*) 조회.
  2. ledger_outbox 에서 오늘(trading_date KST 경계 → UTC 범위) 체결 조회.
  3. application/operator_report.py 순수 집계(build_report): 기존 당일 net(Σ매도−Σ매수) 계산 (회귀 보존, NFR-2).
  4. WAC 경로(REQ-110): TREX_PORTFOLIO_HUB_BASE_URL 설정 시 portfolio-hub GET /v1/portfolios/{id}/pnl/realized 를 portfolio_id 별 N회 호출(get_realized_pnl(pid, from_dt, to_dt), from_dt/to_dt = 오늘 KST date). 응답의 total_realized_pnl / 자산별 realized_pnl 을 합산해 apply_wac_to_report 로 report 에 반영. 매수 fee 가 cost basis 에 편입된 WAC 기준이므로 멀티데이 정확.
  5. degrade(REQ-111): portfolio-hub 미설정 또는 PortfolioHubUnavailableError → wac_unavailable=True + wac_unavailable_reason 기록. 폴백 없음(당일 net 을 WAC 위치에 대체하지 않음). 보유/대기/체결 건수 섹션은 정상 출력.
  6. text 표 또는 --json 출력. WAC 섹션 미가용 시 에러 메시지만 표시, 나머지 섹션 정상.

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: slot strategy-state 저장소 (BAT-042 / dcy-028, REQ-079~082)

전략 데몬(예: trex-tsl)이 슬롯 점유 동안 누적하는 opaque 상태(예: TSL peak)를 슬롯 수명에 묶어 server-side 로 보관한다. core 는 data 를 해석하지 않는다(opaque UTF-8 + byte size cap 만 검증).

  1. read (piggyback, C-1): 데몬이 매 사이클 trex.slots.req 로 슬롯을 읽으면 각 행에 occupancy_seq(int)와, 보유 시 strategy_state 맵({<strategy_id>: {version,data,updated_at}})이 함께 실린다. 맵은 핸들러가 반환 slot_id 들로 StrategyStateStore.get_for_slots() 1회 batched 조회 후 group by 해 조립한다(추가 왕복 없음). 슬롯 현재 occupancy_seq 와 다른 stale 엔트리는 노출하지 않는다.
  2. evaluate (pure): 데몬은 행의 strategy_state[strategy_id] 를 read-only 입력으로 evaluate() 에 넣는다(상태 write 는 evaluate 밖, 엔진이 수행).
  3. write (별도 RPC, C-2): 상태가 바뀌면 trex.slots.strategy_state.set 으로 {slot_id, strategy_id, expected_version, expected_occupancy_seq, data} 를 보낸다. core 는 슬롯의 현재 occupancy_seq·active 여부를 조회해 CAS 검증: 점유 불일치 → occupancy_changed, version 불일치 → version_conflict, inactive/미존재 → slot_inactive, size 초과 → state_too_large, 일치(없으면 expected_version=0) → commit + version+1. malformed → bad_request. 동일 입력 재전송은 무부작용.
  4. occupancy 가드 (C-2b, R-1): 슬롯은 monotonic occupancy_seq 를 갖고 새 점유 시작(inactive→pending_buy)마다 ++. 영구 슬롯 재사용 시 read v0→close→reopen→write v0 의 ABA(옛 점유 계산이 새 점유에 바인딩)를 occupancy_seq CAS 가 구조적으로 차단한다.
  5. close 삭제 (C-3): 슬롯이 어떤 경로로든 →INACTIVE(전략 매도/safety-sell/forced-close/operator) 되면 transit_slot 단일 전이 지점에서 해당 slot_id 의 모든 strategy_state 를 삭제한다. 재사용 슬롯은 새 occupancy_seq + 빈 상태로 시작 — 전략/데몬은 cleanup 책임이 없다(슬롯이 source of truth + GC).

저장은 별도 slot_strategy_state 테이블(복합 PK (slot_id, strategy_id)), 슬롯 본체에는 occupancy_seq 컬럼 1개만 추가(full 3계층 분할은 후속 Discovery). full 3계층(occupancy 테이블 분할)·다중 writer 동시성·장기 보존은 범위 밖.

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).
  • 2026-06-15 :: BAT-042 (dcy-028) — slot strategy-state 저장소: opaque per-strategy state(별도 slot_strategy_state 테이블), read piggyback(trex.slots.req 행에 strategy_state 맵+occupancy_seq), write 별도 RPC(trex.slots.strategy_state.set, occupancy_seq+version CAS), occupancy_seq 점유 가드(점유 전환 ABA 차단), close(→INACTIVE) 시 자동 삭제 (REQ-079~082, REQ-073/074 CHG). full 3계층 분할은 후속.

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-11 :: BAT-035 (dcy-025) — trex.snapshot.req → trex.slots.req lockstep 교체: trex-snapshot-rpc → trex-slots-rpc worker, make_snapshot_rpc_handler → make_slots_rpc_handler, build_snapshot_envelope/View 제거. 슬롯 RPC 응답 산출 flow, 워치독 spy=true 갱신 억제 flow 추가. 운영자 CLI flow: trex.snapshot.req → trex.slots.req (REQ-073/074/075).
  • 2026-06-11 :: BAT-036 (dcy-024) — 데몬 lifecycle: safety_sell_enabled=false 시 trex-safety-sell worker 미기동 + WARNING 로그(REQ-048 CHG-01/REQ-005 CHG-01).
  • 2026-06-10 :: BAT-031 (dcy-021) — 포지션 사이징(증액·부분 감액) 흐름 추가, transit 13 전이.
  • 2026-06-12 :: BAT-038 (dcy-026/issue #24) — 매도 증권거래세 누락 정정: 체결 → ledger outbox flow 의 tax=0 → calculate_tax(side, amount)(매도 0.2%/매수 0) 정책 계산. fee/tax 단일 정책 지점 + env(TREX_KIS_FEE_BPS/TREX_KRX_TAX_SELL_BPS), paper·KIS 동일 공유, tax=0.0 하드코딩 제거 (REQ-018 CHG-20260612-01).
  • 2026-06-12 :: BAT-041 (dcy-027/issue #20 이월) — 중장기 인스턴스 병행 운영 flow 신설: 전용 instance_key 프로파일(config/longterm.example.toml), watchdog_timeout_sec per-instance TOML(REQ-076, env 폴백), 보호 규칙 경계(전략 모듈 전적 책임 + CLI exit 백스톱, 코어 손절매 미사용), forced_close_at 중장기 미사용 (REQ-076~078).
  • 2026-06-21 :: BAT-049 (dcy-033) — 알람 발신 흐름 신설: AlarmEmitter → MessagingTransport.publish_alarm_event() → trex.alerts exchange, best-effort 비차단 정책, transport None 안전 skip, CRITICAL code 8종 발생 지점 목록.

Flow: 알람 발신 (trex.alerts, dcy-033)

trex-core가 CRITICAL 조건 발생 시 trex.alerts exchange로 표준 alarm envelope을 발행한다.

발행 흐름

발생 지점 (execution.py / killswitch.py / reconciliation.py 등)
    │ alarm_emitter.publish_alarm(code, title, fields=...)
    ▼
AlarmEmitter.publish_alarm()      [application 레이어]
    │  envelope 구성: source="trex-core", severity="critical",
    │  schema_version="1", occurred_at=KST ISO-8601
    │  routing_key = "alarm.critical.trex-core"
    │  MessagingTransport.publish_alarm_event(routing_key, payload)
    ▼
RabbitMQTransport.publish_alarm_event()   [adapters 레이어]
    │  basic_publish(exchange=trex.alerts, mandatory=False, delivery_mode=1)
    ▼
trex.alerts exchange (topic, durable=True)
    │  notifier가 alarm.# 패턴으로 바인딩한 큐
    ▼
trex-notifier (단일 소비자) → 텔레그램 등 전달

best-effort 비차단 정책

  • 발행 실패(TransportUnavailableError, 임의 예외) 시: ERROR 로그 후 예외 삼킴(re-raise 금지).
  • 슬롯 전이·매매 집행·원장 기록 경로는 알람 발행 실패에 영향받지 않는다.
  • transport 미연결(None) 시: INFO 로그 후 silent skip. reset.py 등 CLI 진입점에서 안전.
  • notifier 미연결(unroutable) 시: mandatory=False → silent drop. TransportUnavailableError 없음.

trex-core CRITICAL code 8종 발생 지점

code 발생 모듈 조건
fill_price_unresolvable application/execution.py 체결가·지정가 모두 0/None → 슬롯 PENDING 고착 위험
sell_skipped_no_balance application/execution.py 매도 dispatch skip: 브로커 잔고 0
core_holding_broker_flat application/reconciliation.py 슬롯 HOLDING인데 브로커 잔고 0 (정산 불일치)
ledger_delivery_failed application/ledger_outbox.py 원장 영구 실패 / dead-letter / 409 충돌
kill_switch_fired application/killswitch.py 긴급 청산 발동
publish_unavailable_after_reconnect adapters/messaging/rabbitmq.py 재연결 후에도 이벤트 발행 불가 (콜백 패턴)
dlq_publish_failed adapters/messaging/rabbitmq.py DLQ 발행 실패 (콜백 패턴)
portfolio_hub_unavailable application/snapshot_scheduler.py whoami / get_portfolios 실패 → 스냅샷 부실화
schema_verification_failed runtime/reset.py 리셋 후 daily_slot 컬럼 불일치
migration_failed runtime/reset.py apply_migrations 예외

trex.events와의 차이점

항목 trex.events trex.alerts
용도 체결/슬롯 상태 이벤트 CRITICAL 알람 envelope
exchange 타입 topic, durable topic, durable
mandatory True (DLQ 라우팅) False (silent drop)
DLQ 있음 (trex.events.dlq) 없음
delivery_mode 2 (persistent) 1 (non-persistent)
소비자 전략 모듈(strategy_id 필터) trex-notifier 단독
발행 재시도 재연결 후 1회 retry 없음 (best-effort)
  • 2026-07-29 :: BAT-063 (REQ-118 CHG-20260729-01) — 일반 주문과 결과불명 주문의 poll_fill이 신형 일별주문체결조회와 EXCG_ID_DVSN_CD=ALL을 사용해 KRX·NXT 체결을 함께 확인한다.
  • 2026-07-06 :: BAT-056 (dcy-002 FR-6, REQ-117/118) — 브로커 집행 venue 전달 경로 명시: OrderRequest.venue(기본 KRX) → KIS 어댑터 EXCG_ID_DVSN_CD 항상 명시 전송(submit_order/cancel_order). get_current_price market 파라미터 추가(기본 J). 기존 orb/tsl 경로 무영향.

Flow: operator sync (REQ-105/REQ-123, dcy-034 / BAT-051)

  • Operator CLI trex-core sync는 브로커 잔고를 1회 조회해 슬롯 병합을 수행한다.
  • 대상 슬롯은 상태별로 다음 규칙을 따른다:
    • PENDING_*: 쓰기 스킵(수정 없음, warning clear 없음)
    • broker_qty == 0: 스킵(안전 가드)
    • HOLDING: qty(및 avg_price) 갱신
    • INACTIVE: qty onboarding 시 cost_basis를 seeded_avg_price로 seed 시도
  • INACTIVE 상태에서 broker_qty > 0이나 seedable avg_price가 없으면:
    • --dry-run은 skipped=True, seeded_avg_price=False, cost_basis_reason=<reason>만 노출하고 status/qty/avg_price 변경을 수행하지 않는다.
    • live는 cost_basis_unavailable 에러로 fail-closed 하여 status/qty/avg_price 쓰기를 모두 거부한다.
  • 결과 객체에는 before/after, warning_cleared, seeded_avg_price, cost_basis_source, cost_basis_reason, error가 포함되어 운영자가 no-write/fail-closed 여부를 판별할 수 있다.