Skip to content

fix: 바스켓 트랙 동결·현금 누수 해소 + 주문 안전장치 강화 - #456

Merged
easygap merged 8 commits into
mainfrom
fix/live-order-cancel-safety
Aug 26, 2026
Merged

fix: 바스켓 트랙 동결·현금 누수 해소 + 주문 안전장치 강화#456
easygap merged 8 commits into
mainfrom
fix/live-order-cancel-safety

Conversation

@easygap

@easygap easygap commented Aug 26, 2026

Copy link
Copy Markdown
Owner

배경

6/10에 페이퍼 운영을 시작한 뒤 두 달 넘게 트랙이 설계대로 굴러가지 않았다. 크래시는 한 번도 없었다. 오류 0건, 주문 실패 0건, 스냅샷 정상, 헬스 계속 OK. 그런데 실제로는 이랬다.

  • 6/10 ~ 8/07: 58거래일 동안 거래 0건. 1일차 체결 상태 그대로 동결.
  • 8/07 ~ 8/26: 배치율 61.0% → 54.9%로 단조 감소. 19거래일 재투자 0건. 그 사이 KOSPI가 +8.17% 반등해서 유휴 현금이 반등분의 45%를 깎아먹었다.

둘 다 감시망에 안 걸렸다. 이 PR은 그 두 결함과 같은 성격의 관측 사각을 정리한다.

1. 자기상관 자기거부로 인한 영구 동결 (1f0e6ea)

check_correlation_risk가 대상 종목을 자기 자신과 비교했다. corr(x, x) = 1.0이니 항상 임계값을 넘고, 고정수량 어댑터는 scale < 1.0이면 주문을 하드 거부한다. 바스켓이 만드는 주문은 대부분 보유분 추가매수라, 1일차 체결 이후 모든 매수가 영구 거부됐다.

  • 상관 비교에서 자기 자신 제외. 동일 종목 추가 노출은 max_position_ratio 관할이다.
  • 사전 승인된 목표 비중표로 집행하는 주문(weight_policy_managed)은 쌍별 상관 거부권을 적용하지 않는다. 비중표가 곧 분산 정책이라 이중 계산이고, 하락장에서 대형주 상관이 일제히 1에 수렴하면 설계대로 채우는 주문이 전부 막힌다. 집중도는 비중·업종·노출 상한이 그대로 강제한다.

같이 정리한 것:

  • 주식 노출을 target_stock_weight: 0.60으로 명시. 종전엔 설계가 80%인데 실효 59%였다.
  • 000660 제외. 1주 148만원이 주식 슬리브의 26%라 단일 종목 상한 20%를 넘는다. 현재 자본에서 편입 자체가 불가능한 슬롯이 2개월간 목표표에 상주해 있었다.
  • 트랙별 리스크 정책(core/basket_risk.py). 전역 단타 기준(-3% 손절, +8% 익절)이 buy&hold 포지션에 기록만 되고 일일 사이클은 평가하지 않아, 손절선을 뚫은 포지션 6개가 방치돼 있었다.
  • 손절 종목 재진입 차단. 없으면 비워진 슬롯을 같은 사이클의 비중 교정이 곧바로 되사서 손실만 확정하는 왕복매매가 된다. 8/07에 실제로 발생했다.
  • daily_return이 스냅샷 61행 전부 0.0이었다. save_daily_snapshot이 값을 아예 안 넘겼다. 변동성·샤프·일일 손실 한도가 전부 조용히 죽어 있었다.

2. 현금 래칫 (841f1e2)

리밸런싱이 한쪽 방향으로만 돌았다. 비중 초과 매도는 min_trade를 넘겨 집행되는데, 그 현금을 되돌리는 매수는 9종목에 얇게 퍼져 개별로는 전부 미달이라 집행되지 않는다. 종목별 드리프트 트리거는 이 상태를 구조적으로 볼 수 없다. 어느 종목도 임계값을 안 넘으니까.

  • 집계 배치율을 리밸런싱 트리거로 승격(deployment_band, 기본 3%p).
  • 얇게 퍼진 미달분을 min_trade 단위로 모아 보충 매수한다. 집행 규칙은 하나다. 그 매수가 집계 격차를 실제로 줄일 때만 산다. 1주 절사 때문에 딱 맞게 살 수 없으니 상한을 따로 두는 대신 매수 후 잔여 격차로 판정한다.
  • 집계를 맞추자고 구성이 무너지지 않게, 매수 후 종목 비중이 목표 + 드리프트 임계값을 넘으면 보류한다. 어느 종목도 통과 못 하면 주문 없이 두고 미달을 경보로 남긴다. 자본이 부족하다는 신호다.
  • deployment_tolerance 1.0 → 0.05. 1.0은 사실상 감시 해제였고, 그래서 위 누수를 헬스가 3주간 한 번도 못 봤다.
  • 8/18 결측 하나가 3주간 warning 16건을 만들었다. 결측일 기준 1회만 알린다.
  • 적립 이행 점검 추가. kr_pocket 입금이 47일간 0건인데 헬스는 계속 OK였다.

