Skip to content

휴장일 달력·결측 보충·배치율 감시 오류 수정, 전략·백테스트·KIS 보완 - #472

Merged
easygap merged 50 commits into
mainfrom
fix/algorithm-review-20260923
Sep 23, 2026
Merged

easygap merged 50 commits into
mainfrom
fix/algorithm-review-20260923

Conversation

@easygap

@easygap easygap commented Sep 23, 2026

Copy link
Copy Markdown
Owner

운영 중인 두 바스켓 트랙(kr_diversified_hold, kr_pocket)을 점검해 보니, 오류는 한 건도 안 나고 헬스도 OK인데 설계대로 돌지 않는 곳이 여러 군데 있었습니다. 그 부분을 고치고, 같은 기준으로 전략·백테스트·실행 환경도 점검해 고쳤습니다.

운영 트랙

  • 휴장일 달력: 9/28(월)이 휴장일로 잘못 들어가 있었습니다. 추석 연휴가 토요일과 겹쳐도 대체공휴일은 생기지 않습니다. 그대로 두면 그날 스냅샷이 전 거래일 기록을 덮어써 결측이 숨습니다. 2027년 설·추석 날짜도 바로잡았습니다. 자동 갱신이 아직 확인할 수 없는 미래 평일을 휴장으로 찍거나, 손으로 고친 항목을 지우지 않게 했습니다.
  • 결측 자동 보충: 8/27에 넣은 결측 보충이 baskets_cfg 이름 오류로 첫날부터 매일 실패하고 있었습니다. 커버리지 100%는 수동 복원 덕분이었습니다. 이름 오류를 고쳤고, 실패하면 하루 한 번 이벤트로 남깁니다. 보충은 paper 트랙에서만 합니다. 휴장일에 사이클이 돌면 매매는 하지 않고 기록만 남깁니다.
  • 배치율 감시: 8/26에 되살린 5%p 경보는 실제로는 울릴 수 없었습니다. 허용폭을 보유 종목별 1주 가격의 합으로 잡아서 9종목 바스켓은 약 20%p였고, 8월 현금 래칫(61%→54.9%) 상태를 넣어도 OK가 나왔습니다. 이제 허용폭은 설정 밴드와 '가장 싼 1묶음을 사도 못 메우는 격차' 중 큰 값입니다. kr_pocket은 CD ETF 매수분도 투자 비중으로 셉니다.
  • 소액 계좌 유휴 현금: kr_pocket에서 주식 1주를 못 사서 남는 현금을 파킹 ETF(357870)로 넣습니다. 운영 DB 복사본으로 돌려 보니 357870 1주를 사서 투자 비중이 73.4%에서 88.3%가 됐습니다.
  • 지수 자료 신선도: 지수 자료가 멈춰도 예전 값으로 조용히 판정하던 것을 고쳤습니다. 이제 자료가 최신인지 먼저 확인하고, 늦었으면 069500 종가로 대신 판단합니다. 대신 쓴 날은 헬스에 표시합니다.
  • 주문 단계 판정: 노출 한도와 낙폭 가드를 원가가 아니라 계획과 같은 시가로 잽니다. 원가로 재면 하락장 보충 매수가 거부되고, 평가익이 쌓이면 가짜 '일일 손실'로 매수가 막혔습니다. 매수 거부 사유는 로그와 이벤트로 남기고, 손절 청산은 최소 보유 기간보다 우선합니다. 계좌 낙폭 가드는 자기 낙폭 규칙을 켜 둔 바스켓(kr_pocket) 주문만 그 규칙에 맡기고, kr_diversified_hold에는 그대로 적용합니다.
  • 리포트 수치: 적립 입금은 그 입금이 처음 반영된 스냅샷에서 빼고 계산합니다. 주간 리포트 기준일, 나중에 채운 기록 표시, 국면별 포착률(하루 평균 등락 비율), 샤프 기준 표기를 바로잡았습니다. 일간 수익률을 계산하지 못한 날은 0.00% 대신 '—'로 표시합니다. 나중에 채운 추정 기록은 낙폭 고점으로 쓰지 않습니다.
  • 평가: 규칙을 바꾼 kr_pocket은 9/17 이후 기록만 따로 셉니다(현재 5/60일). 승격 판정에 NaN이 섞이면 통과로 보지 않습니다.
  • 대시보드: 경보를 거래일 기준으로 판단하고, 수익은 스냅샷 시점 원금 기준으로 보여 줍니다. 검증 결과를 불러오는 중에는 실패 안내를 띄우지 않습니다. 069500으로 대신 판단한 날에 '비중 확대 보류'라고 잘못 쓰지 않습니다. Discord 전송 실패는 기록하고, 429 응답에는 한 번 다시 보냅니다.
  • DB 스키마: 시작할 때 모델과 실제 테이블을 대조하고, 빠진 인덱스처럼 안전한 차이는 바로 고칩니다. 7월 포지션 유실 사고와 같은 종류의 문제입니다.
  • 테스트 격리: 테스트가 운영 파일(대시보드 상태, 실행 잠금, 연구 기록, 승인 전략 목록)을 건드리지 않습니다.

연구 결론 수정

9/17 오버레이 연구는 운영 조건과 다른 조건으로 계산돼 있었습니다. 지금 보유한 9종목과 이자 없는 현금으로 다시 계산하니 모든 오버레이가 샤프와 칼마를 낮췄습니다. 그대로 보유하면 연 6.07%, MDD −19.5%, 샤프 0.45였고, 낙폭 제어를 켜면 3.10%, −15.0%, 0.32였습니다. 그래서 kr_diversified_hold에 오버레이를 켜자는 9/17 권장은 철회했습니다. 주식 비중 60/70/80% 비교도 함께 실었습니다.

