"""수급 백필 — ka10059 페이지네이션으로 종목별 투자자 순매수 다년치 → flow_history.sqlite. 백테스트가 수급(외국인·기관)을 반영하려면 과거 일자별 순매수가 필요한데 키움은 단일 콜 100일뿐 이라 연속조회로 깊이 받아 로컬 적재한다. 1회성(또는 가끔) 실행, idempotent(코드+일자 upsert). 스키마: flow(code, date, foreign_net, inst_net, indiv_net) 단위 천주, +순매수/−순매도. backtest.load_flow 가 (foreign_net, inst_net) 를 읽는다. rate limit(ka10059 stkinfo): 콜·종목 간 sleep + 429 백오프 재시도. CLI: python3 -m sim.backfill_flow [--pages N] [--force] [code ...] --pages N : 종목당 페이지수(100거래일/페이지, 기본 6 ≈ 2.4년) --force : 이미 충분히 쌓인 종목도 다시 받음 """ from __future__ import annotations import sqlite3 import sys import time from datetime import datetime from . import config, universe sys.path.insert(0, str(config.SCRIPTS)) import kiwoom_client as kc # noqa: E402 FLOW_DB = config.STATE_DIR / 'flow_history.sqlite' DEFAULT_PAGES = 6 # 100거래일 × 6 ≈ 2.4년 PAGE_SLEEP = 0.35 # 페이지 간 간격 CODE_SLEEP = 0.5 # 종목 간 간격 RATE_BACKOFF = 3.0 # 429 시 추가 대기 def _conn() -> sqlite3.Connection: FLOW_DB.parent.mkdir(parents=True, exist_ok=True) c = sqlite3.connect(FLOW_DB, isolation_level=None) c.execute(""" CREATE TABLE IF NOT EXISTS flow ( code TEXT NOT NULL, date TEXT NOT NULL, foreign_net INTEGER, inst_net INTEGER, indiv_net INTEGER, PRIMARY KEY (code, date) )""") return c def _is_rate_limit(resp: dict) -> bool: return resp.get('return_code', 0) != 0 and ( '허용된 요청' in str(resp.get('return_msg') or '') or resp.get('return_code') == 5) def fetch_code(code: str, pages: int = DEFAULT_PAGES) -> list[dict]: """ka10059 연속조회 — 최신부터 pages 페이지(페이지당 100거래일) 누적. 최신순 list.""" label = kc._default_account_label() today = datetime.now(config.KST).strftime('%Y%m%d') body = {'dt': today, 'stk_cd': kc._clean_code(code), 'amt_qty_tp': '2', 'trde_tp': '0', 'unit_tp': '1000'} url = kc.base_url() + kc.ENDPOINT_STKINFO cont, nk = 'N', '' out: list[dict] = [] for _page in range(pages): headers = kc.auth_headers(label, tr_id=kc.TR_INVESTOR_FLOW, cont_yn=cont, next_key=nk) resp, hd = kc._http_post_full(url, body, headers) if _is_rate_limit(resp): time.sleep(RATE_BACKOFF) resp, hd = kc._http_post_full(url, body, headers) if resp.get('return_code', 0) != 0: sys.stderr.write(f'[backfill] {code} page 실패: {resp.get("return_msg")}\n') break for r in resp.get('stk_invsr_orgn') or []: d = (r.get('dt') or '').strip() if d: out.append({'date': d, 'foreign': kc._to_int(r.get('frgnr_invsr')), 'institution': kc._to_int(r.get('orgn')), 'individual': kc._to_int(r.get('ind_invsr'))}) cy = (hd.get('cont-yn') or '').strip().upper() nkk = (hd.get('next-key') or '').strip() if cy == 'Y' and nkk: cont, nk = 'Y', nkk time.sleep(PAGE_SLEEP) else: break return out def _existing_count(c: sqlite3.Connection, code: str) -> int: return c.execute('SELECT COUNT(*) FROM flow WHERE code=?', (code,)).fetchone()[0] def backfill(codes: list[str] | None = None, pages: int = DEFAULT_PAGES, force: bool = False) -> dict: codes = codes or [e['code'] for e in universe.build_universe()] c = _conn() target = pages * 100 done = skipped = failed = 0 for code in codes: if not force and _existing_count(c, code) >= target * 0.9: skipped += 1 continue rows = fetch_code(code, pages) if not rows: failed += 1 continue c.executemany('INSERT OR REPLACE INTO flow(code,date,foreign_net,inst_net,indiv_net) VALUES (?,?,?,?,?)', [(code, r['date'], r['foreign'], r['institution'], r['individual']) for r in rows]) done += 1 sys.stderr.write(f'[backfill] {code}: {len(rows)}행 ({rows[-1]["date"]}~{rows[0]["date"]})\n') time.sleep(CODE_SLEEP) total = c.execute('SELECT COUNT(*) FROM flow').fetchone()[0] n_codes = c.execute('SELECT COUNT(DISTINCT code) FROM flow').fetchone()[0] c.close() return {'fetched': done, 'skipped': skipped, 'failed': failed, 'total_rows': total, 'codes_in_db': n_codes} if __name__ == '__main__': argv = sys.argv[1:] force = '--force' in argv pages = DEFAULT_PAGES if '--pages' in argv: pages = int(argv[argv.index('--pages') + 1]) explicit = [a for a in argv if a.isdigit() and len(a) >= 4] res = backfill(codes=explicit or None, pages=pages, force=force) print(f"백필 완료 — 신규 {res['fetched']} · 스킵 {res['skipped']} · 실패 {res['failed']} " f"· DB {res['codes_in_db']}종목 {res['total_rows']}행")