3. 국면 분해와 리스크 지표 (5a6b3f4)

수익률을 한 숫자로 보고하는 동안 방어의 대가가 통째로 숨어 있었다.

구간 KOSPI NAV 격차
6/10 ~ 8/26 -12.43% -4.61% +7.81%p
8/07 ~ 8/26 +8.17% -0.80% -8.97%p

같은 포트폴리오의 같은 3개월이다. 방어적 포지션은 하락장에서 항상 좋아 보이니까, 전체 구간 숫자 하나만 보면 상승장 미스를 영영 못 본다.

core/performance_lens.py를 추가하고 주간 리포트에 두 줄을 붙였다.

상승 31일 포착 4% (지수 +149.4% 중 +6.0%만 따라감)
하락 20일 포착 15% (지수 -64.9% 중 -10.0%만 맞음)
연변동성 26.3% · 샤프 -0.76 · 하락일 51% · 최악일 -3.02% (표본 51일)

산식에서 걸러낸 것:

  • 일간 수익률을 단순 합산하면 안 된다. 변동성 큰 구간에서 31일을 더하면 +149% 같은 숫자가 나오고 전체와 아귀가 안 맞는다. 복리로 집계하니 상승 × 하락이 전체와 정확히 일치한다.
  • 스냅샷이 빠진 날이 있으면 NAV 수익률은 여러 날 구간이 된다. 벤치마크를 하루치로 짝지으면 기간이 어긋나므로, 일간 수익률이 아니라 종가 레벨을 받아 같은 구간으로 다시 계산한다.
  • 수익률은 스냅샷의 daily_return 열이 아니라 NAV 시계열에서 뽑는다. 그 열은 8/10 이전이 전부 0.0이라 그대로 쓰면 변동성이 0으로 깔린다.

docs/OPERATING_PRINCIPLES.md도 같이 넣었다. 이 저장소에서 터진 중대 결함은 한 번도 크래시가 아니었다. 전부 "오류 0건, 헬스 OK인데 설계대로 안 굴러감"이라는 같은 서명이다. 그 패턴에서 뽑은 원칙 10개와 각각을 만든 실제 사고를 붙여 뒀다.

4. 실전 주문 안전장치 (fe17d61, 9be12d1)

  • 실전 주문과 검증 경계를 fail-closed로 강화
  • 긴급 매도 전 미체결 매수 주문 취소 검증

5. 대시보드와 문서 (c163f4f, d853290)

  • 장기 투자 중심 대시보드와 눈금 BI 개편
  • 주요 화면 중심으로 사용 안내 정리

검증

  • 전체 스위트 1946개 통과
  • 운영 DB 사본(QUANT_DB_PATH)에 실제 실행 경로를 태워 확인
    • 보유 종목 추가매수: 거부 → 성공
    • 배치율 54.9% → 60.0%, 현금 4,280,927 → 3,798,355
    • kr_pocket은 주문 0건. 1주 단가가 총자산 대비 커서 어떤 매수도 종목 상한을 못 지킨다. 자본이 더 필요하다는 정직한 신호다.
  • --mode health, --mode weekly_report 실제 실행 확인

남은 것

  • kr_pocket 첫 적립 입금 미실행(47일). 이게 없으면 배치율이 수렴하지 않는다.
  • 1회 손실 예산이 아직 전역 -3% 손절로 계산된다. 트랙 정책(-25%)을 넣으면 10% 슬롯이 예산을 초과해 재동결되므로 목표비중 주문에는 별도 기준이 필요하다.
  • 리스크 정책 소급 적용 방지(effective_from). 8/07에 -25% 손절을 도입한 사이클이 이미 -34.8%였던 종목을 청산했다. 손절은 앞으로만 구속력이 있어야 한다.