실행 환경 (KIS·스케줄러)

  • KIS 접근 토큰을 앱키별로 공유합니다. 인스턴스마다 따로 발급하면 1분 1회 발급 한도(EGW00133)에 걸려 live 잔고 확인이 실패할 수 있었습니다. 발급에 실패하면 60초 동안 다시 시도하지 않고, 알림도 한 번만 보냅니다.
  • 조회 요청의 5xx 응답 중 초당 한도·토큰 무효 코드는 서버 장애로 세지 않습니다. 이것까지 장애로 세면 서킷이 열려 손절 매도까지 60초 동안 막혔습니다. 서킷 발동 알림은 락 밖에서 보냅니다. 주문 요청은 예전처럼 응답이 불확실하면 다시 보내지 않습니다.
  • 스케줄러는 더 이상 바스켓을 거래하지 않습니다. 장전 시각에는 live 주문이 거래 시간 가드에 막히고 paper는 전일 종가로 체결됐습니다. 이제 바스켓 실행 경로는 매일 10:07에 도는 CLI 하나뿐입니다.
  • 스케줄러 헬스체크가 정상 DB에서도 10분마다 'DB 연결 실패'를 내던 문제(SQLAlchemy 2.0)를 고쳤습니다. 장마감 처리는 15:35 이후면 언제든 한 번 실행하고, evidence 날짜와 경과일은 거래일 기준으로 셉니다.
  • live 전환 게이트는 보유 종목과 지수의 최근 봉이 최신인지 확인합니다.

전략·백테스트 (현재 운영에는 쓰지 않음)

  • MACD·이동평균 점수가 지표가 생기기 전 구간을 잘못 채점하던 문제
  • 돌파 전략이 진입 다음 날 바로 파는 문제(청산 기준을 진입 때 뚫은 가격으로 고정)
  • 평균회귀의 52주 필터가 백테스트에서 빠져 있던 문제, 추세 추종에 trend_ma_period 설정이 반영되지 않던 문제
  • 시장 국면 필터를 켜면 조회 기간이 모자라 매수가 전부 막히던 문제, 회전 전략 시장 필터가 백테스트에서 첫날 상태로 굳던 문제
  • 앙상블 모드 전환이 미래 신호를 보던 문제
  • 백테스트 지표에서 보유기간 만료·갭다운 청산이 빠지던 문제, 손실 전략의 칼마가 양수로 나오던 문제
  • 포트폴리오 백테스트의 체결 순서(시가 주문을 종가 청산보다 먼저 처리)와, 자료가 빈 날의 평가(평균단가 대신 마지막 종가)
  • 워크포워드·OOS 검증이 지표 예열 없이 시작하던 문제, 상위 50종목 벤치마크가 늦게 상장한 종목 때문에 기간이 잘리던 문제

이 수정으로 breakout_volume, mean_reversion, scoring의 기존 백테스트 수치와 연구 스윕 결과는 다시 재야 합니다. 다만 '코스피 전체를 대상으로 한 단순 전략에는 초과수익이 없다'는 결론을 뒤집을 근거는 아닙니다.

확인 결과

  • Python 전체 테스트 2,376개, JavaScript 테스트 20개 통과 (Windows, 433초)
  • 테스트 실행 전후로 운영 DB·위험 조절 상태 파일·백업의 수정 시각이 바뀌지 않았습니다.
  • 9/23 운영 DB 백업 복사본으로 일일 사이클(--mode rebalance)을 처음부터 끝까지 실제로 돌렸습니다. 알림은 가짜로 바꿔 보내지 않았습니다.
    • kr_diversified_hold: 재매수 차단 중인 두 종목의 빈 비중 때문에 리밸런싱이 켜지지 않았고, 오늘 이미 체결이 있어 매매를 건너뛰었습니다. 스냅샷은 저장했습니다(누적 −8.46%, MDD 13.52%).
    • kr_pocket: KS200 자료가 9/17에 멈춰 069500 종가로 추세를 판단했습니다. 357870 1주를 매수해 스냅샷을 저장했습니다.
    • 벤치마크: FDR의 KS11이 9/17에 멈춰 있어 그 값을 버리고 yfinance 값을 썼습니다.
    • DB 스키마 대조: 빠진 인덱스 4개를 만들고, 비어 있던 daily_reports를 계정별 UNIQUE로 다시 만들었습니다. 기존 행은 그대로입니다.
  • 별도 검토에서 나온 8건(평가 KeyError·테스트 실패, 낙폭 가드 위임 범위, 음수 MDD, 대시보드 로딩 문구, '비중 확대 보류' 오표시, 일간 수익률 0.00%, live 결측 보충, 추정 기록 고점)을 모두 고쳤습니다.

운영에서 달라지는 점

  • 다음 실제 실행 때 DB 스키마 대조가 운영 DB에 위 인덱스 4개를 만들고 daily_reports를 다시 만듭니다. 복사본에서 확인한 동작과 같습니다.
  • kr_diversified_hold에는 계좌 MDD 15% 가드가 시가 기준으로 적용됩니다. 9/23 종가 기준 MDD는 13.52%라, 1.5%p가량 더 빠지면 비중을 채우는 매수가 막힙니다. 설계대로 계속 사게 하려면 이 바스켓에 낙폭 규칙을 따로 정해야 합니다.
  • kr_pocket은 남는 현금으로 파킹 ETF(357870)를 사서 투자 비중이 95%에 가까워집니다.
  • 스케줄러로는 바스켓을 거래하지 않습니다. 매일 10:07에 도는 CLI만 씁니다.

