feat: add bounded KIS account read recovery
This commit is contained in:
@@ -168,6 +168,26 @@
|
||||
- 5xx/network/timeout → outcome `retryable_failure`, status `pending` (다음 tick 재시도).
|
||||
- slot 전이는 outbox 적재와 분리되어 critical path 유지.
|
||||
|
||||
## Entity: `broker_account_snapshot` (dcy-041 / GitHub #45)
|
||||
|
||||
- Purpose: KIS 계좌조회 전송 장애 동안 신규매수와 위험축소 매도의 허용 범위를 짧게 제한하는 계정별 마지막 정상값.
|
||||
- Source of Truth: 실시간 KIS 조회가 항상 우선이며 이 테이블은 임시 복구값이다. 재시작 원장대조에는 사용하지 않는다.
|
||||
|
||||
### Key Fields
|
||||
|
||||
- account_hash :: TEXT PK :: `SHA-256(CANO|ACNT_PRDT_CD)`. 평문 계좌번호를 저장·로그하지 않는다.
|
||||
- holdings :: JSONB NOT NULL :: `[{symbol,hldg_qty}]`. 소유권 래퍼가 켜진 계정은 전략 소유분만 저장한다.
|
||||
- holdings_captured_at :: TIMESTAMPTZ? :: 보유수량 조회가 마지막 성공한 시각.
|
||||
- available_cash :: BIGINT? :: KIS 주문가능조회 `nrcvb_buy_amt` 현금주문 가능금액.
|
||||
- cash_captured_at :: TIMESTAMPTZ? :: 주문가능금액 조회가 마지막 성공한 시각.
|
||||
|
||||
### State Rules
|
||||
|
||||
- 보유수량과 주문가능금액은 독립적으로 갱신한다. 한 조회의 실패가 다른 정상값과 캡처 시각을 덮지 않는다.
|
||||
- 계정 해시는 저장 직전 재확인하며 바뀌면 저장하지 않는다.
|
||||
- PostgreSQL advisory lock으로 계정당 백그라운드 조회 소유자는 하나만 허용한다.
|
||||
- 기본 최대 나이는 매수 600초, 매도 3600초이고 매도도 실시간 보유조회가 `transport_failed`일 때만 사용한다.
|
||||
|
||||
## Shared Consistency Rules
|
||||
|
||||
- 모든 상태 변경과 전이 로그 append 는 동일 advisory-locked 트랜잭션 안에서 일어난다. advisory lock 은 인스턴스 스코프(`slot_advisory_lock_id(instance_key)`, REQ-057 NFR-1).
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
- 문서 ID: dcy-041_20260729_broker-uncertainty-and-adapter-retirement
|
||||
- 이슈명: Core 브로커 불확실 상태 구분과 로컬 KIS 어댑터 축소
|
||||
- 작성 시각(KST): 2026-07-29 16:59:16 KST
|
||||
- 마지막 갱신 시각(KST): 2026-07-29 17:01:50 KST
|
||||
- 마지막 갱신 시각(KST): 2026-07-29 17:32:04 KST
|
||||
- 대체됨: 없음
|
||||
- 선행 Discovery:
|
||||
- dcy-013_20260608_kis-external-cancel-pending-sell-stuck
|
||||
@@ -60,7 +60,7 @@
|
||||
- FR-2: 실제 제출 거래소를 슬롯과 Core 체결 전달 대기 원장에 저장하며 취소·재시작 복구에서 재사용한다.
|
||||
- FR-3: 취소 요청은 `requested|rejected|unknown` 결과를 반환한다. `requested`는 최종 취소가 아니며 체결조회가 `cancelled|filled`를 확정한다.
|
||||
- FR-4: `unknown` 취소 결과와 결과불명 주문은 자동 재주문하지 않고 기존 주문 조회로만 수렴한다.
|
||||
- FR-5: 성공한 계좌조회만 계정 해시·캡처 시각·보유수량·주문가능금액과 함께 원자 저장한다.
|
||||
- FR-5: 성공한 계좌조회 영역만 계정 해시·독립 캡처 시각과 함께 원자 저장한다. 보유수량과 주문가능금액 중 한쪽 실패가 다른 정상값을 덮지 않는다.
|
||||
- FR-6: 일시적 전송 오류에만 미만료 스냅샷을 사용한다. 계정 불일치·만료·응답 오류는 차단한다.
|
||||
- FR-7: 신규매수와 위험축소 매도의 스냅샷 최대 나이를 분리하며 KIS 복구 후 다음 백그라운드 조회가 정상값으로 교체한다.
|
||||
|
||||
@@ -127,3 +127,13 @@
|
||||
- Ready for REQ Drafting: true
|
||||
- Confirmed By: 형님
|
||||
- Confirmed At (KST): 2026-07-29 17:01:50 KST
|
||||
|
||||
# 12. 구현 증거
|
||||
|
||||
- `AUTO` 실제 거래소 확정·주문번호 동시 저장·재시작/취소 재사용: 구현 완료.
|
||||
- 취소 `requested|rejected|unknown` 영속화와 poll 전용 최종확정: 구현 완료.
|
||||
- 계정 해시·독립 TTL·백그라운드 단일 소유자·매수/매도 분리: 구현 완료.
|
||||
- KIS 현금 권위 필드: `inquire-psbl-order.output.nrcvb_buy_amt`.
|
||||
- 수동 혼합 보유 계정: `OwnershipAwareBroker` 투영 뒤 스냅샷을 생성하여 전략 소유 수량만 매도 허용.
|
||||
- 운영 기본값: 기능 OFF, 매수 600초, 매도 3600초, 갱신 300초.
|
||||
- 실전 반영: 가상 검증과 사용자 별도 승인 전까지 미적용.
|
||||
|
||||
@@ -423,11 +423,21 @@ resume_kill_switch 요청·응답 (REQ-046):
|
||||
|
||||
### Balance-read failure and reconciliation boundary (BAT-057 / REQ-120/121)
|
||||
|
||||
- `get_balance()`는 검증된 `list[BalanceItem]`만 반환한다. KIS의 token/auth/authorization/transport/response/rejection 실패는 빈 목록으로 축소되지 않고 `BrokerBalanceError`를 raise한다. 안정 code allow-list는 `token_issuance_failed`, `authentication_failed`, `authorization_failed`, `transport_failed`, `malformed_response`, `broker_rejected`다.
|
||||
- `get_balance()`는 검증된 `list[BalanceItem]`만 반환한다. KIS의 token/auth/authorization/transport/response/rejection 실패는 빈 목록으로 축소되지 않고 `BrokerBalanceError`를 raise한다. 안정 code allow-list는 `token_issuance_failed`, `authentication_failed`, `authorization_failed`, `transport_failed`, `malformed_response`, `broker_rejected`, `ownership_mismatch`다.
|
||||
- `sync`와 `verify` CLI는 첫 balance read를 완료한 뒤 cache한 결과를 해당 invocation에 전달한다. 이 read 또는 broker credential resolution이 실패하면 exit code `1`이며, `--json` stderr는 `{ "error": {"code": "…", "message": "broker balance unavailable"} }` 형태의 안전한 값만 낸다. upstream payload, credential, account 값은 계약상 출력하지 않는다.
|
||||
- `reconcile_core_holdings()`의 broker-only 결과는 `unmanaged_position`(대상 instance의 managed slot 없음) 또는 `managed_non_holding_divergence`(동일 instance의 non-holding managed slot 존재)다. `find_managed_slot_by_symbol(instance_key, symbol)` 조회는 instance-scoped이며, 이 두 분류 자체는 slot/warning write나 자동 주문·청산·slot 생성을 하지 않는다.
|
||||
- 이 변경은 AMQP 또는 portfolio-hub external wire contract와 DB schema를 바꾸지 않는다.
|
||||
|
||||
### Account snapshot recovery (dcy-041 / GitHub #45)
|
||||
|
||||
- 기능은 `TREX_ACCOUNT_SNAPSHOT_ENABLED=false`가 기본인 KIS 전용 선택 기능이다.
|
||||
- KIS 보유조회와 현금주문 가능금액 조회(`inquire-psbl-order.output.nrcvb_buy_amt`)를 기본 300초 간격의 단일 계정 소유자가 갱신한다. 두 값은 독립 캡처 시각을 갖는다.
|
||||
- 신규매수는 미만료 현금 스냅샷과 기존 `pending_buy` 지정가 예약금액을 합산해 검사한다. 수량·지정가가 없거나 예약금액을 계산할 수 없으면 매수를 차단한다. KIS 주문 응답이 최종 현금 한도 권위이며 거부된 주문을 자동 재전송하지 않는다.
|
||||
- `pending_sell` 수량은 매 tick 실시간 보유조회를 먼저 시도한다. 오직 `transport_failed`이고 기본 3600초 안의 동일계정 스냅샷이 있을 때만 대체한다.
|
||||
- 인증·인가·응답형식·브로커거부·소유권 불일치·계정 해시 변경·만료는 캐시로 우회하지 않는다.
|
||||
- `sync`, `verify`, 일일 스냅샷 스케줄러와 재시작 원장대조는 기존처럼 실시간 조회만 사용한다.
|
||||
- 소유권 래퍼가 설정된 혼합계정은 `broker total = manual protected + strategy owned`를 유지하며 전략 소유분만 보유수량 스냅샷에 들어간다.
|
||||
|
||||
### cancel_order
|
||||
|
||||
```
|
||||
|
||||
+11
-3
@@ -250,21 +250,29 @@
|
||||
|
||||
데몬 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-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)**: `accepted`/`partially_filled` 상태 슬롯에 `timeout_sec` 가 있고 경과 시간(`now - slot.updated_at`)이 초과하면 `broker.cancel_order` 를 best-effort 로 송신하고 `ExecutionResult.timeout_cancelled` 를 증가시킨다. **슬롯 상태는 이 시점에 변경하지 않는다** — 다음 poll 에서 `is_terminal_reject`(전량 취소) → rollback / `is_partial_cancel`(부분 체결 후 취소) → 부분 보존(기존 경로 재사용). 취소-체결 race: `cancel_order` 가 이미 체결된 주문을 받으면 브로커가 거부 → 다음 poll `is_filled` → 정상 체결 경로 수렴(임의 롤백 없음, FR-4). 기존 `timeout_sec` 없는 슬롯은 stale-alert 만 실행(REQ-014 회귀 없음).
|
||||
- **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)**: buy/sell intent `venue`(기본 KRX) → pending `Slot.venue` → `OrderRequest.venue`와 취소 venue → KIS `EXCG_ID_DVSN_CD`. 허용값은 KRX/NXT/SOR이며 invalid는 슬롯 전이 전 거부한다. INACTIVE 복귀 시 KRX로 초기화한다.
|
||||
- **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 매수 예약금액과 새 주문금액을 합산해 검사한다. 시장가처럼 금액을 확정할 수 없으면 차단한다.
|
||||
4. pending 매도는 실시간 보유조회 전송 실패일 때만 기본 3600초 이내 전략 소유 보유수량을 사용한다. 수동 보유분을 매도 권한으로 승격하지 않는다.
|
||||
5. 재시작 원장대조, `sync`, `verify`, Portfolio Hub 일일 정합은 캐시를 사용하지 않는다.
|
||||
|
||||
### 운영자 cancel 백스톱 (dcy-022/REQ-071)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user