easygap and others added 8 commits July 15, 2026 13:22
긴급 매도 시 동일 종목의 취소 가능 매수 주문을 먼저 정리하고, 재조회에서 잔량 소멸이 확인된 경우에만 매도를 제출하도록 변경했다.

부분 체결이나 조회·취소 상태가 불명확하면 수량을 추정하지 않고 거래를 중단해 수동 정합성 확인이 필요하도록 처리했다. 관련 주문 안전성 테스트를 추가하고 날짜 의존 테스트를 안정화했다.
운영 2개월 점검에서 모의투자 트랙이 6/10 이후 57거래일간 거래 0건으로 얼어붙은
원인을 특정하고, 그 과정에서 드러난 인접 결함들을 함께 정리한다.

동결 원인 (실측 재현):
check_correlation_risk가 대상 종목 자신을 비교 대상에 포함해 corr(x,x)=1.0이 잡히고,
고정수량 어댑터는 scale<1.0을 하드 거부한다. 바스켓이 만드는 주문은 대부분 보유분
추가매수라 1일차 체결 이후 모든 매수가 영구 거부됐다.
 - risk_manager: 상관 비교에서 자기 자신 제외 (동일 종목 추가 노출은 max_position_ratio 관할)
 - order_executor: 사전 승인된 목표 비중표로 집행하는 주문(weight_policy_managed)은
   쌍별 상관 거부권 미적용. 비중표가 곧 분산 정책이라 이중 계산이고, 하락장에서
   대형주 상관이 1에 수렴하면 설계대로 채우는 주문이 전부 막혀 구조적 교착이 된다.
   집중도는 비중/업종/노출 상한이 그대로 강제한다.

주식 노출을 명시 정책으로:
설계는 80%였으나 실효 배치율은 59%였고, 그 현금 완충이 6/10~8/07 KOSPI -18.6%
구간에서 총자산 -5.0% 방어의 주원인이었다. 우연이 아니라 의도로 고정한다.
 - kr_diversified_hold: target_stock_weight 0.60 명시
 - 000660 제외 — 1주 148만원이 슬리브의 26%라 단일 종목 상한(20%)을 넘어 편입 불가.
   목표 비중표에 남겨 두면 영영 못 채우는 공백이 배치율 미달로만 나타난다.

트랙별 리스크 정책 (core/basket_risk.py):
전역 단타 기준(-3% 손절/+8% 익절/-5% 트레일링)이 buy&hold 포지션에 기록만 되고
일일 사이클은 평가하지 않아, 9개 중 6개가 손절선을 뚫은 채 방치돼 있었다.
 - baskets.yaml `risk:` 블록으로 트랙별 기준 선언. 전부 0이어도 '손절 없음'이라는
   결정으로 취급한다(침묵하면 단타 기본값이 적힌다).
 - 평가는 저장된 손절가 컬럼이 아니라 정책 비율로 매번 계산 — 옛 기준으로 기록된
   값이 남아 있어도 판단이 오염되지 않는다.
 - 일일 리밸런싱 사이클이 리밸런싱보다 먼저 리스크 청산을 평가한다.
 - 재진입 차단(reentry_cooldown_days): 청산으로 비워진 슬롯을 같은 사이클의 비중
   교정이 곧바로 되사면 손실만 확정하는 왕복매매가 된다. 실제로 8/07 10:07에
   현대차를 -25% 손절로 판 4초 뒤 되사는 일이 발생해 즉시 막았다.
 - kr_pocket은 손절 없음을 명시 — 지수 ETF 적립 트랙에 손절을 걸면 하락을 손실로
   확정하면서 회복과 저가 매수 효과를 함께 버린다. 위험 통제는 47.5% 비중 자체다.

전일 대비 수익률:
save_daily_snapshot이 daily_return을 아예 넘기지 않아 스냅샷 61행이 전부 0.0이었다.
변동성/샤프/일일 손실 한도가 모두 이 열을 보므로 조용히 무력화돼 있었다.
입금을 중화한 TWR 구간 수익률로 계산한다. get_snapshot_before를 새로 두어 같은 날
재실행(upsert) 때 자기 자신을 직전으로 잡아 항상 0이 되는 문제도 함께 막는다.
3주 무인 운영(8/07~8/26) 데이터를 시간순으로 되짚어 보니, 트랙이 표면상 완전히
정상인데 설계대로 굴러가지 않는 상태가 3주간 아무에게도 보이지 않았다.
오류 0건, 주문 실패 0건, 스냅샷 저장 정상, 헬스 ✅ OK — 그런데:

  배치율 61.0% → 54.9%로 단조 감소, 19거래일간 재투자 0건.
  같은 기간 KOSPI +8.17% 반등, 포트폴리오 -0.80% (-8.97%p).
  유휴 현금이 반등분의 45%를 깎아먹었다.