추석 연휴(9/24~26)가 토요일과 겹친 것이라 9/28은 대체공휴일이 아니다.
휴장으로 두면 9/28 사이클이 9/23 실측 스냅샷을 덮어쓰고 그날은 커버리지에서
사라진다. 2027년은 설(2/7)·추석(9/15) 날짜가 틀려 평일 휴장 6일이 잘못
들어가고 7일이 빠져 있었다.

- fallback 표를 8/26 교정본과 맞추고, 평일 휴장 집합을 테스트로 고정
- 자동 갱신이 pykrx 실패 시 기존 항목을 지우지 않게(합집합), pykrx는 마지막
  거래일까지만 판정, 머리 주석 보존
- TradingHours 기본 시각을 KST로, systemd 유닛에 TZ 지정
결측 보충이 8/27 도입 이래 정의되지 않은 이름(baskets_cfg) 때문에 매 사이클
실패했는데 경고 로그로만 남아 있었다. 리밸런서가 해석한 자본을 쓰도록 고치고,
오버레이 판단이 캐시되기 전에 돌도록 상태 보고 앞으로 옮겼다. 실패하면 하루
한 번 이벤트로 남긴다.

보충을 켜면서 드러난 문제도 함께 고쳤다.
- 계정이 생기기 전 날을 결측으로 채우지 않는다(첫 체결·입금·스냅샷 기준)
- 입금 경계를 직전 스냅샷의 실제 측정 시각으로 잡고, 복원 행의 측정 시각을
  그날 끝으로 남긴다 — 그렇지 않으면 인근 입금이 +25~33% 수익으로 영구 기록된다
- 입출금 계정의 복원 MDD는 TWR 지수 기준

휴장일(평일 추석 등)에는 리스크 청산·리밸런싱 매매를 건너뛰고 스냅샷만 남긴다.
직전 거래일 종가로 체결을 남기면 장이 닫힌 날의 가짜 체결이 트랙레코드에 섞인다.
1일 1매매 가드의 '오늘'도 KST 자정 기준으로 맞췄다.

승격 게이트의 커버리지는 실측 기준으로 판정한다. 보충이 매번 100%로 메우면
'사이클이 실제로 돌았는가'를 볼 수 없다. 현재 두 트랙 판정은 바뀌지 않는다.
헬스의 배치율 허용 하한이 보유 슬롯별 1주 가격의 합이라 9종목 바스켓에서
허용이 20%p까지 벌어져 있었다. 8/26에 5%p로 '복원'한 감시가 실제로는 울릴
수 없었고, 8/07~8/26 현금 래칫(61% → 54.9%)을 그대로 넣어도 OK였다.
보충 매수가 생긴 뒤로 정상 상태에서 남는 미달은 max(band, 가장 싼 집행 가능
묶음의 절반)을 넘지 않으므로 그 값을 쓴다. 초과 배치도 함께 본다.

헬스의 목표 비중을 리밸런서와 같은 헬퍼로 계산한다. 방어 자산(CD ETF)이
있으면 오버레이가 켜져도 투자 비중은 설계 그대로인데 헬스는 절반으로 재고
있었고, 모드 없이 예전 공용 상태 파일을 읽고 있었다.

kr_pocket은 두 종목 모두 1주가 종목 상한(목표+8%p)을 넘어 유휴 현금 10.4만 원이
한 달째 그대로였다(설계 95% vs 실제 73%). 방어 자산은 현금 대용이므로, 다음
사이클 매도 규칙이 되팔지 않는 범위에서 상한을 넘겨 보충하고 주문 단계 상한도
투자 비중 기준으로 맞췄다. 사본 DB에서 실제 실행 경로로 73.4% → 88.3% 확인.
주식 종목의 상한은 그대로다.
FDR의 KS200·KS11 자료가 9/17에서 멈췄는데, 비어 있지 않은 표는 성공으로 보던
탓에 폴백이 한 번도 시도되지 않았다. kr_pocket 추세 필터는 '직전 상태 유지'로
동결됐고, 평가의 'NAV vs KOSPI'는 며칠 전 종가로 계산됐다. 흔적은 경고 로그뿐.

- 최근 요청에서 마지막 봉이 기준 거래일보다 오래되면 yfinance로 이후 봉만 보충
- yfinance 폴백 수리: 2단 열 평탄화, 종료일 봉 포함, 지수 티커(^KS200 등)·코스닥 .KQ
- 벤치마크 수익률은 오래된(또는 값이 빈) 마지막 봉으로 계산하지 않는다
- 추세 필터: KS200이 늦으면 추종 ETF 069500 종가로 판단하고 그 사실을 남긴다
- 오버레이 입력 문제를 하루 한 번 이벤트로 남기고 헬스 확인 항목에 올린다
- '200일치 부족'은 실제로 기간이 짧을 때만 쓰고, 자료가 없으면 그렇게 적는다

실데이터 확인(9/23): KS200은 9/17, yfinance ^KS200은 사실상 비어 있어 069500
대용으로 판단(200일선 +16.7%, 배수 변화 없음).
리밸런서는 시가로 주문을 계획하는데 주문 단계는 평균단가로 노출과 낙폭을 쟀다.
하락장에서는 계획한 보충 매수가 '투자 비중 초과'로 거부되고(재현: 시장 -15%에서
시가 60.0% 주문이 원가 62.1%로 계산), 평가익이 쌓이면 가짜 '일일 손실'로 매수가
막힌다(6/15 원장 재현 -5.5%). 가격 스냅샷을 넘겨 총자산·보유 노출·업종 비중을
같은 시가로 잰다.

