Initial commit: OpenClaw 워크스페이스 버전관리 시작
설정·스크립트·스킬·문서·큐레이션 메모리 추적. 시크릿(credentials/identity)·런타임 상태(state/logs/sessions/sqlite)· 백업(clobbered/bak)·dream 캐시는 .gitignore로 제외. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
#!/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)
|
||||
Reference in New Issue
Block a user