원인은 리밸런싱이 한쪽 방향으로만 도는 래칫이었다. 비중 초과 종목은 매도액이
min_trade를 넘겨 집행되는데(현금 증가), 그 현금을 되돌리는 매수는 9종목에 얇게
퍼져 개별로는 전부 min_trade 미만이라 영원히 집행되지 않는다. 8/07 리밸런싱이
현금을 +394,700원 늘린 뒤 한 건도 재투자되지 않은 게 실측 증거다.
종목별 드리프트 트리거는 이 상태를 구조적으로 볼 수 없다 — 어느 종목도 임계값을
넘지 않기 때문이다.

집계 배치율을 1급 신호로 승격한다:
 - should_rebalance가 종목별 드리프트와 별개로 집계 배치율 이탈(deployment_band,
   기본 3%p)을 트리거로 본다.
 - plan_rebalance가 얇게 퍼진 미달분을 min_trade 단위로 모아 보충 매수한다.
   집행 규칙은 하나 — 그 매수가 집계 격차를 실제로 줄일 때만 산다. 1주 절사와
   min_trade 때문에 딱 맞게 살 수 없으므로 상한을 따로 두는 대신 매수 후 잔여
   격차가 지금보다 작아지는지로 판정한다.
 - 집계를 맞추자고 구성이 무너지지 않게, 매수 후 종목 비중이 목표+드리프트
   임계값을 넘으면 보류한다. 어느 종목도 통과 못 하면 주문 없이 두고 배치율
   미달을 경보로 남긴다(자본 부족 신호 — kr_pocket이 이 경우다).
 - 재진입 차단 종목은 이 경로로도 되사지 않는다.
검증: 운영 DB 사본에서 54.9% → 60.0% 복구, 현금 4,280,927 → 3,798,355.

배치율 감시 복원 (deployment_tolerance 1.0 → 0.05):
1.0은 사실상 감시 해제였다. 그렇게 둔 이유는 '하이닉스 슬롯 미달은 조치 불가'
였는데, 그 슬롯을 제거하고 목표를 60%로 명시한 지금은 미달이 곧 현금 누수다.
감시가 꺼져 있어서 위 3주간의 누수를 헬스가 한 번도 잡지 못했다.

결측 경보 중복 억제:
복구 불가능한 8/18 결측 하나가 3주간 16건의 warning을 만들어 다른 신호를 덮었다.
경보의 가치는 새로운 사실에 있으므로 결측일 기준 1회만 알린다. 조회 실패 시에는
거르지 않는다 — 중복이 침묵보다 낫다. 커버리지 집계는 별개로 항상 전체를 본다.

적립 이행 점검:
적립식 트랙은 입금이 멈추면 '주문 실패 0건'인 채로 설계가 굴러가지 않는다.
잔고가 1주 단위를 못 넘겨 배치율이 수렴하지 않기 때문이다. kr_pocket 입금이
47일간 0건인데 헬스는 계속 OK만 반환했다. summarize_contribution_plan(순수 함수)
으로 주기+유예 초과를 ATTENTION으로 올린다.
수익률을 한 숫자로 보고하는 동안 '방어의 대가'가 통째로 숨어 있었다.
같은 포트폴리오, 같은 3개월인데 어느 창으로 보느냐에 따라 정반대로 읽힌다:

  전체 (6/10~8/26)   KOSPI -12.43% vs NAV -4.61%   → +7.81%p
  반등 (8/07~8/26)   KOSPI  +8.17% vs NAV -0.80%   → -8.97%p

방어적 포지션은 하락장에서 항상 좋아 보이므로, 전체 구간 숫자 하나만 보면
상승장 미스를 영영 못 본다. 그래서 국면을 갈라 보고한다.

