549545bde6
설정·스크립트·스킬·문서·큐레이션 메모리 추적. 시크릿(credentials/identity)·런타임 상태(state/logs/sessions/sqlite)· 백업(clobbered/bak)·dream 캐시는 .gitignore로 제외. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
197 lines
7.3 KiB
Python
197 lines
7.3 KiB
Python
#!/usr/bin/env python3
|
|
"""일봉 캐시 (state/daily_candles.sqlite).
|
|
|
|
자산웹 차트보기·분석 페이지에서 사용. 키움 ka10081 rate limit(분당 ~20콜)이 빡빡해
|
|
한 번 받은 일봉은 sqlite 에 저장하고 어제까지의 봉은 재호출하지 않는다.
|
|
|
|
스키마:
|
|
daily_candles(code, date, open, high, low, close, volume, value, turnover_rate)
|
|
PRIMARY KEY (code, date)
|
|
|
|
정책:
|
|
- 어제까지 봉만 저장. 오늘 봉은 장중 변동이라 캐시에 두지 않는다 (호출자가 ka10001 로 별도 결합).
|
|
- upd_stkpc_tp=1 수정주가 기준
|
|
- lazy fill 시 새로 받은 봉 중 캐시와 겹치는 날짜의 close 를 비교 → 다르면 권리락(분할/증자/감자) →
|
|
250 개 전체 재구축. 현금배당은 차트 영향 없음 (배당락일에 한 봉 갭으로 자연스레 표시).
|
|
|
|
CLI (디버깅용):
|
|
python3 daily_candles_cache.py <code> [count]
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
import sys
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
from zoneinfo import ZoneInfo
|
|
|
|
KST = ZoneInfo('Asia/Seoul')
|
|
WORKSPACE = Path(__file__).resolve().parent.parent
|
|
DB_PATH = WORKSPACE / 'state' / 'daily_candles.sqlite'
|
|
|
|
CACHE_TARGET_COUNT = 250 # 첫 fill 시 받을 봉 수 (분석 페이지 1Y 와 동일)
|
|
VERIFY_WINDOW = 5 # 권리락 감지: 캐시·신규 겹치는 마지막 N일 close 비교
|
|
KIWOOM_FETCH_MAX = 600 # ka10081 한 콜 최대치 (안전 상한)
|
|
MISMATCH_THRESHOLD = 2 # 겹친 봉 중 close 가 다른 게 N 개 이상이면 권리락으로 판정
|
|
|
|
|
|
def _conn() -> sqlite3.Connection:
|
|
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
c = sqlite3.connect(DB_PATH, isolation_level=None)
|
|
c.execute("""
|
|
CREATE TABLE IF NOT EXISTS daily_candles (
|
|
code TEXT NOT NULL,
|
|
date TEXT NOT NULL,
|
|
open INTEGER NOT NULL,
|
|
high INTEGER NOT NULL,
|
|
low INTEGER NOT NULL,
|
|
close INTEGER NOT NULL,
|
|
volume INTEGER NOT NULL,
|
|
value INTEGER NOT NULL,
|
|
turnover_rate REAL,
|
|
PRIMARY KEY (code, date)
|
|
)
|
|
""")
|
|
c.execute("CREATE INDEX IF NOT EXISTS idx_code_date ON daily_candles(code, date DESC)")
|
|
return c
|
|
|
|
|
|
def _yesterday_kst() -> str:
|
|
return (datetime.now(KST) - timedelta(days=1)).strftime('%Y%m%d')
|
|
|
|
|
|
def _today_kst() -> str:
|
|
return datetime.now(KST).strftime('%Y%m%d')
|
|
|
|
|
|
def _row_to_dict(row: tuple) -> dict:
|
|
return {
|
|
'date': row[0],
|
|
'open': row[1],
|
|
'high': row[2],
|
|
'low': row[3],
|
|
'close': row[4],
|
|
'volume': row[5],
|
|
'value': row[6],
|
|
'turnover_rate': row[7],
|
|
}
|
|
|
|
|
|
def _select_latest(code: str, count: int) -> list[dict]:
|
|
"""캐시에서 최신 count 개 봉 (내림차순)."""
|
|
with _conn() as c:
|
|
rows = c.execute("""
|
|
SELECT date, open, high, low, close, volume, value, turnover_rate
|
|
FROM daily_candles WHERE code = ?
|
|
ORDER BY date DESC LIMIT ?
|
|
""", (code, count)).fetchall()
|
|
return [_row_to_dict(r) for r in rows]
|
|
|
|
|
|
def _replace_all(code: str, candles_desc: list[dict]) -> None:
|
|
"""code 의 캐시 모두 삭제 후 신규 봉으로 교체. 권리락 발생 시 사용."""
|
|
with _conn() as c:
|
|
c.execute("BEGIN")
|
|
c.execute("DELETE FROM daily_candles WHERE code = ?", (code,))
|
|
c.executemany("""
|
|
INSERT INTO daily_candles
|
|
(code, date, open, high, low, close, volume, value, turnover_rate)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""", [
|
|
(code, x['date'], x['open'], x['high'], x['low'], x['close'],
|
|
x['volume'], x['value'], x.get('turnover_rate'))
|
|
for x in candles_desc
|
|
])
|
|
c.execute("COMMIT")
|
|
|
|
|
|
def _upsert_many(code: str, candles_desc: list[dict]) -> None:
|
|
"""INSERT OR REPLACE — 갭 보충용."""
|
|
with _conn() as c:
|
|
c.executemany("""
|
|
INSERT OR REPLACE INTO daily_candles
|
|
(code, date, open, high, low, close, volume, value, turnover_rate)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""", [
|
|
(code, x['date'], x['open'], x['high'], x['low'], x['close'],
|
|
x['volume'], x['value'], x.get('turnover_rate'))
|
|
for x in candles_desc
|
|
])
|
|
|
|
|
|
def _fetch_from_kiwoom(code: str, count: int) -> list[dict]:
|
|
"""ka10081 호출 후 오늘 봉 제외한 어제까지 봉 반환 (최신순)."""
|
|
import kiwoom_client as kc
|
|
candles = kc.get_daily_candles(code, count=count)
|
|
today = _today_kst()
|
|
return [c for c in candles if c['date'] != today]
|
|
|
|
|
|
def _date_gap_days(yyyymmdd_a: str, yyyymmdd_b: str) -> int:
|
|
a = datetime.strptime(yyyymmdd_a, '%Y%m%d')
|
|
b = datetime.strptime(yyyymmdd_b, '%Y%m%d')
|
|
return (b - a).days
|
|
|
|
|
|
def get_candles(code: str, count: int = CACHE_TARGET_COUNT) -> list[dict]:
|
|
"""code 의 어제까지 일봉 최신 count 개를 시간 오름차순으로 반환.
|
|
|
|
동작:
|
|
1) 캐시 조회. 비었으면 ka10081 으로 250 개 fetch 후 저장.
|
|
2) 캐시의 최신 봉 date 가 어제 이전이면 갭 + 검증분만큼 받아옴.
|
|
겹치는 봉의 close 가 MISMATCH_THRESHOLD 이상 다르면 권리락 → 250 개 재구축.
|
|
그 외엔 새 봉만 upsert.
|
|
3) 최신 count 개 슬라이스 후 reverse (render_svg_chart 가 오름차순 기대).
|
|
"""
|
|
cached_desc = _select_latest(code, max(count, CACHE_TARGET_COUNT))
|
|
yesterday = _yesterday_kst()
|
|
|
|
if not cached_desc:
|
|
fresh_desc = _fetch_from_kiwoom(code, CACHE_TARGET_COUNT)
|
|
if fresh_desc:
|
|
_replace_all(code, fresh_desc)
|
|
cached_desc = fresh_desc
|
|
else:
|
|
latest_cached = cached_desc[0]['date']
|
|
if latest_cached < yesterday:
|
|
gap_estimate = _date_gap_days(latest_cached, yesterday) + VERIFY_WINDOW
|
|
fetch_count = min(KIWOOM_FETCH_MAX, max(VERIFY_WINDOW + 2, gap_estimate * 2))
|
|
fresh_desc = _fetch_from_kiwoom(code, fetch_count)
|
|
|
|
cached_by_date = {c['date']: c for c in cached_desc}
|
|
overlap_mismatches = 0
|
|
overlap_total = 0
|
|
for fc in fresh_desc:
|
|
if fc['date'] in cached_by_date:
|
|
overlap_total += 1
|
|
if fc['close'] != cached_by_date[fc['date']]['close']:
|
|
overlap_mismatches += 1
|
|
|
|
if overlap_total >= 3 and overlap_mismatches >= MISMATCH_THRESHOLD:
|
|
sys.stderr.write(
|
|
f'[daily_candles_cache] {code}: 권리락 감지 '
|
|
f'({overlap_mismatches}/{overlap_total} close 불일치) → 250개 재구축\n'
|
|
)
|
|
full_desc = _fetch_from_kiwoom(code, CACHE_TARGET_COUNT)
|
|
if full_desc:
|
|
_replace_all(code, full_desc)
|
|
cached_desc = full_desc
|
|
else:
|
|
_upsert_many(code, fresh_desc)
|
|
cached_desc = _select_latest(code, max(count, CACHE_TARGET_COUNT))
|
|
|
|
desc_slice = cached_desc[:count]
|
|
return list(reversed(desc_slice))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
if len(sys.argv) < 2:
|
|
print('usage: python3 daily_candles_cache.py <code> [count]')
|
|
sys.exit(1)
|
|
code = sys.argv[1]
|
|
cnt = int(sys.argv[2]) if len(sys.argv) > 2 else 10
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
out = get_candles(code, count=cnt)
|
|
for c in out:
|
|
print(c)
|