- 계좌 MDD·일일 손실 가드는 목표 비중 주문에 한해 바스켓 낙폭 정책에 위임한다
  (상관 거부권·노출 상한과 같은 원칙). 판정은 계속 계산해 로그로 남기고, 평가 불가·
  설정 오류는 위임하지 않는다. 지금까지도 원가 기준이라 사실상 걸리지 않던 가드라
  현재 동작은 같다.
- 매수 거부 사유를 경고 로그와 하루 한 번 ORDER_REJECTED 이벤트로 남긴다
- 손절·트레일링 청산은 emergency로 넘겨 최소 보유 기간보다 우선한다(익절 제외)
- 손절 쿨다운 종목의 빈 슬롯이 드리프트 트리거를 매일 켜 두지 않게 했다.
  9/22 보충으로 산 035720 6주를 9/23에 그대로 되판 왕복이 이 때문이었다.
- 가격 없이 부른 요약이 피크를 올리지 않게 했다(원가 총액이 피크로 굳는 문제)
- 아무도 읽지 않던 drawdown.recovery_scale 설정 삭제
주간 리스크·국면 분해가 입금을 달력 날짜로 묶고 있었다. 그날 10:07 스냅샷 뒤에
들어온 입금이나 주말·휴장일 입금이 엉뚱한 구간에 들어가 그 구간이 +25~36% 수익으로
잡힌다(#461과 같은 증상이 적립 주기마다 재발). 다음 적립이 추석 연휴와 겹치는
9/26(토)이라 곧 재현될 상황이었다. 흐름을 흡수한 첫 스냅샷 기준으로 묶는다.

- 주간 변화의 기준을 정확히 7일 전에 가장 가까운 스냅샷으로(예전엔 4일 전 근처라
  금 10:07~월 10:07 구간을 매주 빠뜨렸다)
- 주간 이벤트에 사후 복원 일수를 적고, 복원이 있으면 '무사고'로 쓰지 않는다
- 국면 분해는 지수 시가 기준(10:07 NAV 마크에 가깝다)으로 근사임을 밝히고,
  지수 자료가 NAV보다 오래됐으면 그렇다고 적는다
- 일일 카드에 리스크 한 줄(변동성·샤프·하락일), 샤프는 rf 0% 기준임을 표기
- 입금 조회 실패 시 일간 수익률을 중화 없이 계산하지 않는다
- deploy_check: 회전율 분모를 계정 실제 평가액으로, ETF 매도세 면제 반영
오늘 테스트 실행 기록을 확인해 보니 운영 파일에 테스트 기록이 쌓이고 있었다.
- data/dashboard_runtime_state.json: 가짜 매수 신호와 스케줄러 루프 기록
  (대시보드가 스케줄러가 도는 것으로 읽는다)
- reports/paper_evidence/daily_evidence_scoring.jsonl: execution_backed=True
  '실제 paper' 증거 기록
- reports/paper_runtime/pilot_audit.jsonl, data/.live_runtime.lock
- reports/backtest_scoring_*.html/txt, reports/approved_strategies.json

DB·오버레이 상태와 같은 방식으로 격리한다. 대시보드 상태·live 락은 환경 변수로
경로를 바꿀 수 있게 하고 conftest가 임시 경로를 지정한다. 연구 증거·런타임 원장
경로는 autouse 픽스처가 먼저 임시 폴더로 돌린다(경로를 직접 지정하는 테스트는
그 위에 덮어쓴다). 전체 스위트를 돌린 뒤 운영 파일 수정 시각이 바뀌지 않는 것을
확인했다.
- kr_pocket은 9/17에 위험 관리 규칙을 바꿨는데 진행률은 예전 규칙의 운영
  일수로 센다. 10/12쯤 '60/60'이 돼도 새 규칙 기록은 20일 남짓이다.
  promotion.rules_effective_from 이후를 같은 정의로 따로 센 rules_window를 더하고
  평가 보고서와 일일 카드에 '새 규칙 N/60일'로 적는다(상위 판정 키는 그대로).
- kr_diversified_hold는 8/07에 80%·10종목 → 60%·9종목으로 바뀌었는데, 성과
  귀속이 지금 설계를 운영 시작일부터 소급 적용하고 있었다.
  promotion.design_effective_from부터 NAV·설계·벤치마크를 같은 창으로 다시 잰다.
- 검증 중 상시 안내(review_note)를 확인 항목과 따로도 돌려준다(화면이 구분)
- 승격 판정: NaN·Inf·빈 지표는 모든 비교를 통과해 버린다 — 이유를 적고 탈락
- paper 증거: 현금분 무위험수익률 단위(퍼센트) 수정, 보유 종목이 있는 날의
  결측은 0%로 채우지 않고 결측으로 둔다
- basket_track_record: 연구 도구가 운영 오버레이 상태를 저장하지 않게
- 기록 공백 경보를 KRX 거래일로 판정한다(서버 계산). 달력 4일·스케줄러 루프
  720분 규칙은 밤·주말·휴장일마다 '자동매매가 N시간째 멈춤' 긴급 경보를 냈고
  (이 배포는 스케줄러가 없다), 반대로 평일 이틀 공백은 '정상'이었다.
  스케줄러는 최근에 쓴 기록이 있을 때만, 장중 60분 공백을 멈춤으로 본다.
- 원금 대비 손익을 스냅샷 시점 원금으로 계산하고, 아직 반영 안 된 적립금은
  따로 적는다(적립 직후 '원금 대비 -21%' 가짜 손실).
- kr_pocket의 상시 검증 안내가 '이번 달 적립금 기록이 없습니다'를 가리고 있었다
  (9월 적립이 아직 없다). 안내는 확인할 일이 없을 때만 보인다.
- 적립 기록 전체를 읽는다(12건 제한이면 13번째부터 차트·CSV 원금이 틀린다).
- 검증 패널에 사후 복원 일수·실측 커버리지 표시, 조회 실패 시 이전 결과로
  판단하지 않고, 한 계좌 실패가 다른 계좌를 가리지 않게.
- '이전 기본 계좌'는 기본 계정만 DB에서 읽는다. 예전엔 전 계정 장부를 합친 가짜
  계좌를 보여 줬고 live에서는 조회마다 증권사 토큰을 받을 수 있었다. 비어 있으면 숨긴다.
- 시장 국면 필터가 꺼져 있으면 '상승 추세' 대신 '판단 안 함'.
- 적립 기록 요청은 루프백 Host·같은 출처에서, 운용 중인 계좌에만(DNS 리바인딩 방어).
- 디스코드 거부 응답(4xx/5xx)을 로그로 남기고 429는 한 번 재시도, 모든 채널이
  실패한 경보는 CRITICAL로 남긴다(웹훅 주소는 기록하지 않는다).
9/17 표는 000660(SK하이닉스)을 넣은 10종목에 현금 연 3%를 붙여 계산했다. 운영
트랙은 8/07부터 9종목이고 paper 현금은 이자가 없으며, 슬리브는 8%p 이탈 시 재조정
한다. 표도 첫날 비중을 들고만 갔다. baskets.yaml 보유 종목·재조정 규칙·무이자 현금으로
고쳐 다시 계산하니(2021-12~2026-09) 모든 오버레이가 샤프·칼마를 낮춘다.

  고정 비중      연 +6.07%  MDD -19.5%  샤프 0.45  칼마 0.31
  낙폭 제어      연 +3.10%  MDD -15.0%  샤프 0.32  칼마 0.21

kr_diversified_hold 오버레이는 꺼 둔 채가 맞다. 노출 60/70/80% 비교(P3)도 추가했다 —
비중을 올리면 수익과 낙폭이 같이 늘 뿐 공짜 개선은 없다. 이 설계의 실현 연수익률은
약 6%(2022 약세장·2026 여름 급락 포함)로, 이전의 '기본 13~14%'는 80%·10종목 기준이었다.

국면 포착률 정의도 고쳤다. 국면별 복리 수익률의 비는 기간이 길면 한쪽으로 쏠린다(위
표를 그 정의로 내면 상승 1%, 하락 96%). 하루 평균(기하평균) 수익률의 비로 바꿨고
주간 리포트도 같은 함수를 쓴다. 적립 트랙 표도 현재 코드로 다시 만들었다(커밋돼 있던
표는 9/22 백테스트 수정 이전 결과였다).
create_all은 이미 있는 테이블을 고치지 않아, 모델의 제약을 바꿔도 예전 DB에는 옛 제약이
남는다. 7/10 kr_pocket 가짜 낙폭(-41%)이 이 경우였는데(positions의 옛 UNIQUE(symbol)),
그 뒤로도 어긋남을 찾는 장치가 없었다. 운영 DB에서 실제로 찾은 것:
- daily_reports: 옛 UNIQUE(date) — 계정별 리포트가 같은 날 두 번째부터 실패
- trade_history: 모델의 account_key·order_id·execution_session_id 인덱스 없음

init_database가 모델 테이블·컬럼·UNIQUE·인덱스를 PRAGMA로 대조해 남은 어긋남을
ERROR로 남기고 헬스 확인 항목에도 올린다. 보정은 데이터를 건드리지 않는 것만 한다:
빠진 인덱스 생성, 비어 있는 daily_reports 재생성(행이 있으면 로그만).
오늘 백업 사본으로 리허설: 어긋남 6건 → 0건, 테이블별 행 수 동일.
A2(미해결 실패 주문 0건)는 dead-letter가 증권사 주문 경로에서만 쌓여 paper에서는
구조적으로 걸리지 않는다. 운영 기간의 ORDER_ERROR·CYCLE_ERROR 이벤트 수를 따로 세어
보고서에 적는다. 해결 표시가 없는 기록이라 판정에는 넣지 않는다.
MACD 시그널선이 생기기 전 약 33봉이 매도 점수(-1)로 채점되고, 첫 봉은 골든크로스(+2)로
잡히고 있었다. 이동평균 점수는 접두어로 컬럼을 찾다 보니 실제로는 EMA(5)/EMA(20)을 보고
있었고 설정 기간을 바꾸면 조용히 0점이 됐다. 설정한 기간의 SMA 컬럼을 정확히 쓰고,
값이 없는 구간은 점수와 크로스에서 뺀다.
매 봉의 돌파 기준선과 비교하다 보니 다음 봉 기준선에 돌파 봉 자신의 고가가 들어가
사실상 '다음 날 종가 < 오늘 고가'를 검사하고 있었다. 그래서 진입 다음 봉에 매도가 나는
하루짜리 왕복 전략이 돼 있었다(합성 데이터 기준 64%). 진입 봉의 기준선을 다음 진입까지
고정하고, 그 아래에 있는 동안은 매 봉 매도 신호를 내서 최소 보유일에 막힌 청산도
다음 봉에 다시 나오게 했다.
52주 고점 대비 급락·신저가 근방 제외 규칙이 실시간 판단에만 있어서, 백테스트는
매수의 절반가량이 실제로는 걸러지는 전략을 평가하고 있었다. 이 두 규칙은 과거 자료만
보므로 analyze()에서 같이 적용한다. 재무 필터와 코스피200 제한은 지금 시점 자료밖에
없어 백테스트에 넣지 않고, 켜져 있으면 결과에 빠져 있다고 경고한다.
전략이 지표 엔진의 sma_200을 고정으로 읽어서 최적화기가 바꿔 보던 trend_ma_period가
아무 효과가 없었다. 추세선을 전략에서 직접 계산한다(기본 200일이면 결과는 예전과 같다).
어디서도 읽지 않던 손절·트레일링 배수 설정은 지우고 최적화 탐색 대상에서도 뺐다.
strict 백테스트는 봉마다 analyze()를 부르는데, 지수 캐시가 (시작, 끝) 날짜를 키로 써서
봉마다 지수를 새로 내려받았다. 반대로 회전 전략의 시장 필터는 첫 호출(첫 1일) 상태가
전 기간에 퍼져 한 번도 작동하지 않았다. 지수를 인스턴스당 넓게 한 번 받아 두고 호출
끝 날짜까지만 잘라 쓴다. 지수를 못 받았거나 이동평균이 아직 없는 날은 필터가 진입을
막지도 청산을 강제하지도 않고, market_filter_active=False로 남긴다.
필요한 거래일 수를 그대로 달력일로 써서(280일 ≈ 190거래일 < 200일선) 필터를 켜면
늘 '지수 자료 부족'으로 모든 매수가 막혔다. 달력일로 환산해 조회하고, 장중에 받은
오늘 봉은 빼고(백테스트처럼 전일 종가까지), 마지막 이동평균 값이 유효한지 확인한다.
지수 자료가 며칠 밀려 있으면 마지막 봉 날짜와 빠진 거래일 수를 같이 남긴다.
국면별 설정에서 어디서도 읽지 않던 buy_threshold_offset은 지웠다. 국면에 따라 바뀌는
것은 손절·익절 배수뿐이다.
예전에는 첫 analyze 호출의 신호 전체로 인스턴스 모드를 한 번 바꿨다. 포트폴리오
백테스트에서는 첫 종목의 전 기간(미래 신호 포함)이 모든 날짜·종목의 모드를 정해서
종목 순서에 따라 결과가 달라졌다. 이제 날짜마다 그날까지의 60행 신호 상관으로
conservative 전환 여부를 정하고, 설정 모드는 그대로 둔 채 행별 모드를
ensemble_mode 컬럼에 남긴다. 검사 자체가 실패하면 경고를 한 번 남긴다.
포트폴리오 급락·연속 하락 규칙은 백테스터에만 있고 paper/live에서는 호출하는 곳이
없다. 그래서 신호 전략의 백테스트 낙폭에는 실제로는 없는 보호 청산이 들어 있다.
나중에 연결할 때 주의할 점(거래일당 한 번만 호출, 바스켓 트랙 계좌에는 연결 금지)도
같이 적어 뒀다.
- 보유기간 만료(MAX_HOLD) 청산이 승률·손익비·거래 수에서 빠져 있었다.
- 리포트 거래표와 실현손익 차트는 따로 둔 목록 때문에 갭다운·블랙스완 청산이 빠져,
  하루 손실이 가장 큰 거래가 표에 안 보였다. 엔진과 리포트가 한 목록(PNL_EXIT_ACTIONS)을 쓴다.
- 칼마를 abs(산술 연수익 / MDD)로 계산해 꾸준히 잃는 전략도 양수로 나왔다. 부호 있는
  CAGR / |MDD|로 바꿨다.
- 샤프의 무위험수익률 3%를 상수로 묶고, 리포트에 기준을 같이 적는다(운영 지표는 0% 기준).
- 슬리피지 비교 카드가 없는 키(risk_params.slippage)를 읽어 늘 기본값을 보여 줬다.
  transaction_costs.slippage를 읽고, 호가 틱·거래량 배수 적용 전 하한이라고 적었다.
- 월수익률을 '당월 첫날 대비 말일'로 재서 월초 첫 거래일의 등락이 어느 달에도 안 잡혔다.
  전월 말 대비로 바꾸고, 코스피 쪽도 같은 공식으로 맞춰 국면 분류가 틀어지지 않게 했다.
- 국면 MDD가 떨어져 있는 달들의 월말 자산을 그대로 이어서 사이에 낀 다른 국면의 손익이
  섞였다. 그 국면 달의 월수익률만 이어 붙여 잰다.
- 시가에 산 주식을 같은 시가에 갭다운 청산으로 되파는 경우가 있었다. 비용만 나가는 가짜
  왕복이다. 갭다운 청산은 전날부터 들고 있던 포지션에만 적용한다.
- 실전은 최소 보유 기간 동안 손실 방어 청산(손절·트레일링·갭다운·블랙스완)만 허용하고
  익절·부분 익절·보유기간 만료 매도는 막는다. 백테스트(next_open)도 같은 규칙을 따른다.
  legacy_same_close는 과거 결과 재현용이라 그대로 뒀다.
- next_open인데도 종가 손절·익절을 먼저 처리해서, 그날 시가에 나갈 전략 매도가 밀려나거나
  종가에 판 종목을 같은 날 시가에 다시 사는 시간 역행이 있었다. 시가 청산 → 시가 매수 →
  종가 청산 순서로 바꾸고, 같은 날 판 종목은 다시 사지 않는다. 매수 수량과 비중 한도는
  시가 시점에 알 수 있는 가격으로 계산한다.
- 행이 없는 날(거래정지·결측)은 보유 종목을 평균단가로 되돌려 평가해서 그동안의 평가손실이
  하루아침에 사라졌다. 마지막 종가로 평가하고, 자료가 아예 끝난 종목은 DATA_END로 청산한다.
- 이 엔진이 아직 반영하지 않는 실전 규칙(업종 비중, 전역 최소 보유, 월간 매수 상한)을
  설명에 적었다. legacy_same_close 결과는 예전과 같다(무작위 자료 512회 비교).
종목별 종가를 inner 조인해서 한 종목만 늦게 상장해도 벤치마크 기간이 그 종목 상장일부터로
잘렸다. 전략과 다른 기간을 비교하고 있었던 셈이다. 모든 날짜를 합치고, 늦게 상장한 종목은
첫 가격일에 동일비중으로 편입하는 매수·보유로 계산한다. 시작일에 가격이 있던 종목 수와
늦은 편입·중도 종료 종목을 리포트에 같이 적는다.
OOS와 워크포워드 창을 평가 구간만 잘라 넣어서, 60~200일 지표를 쓰는 전략은 구간 앞부분
내내 신호를 못 냈다. 전략이 아니라 예열 기간을 평가하고 있었던 셈이다. Backtester.run에
trade_start_date를 추가해(포트폴리오 엔진과 같은 규약) 앞 구간은 지표 계산에만 쓰고 거래와
지표는 그 날짜부터 잰다. 검증 OOS, 워크포워드 창, 전략 비교(ablation) OOS, 최적화 OOS에
적용했다. 워크포워드의 train 구간은 학습이 아니라 예열에만 쓰므로 결과 키도 warmup_period로
바꿨다. strict 실행은 예전보다 2~3배 느리다.
인스턴스마다 토큰을 따로 발급해서, live 한 사이클(동기화·잔고 요약·매수마다 새 KISApi)에서
몇 초 사이에 발급 요청이 여러 번 나갔다. KIS는 발급을 1분에 1번으로 제한하므로(EGW00133)
두 번째부터 거절돼 잔고 확인이 실패하고 live 매수가 전부 보류될 수 있었다. 토큰을 앱키·도메인
단위로 공유하고, 서버가 거절했을 때만 새로 발급한다. 발급에 실패하면 60초 동안 어느
인스턴스도 다시 시도하지 않고 알림도 한 번만 보낸다. 모의(VTS)와 실전은 주소가 달라 토큰이
섞이지 않는다.

요청 수·429·연결 오류 카운터도 인스턴스별이라 새 인스턴스의 통계는 늘 0이었다. 호출 한도와
같은 단위로 공유해 센다.
- 발동 알림(Discord·메일)을 락 안에서 보내서, 전송하는 몇 초 동안 주문 제출과 손절 매도를
  포함한 모든 요청이 멈췄다. 판정은 락 안에서 끝내고 알림은 락을 놓은 뒤 보낸다.
- 복구 확인 요청(HALF_OPEN)이 400/401/403/429 같은 확정 응답을 받으면 점유만 풀어
  다음 요청이 60초를 기다리지 않게 했다. 상태는 닫지 않는다.
- KIS 게이트웨이는 초당 한도 초과(EGW00201)와 토큰 무효(EGW00121/00123)도 5xx로 돌려준다.
  이걸 서버 장애로 세면 조회 두 건만 재시도해도 서킷이 열려 60초간 손절 매도까지 막힌다.
  조회 요청에 한해 본문을 보고 한도는 잠깐 쉬었다 재시도, 토큰 무효는 토큰 갱신으로
  처리한다. 주문 요청은 예전처럼 '응답 불명'으로 보고 다시 보내지 않는다.
SQLAlchemy 2.0에서는 문자열 SQL을 그대로 실행할 수 없고(text() 필요), get_session()이
돌려주는 세션에는 remove()가 없다. 그래서 DB가 멀쩡해도 매번 실패로 잡혔다.
live KIS 점검도 새 인스턴스를 만들어 '토큰 없음'을 보고하던 것을, 공유 토큰 상태만 보고
직전 발급이 실패한 경우에만 알리도록 바꿨다. 점검 때문에 토큰을 새로 발급하지는 않는다.
스케줄러는 장전(08:50~09:00)에 바스켓 리밸런싱을 돌렸는데, 그 시각에는 live 주문이
거래 시간 가드에 전부 거부되고 paper는 전일 종가로 체결됐다. CLI 사이클에 있는 안전
단계(-25% 손절·재매수 쿨다운, 하루 1회 거래 가드, 결측 보충, 사이클 이벤트)도 빠져 있었다.
둘을 같이 돌리면 10:07 CLI가 '오늘 이미 체결 있음'으로 건너뛰어 정해 둔 사이클이 조용히
멈췄다. 스케줄러의 바스켓 경로를 빼고, 바스켓이 켜져 있으면 실행 경로가 CLI라는 경고만
남긴다.
장전의 '전일 evidence 확정'이 주말만 건너뛰어서, 추석·설·대체공휴일 같은 평일 휴장일이
전일로 잡혀 그 날짜의 가짜 evidence가 만들어지고 실제 직전 거래일은 확정되지 않았다.
evidence 경과일도 평일 수로 세서 연휴 뒤 첫 거래일에 '며칠 밀렸다'고 보고 신규 진입을
막고 critical 알림을 냈다. 둘 다 거래일 달력으로 세고, 휴장일에는 evidence를 기록하지 않는다.
장마감 처리를 15시대에만 돌려서, 16시 이후에 켜거나 재시작하면 그날 스냅샷·리포트·evidence·
DB 백업이 조용히 빠졌다. 15:35 이후면 시각과 상관없이 그날 한 번 실행한다. 스케줄러 일일
리포트의 일간 수익률은 0으로 박혀 있었는데, 일일 CLI와 같은 방식(입금을 빼고 계산)으로 잰다.
예전 점검은 005930의 고정 구간(2026-01-01~03-26)만 받아 봐서, 과거 자료는 주지만 갱신이
멈춘 피드나 바스켓 자기 종목(069500·357870 등)의 문제를 잡지 못했다. 바스켓 보유 종목 전부와
KS11의 최근 10일가량을 받아, 마지막 봉이 직전 거래일에서 1거래일 안인지 확인하고 실제 마지막
봉 날짜를 로그로 남긴다.
장중 재스캔과 쿨다운 해제 재스캔 후보에는 신호 시각이 없어서 30분 경과 폐기가 적용되지
않았고, 신호에서 주문까지 걸린 시간도 기록되지 않았다. 신호 시각과 그 시점의 국면 배수를
다른 후보처럼 같이 남긴다.
실행 위치(CWD) 기준으로 파일을 찾아서 다른 폴더에서 띄우면 매일 '파일 없음'으로 갱신을
시도했다. 또 설치된 pykrx(1.0.51)에는 거래일 조회 API가 없어 갱신기가 대체 목록으로
떨어지는데, 그러면 손으로 바로잡은 달력이 틀린 날짜로 돌아갈 수 있다. 이때는 갱신하지 않고
직접 고친 뒤 tools/verify_trading_calendar.py로 검증하라고 남긴다.
체결 프레임에 여러 건(data_count)이 실려 오면 첫 건만 전달하고 있었다. 레코드별로 나눠 모두
전달한다. 이 핸들러는 지금 일일 CLI·스케줄러·대시보드 어디에서도 시작하지 않아 갭 보충과
블랙스완 재점검이 어떤 모드에서도 돌지 않는다. 문서에 그렇게 적고, 문서와 실제 연결 상태가
어긋나면 실패하는 테스트를 넣었다.
- 목표 비중 주문이면 무조건 계좌 MDD·일일 손실 가드를 건너뛰게 해 뒀는데, kr_diversified_hold는
  낙폭 규칙을 꺼 둔 바스켓이라 이 트랙에서는 계좌 가드를 아무도 적용하지 않게 됐다. live에서는
  원래 작동하던 안전판이 사라지는 셈이다. 바스켓이 overlays.drawdown_guard를 켜 둔 경우에만
  넘긴다(지금은 kr_pocket만 해당).
- 피크를 시가 요약으로만 올리게 바꾼 뒤, 가격 없이 잰 총액이 옛 피크보다 크면 MDD가 음수로
  나왔다. 주문 가드는 abs()로 읽어 가짜 한도 도달로 매수를 막는다. 낙폭은 max(피크, 총액)
  기준으로 잰다(피크 값 자체는 올리지 않는다).
검증 결과는 계좌 기록보다 늦게 오는데, 도착 전(null)을 실패와 같은 값으로 봐서 정상적으로
열어도 잠깐씩 실패 안내가 떴다. 불러오는 중과 실패를 따로 표시한다.
지수 자료 대신 069500 종가로 추세를 제대로 판단했어도, 그 사실을 남긴 메모 때문에 화면에
'자료 확인 전 비중 확대 보류'가 떴다. 실제로는 비중을 막지 않았으니 반대로 알린 셈이다.
자료가 없어 비중을 실제로 묶었는지(held_back)를 따로 기록하고, 설명은 그 값을 따른다.
예전 상태 파일은 지금처럼 읽는다.
입금 조회에 실패하면 일간 수익률을 비워 두려던 코드가 0.0에서 시작해 카드에 0.00%가 찍혔다.
첫 기록이라 비교할 전날이 없을 때도 마찬가지였다. 계산하지 못하면 None으로 두고 '—'로
표시한다. 스케줄러 리포트도 같은 규칙을 따른다.
보충 기록은 로컬 원장과 시가·종가 중간값으로 만든 추정치다. live 기록은 증권사 잔고를
따라야 하므로 live 트랙에는 채우지 않는다. 이름 오류로 한 번도 안 돌던 단계라 지금까지는
문제가 드러나지 않았다.
복원한 날의 평가액은 시가·종가 중간값으로 만든 추정치라 2%가량 틀릴 수 있다. 그 값이
고점이 되면 이후 MDD와 kr_pocket 낙폭 규칙이 계속 부풀어 규칙이 일찍 발동한다. 계좌 MDD의
최대 누적수익률 조회와 위험 조절의 낙폭 계산에서 추정 기록을 고점 후보에서 뺀다. 값 자체는
흐름을 잇는 데 그대로 쓴다.
@easygap
easygap merged commit 74cca67 into main Sep 23, 2026
1 check passed
@easygap
easygap deleted the fix/algorithm-review-20260923 branch September 23, 2026 07:21
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