core/performance_lens.py (전부 순수 함수):
 - daily_returns_from_nav — 스냅샷의 daily_return 열이 아니라 NAV 시계열에서
   직접 뽑는다. 그 열은 2026-08-10 이전 전 구간이 0.0이라(값을 안 넘기던 버그)
   그대로 쓰면 변동성이 0으로 깔려 '없는 안정성'을 주장하게 된다.
 - aligned_returns — NAV와 벤치마크를 같은 구간으로 맞춘다. 스냅샷이 빠진 날이
   있으면 NAV 수익률은 여러 날 구간이 되는데 벤치마크를 하루치로 짝지으면
   기간이 어긋난다. 그래서 일간 수익률이 아니라 종가 레벨을 받아 동일 구간으로
   다시 계산한다. 1차 구현이 정확히 이걸 틀려서 상승 국면 벤치마크가 +149%로
   나왔고 전체 수익률과 아귀가 안 맞았다.
 - split_by_regime — 벤치 부호로 갈라 복리로 집계. 단순 합산하면 변동성 큰
   구간에서 아귀가 안 맞는다. 이제 상승×하락 복리가 전체와 정확히 일치한다.
 - risk_metrics — 표본 2개 미만이면 0으로 채우지 않고 None. 없는 값을 0으로
   채우면 '변동성 0'으로 읽혀 없는 안정성을 주장한다.
 - flow 중화는 저장소 표준(twr_period_return)과 동일: r = v/(v_prev+flow)-1.

주간 리포트에 두 줄이 추가된다(실측):
  🌗 상승 31일 포착 4% (지수 +149.4% 중 +6.0%만 따라감)
     하락 20일 포착 15% (지수 -64.9% 중 -10.0%만 맞음)
  📉 연변동성 26.3% · 샤프 -0.76 · 하락일 51% · 최악일 -3.02% (표본 51일)
하락 포착 15%는 방어가 실제로 작동했다는 뜻이고, 상승 포착 4%는 그 대가가
얼마나 컸는지를 말한다. 종전에는 둘 다 안 보였다.

docs/OPERATING_PRINCIPLES.md:
이 저장소에서 터진 중대 결함은 한 번도 크래시가 아니었다. 전부 같은 서명이다 —
'오류 0건·헬스 OK인데 설계대로 굴러가지 않음'. 그 패턴에서 뽑은 10개 원칙과
각각을 만든 실제 사고를 붙여 문서화했다. 앞으로의 우선순위도 여기 둔다.
CI가 이 PR에서 처음으로 테스트 단계까지 갔고, 거기서 9건이 깨졌다. 로컬은 전부
통과하는데 CI만 깨지는 이유를 따라가 보니 테스트가 환경에 얹혀 있었다.

config/settings.yaml은 git 미추적이다. 로컬 파일에는 use_mock: true가 있어
_is_real_money_live()가 False가 되고, 그래서 live BUY의 실계좌 kill switch
(ENABLE_LIVE_TRADING) 분기를 자연스럽게 비켜 갔다. CI에는 그 파일이 없으니
실계좌로 판정돼 9건이 kill switch에 막혔다. 즉 이 테스트들은 로컬 설정 파일 덕에
통과해 온 것이고, CI에서 실제로 검증된 적이 한 번도 없었다.

_prepare_live_executor가 실계좌 판정을 인스턴스 단위로 못박게 했다. 이 클래스가
검증하는 건 주문 상태기계(ACK·체결확인·reconcile)이지 kill switch가 아니다.
공유 Config 싱글톤은 건드리지 않는다.

여기에 2차 파급이 하나 더 있었다. live SELL 체결 미확인 테스트가 전역 HALT를 켜는데,
HALT는 OperationEvent에 append-only로 쌓이고 최신 이벤트가 이긴다. 테스트 세션은
임시 DB 하나를 공유하므로 그 HALT가 뒤따르는 모든 매수 테스트를 막았다.
test_trading_halt.py에는 정리 픽스처가 있지만 파일 안에서만 돈다.

정리를 conftest의 autouse로 올렸다. 개별 테스트 규율에 맡기면 새 테스트가 추가될
때마다 다시 샌다 — 격리는 공용 인프라에 두는 게 이 저장소의 원칙이다(DB 경로,
섹터 맵 캐시와 같은 자리).

검증: config/settings.yaml을 잠시 치우고 전체 스위트를 돌려 CI 환경을 재현했다.
1946개 통과.
@easygap
easygap merged commit bd8d74d into main Aug 26, 2026
1 check passed
@easygap
easygap deleted the fix/live-order-cancel-safety branch August 26, 2026 02:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant