Files
hyowons a5f6b0ecd8 auto: 일일 백업 2026-07-30 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 02:00:02 +09:00

450 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""투자원금(순입금) 집계 — 조회 전용.
투자원금 = 계좌 개설 이후 누적 입금 − 누적 출금. "내 돈 얼마 넣었나"이며,
`총 매입금액`(현재 보유종목 매입원가)이나 `예수금`(남은 현금)과 전혀 다른 값이다.
이 값이 있어야 `순자산 − 투자원금 = 진짜 총수익`(실현+미실현+배당)이 나온다.
키움 kt00018의 평가손익은 현재 보유분 미실현만 잡아 누적 실현손익·배당을 놓친다.
데이터원은 kt00016(일별계좌수익률상세현황)의 기간내총입금·총출금.
⚠️ kt00016은 **조회기간 1년 제한**이라 연도별 창으로 쪼개 누적한다.
캐시 `state/investment_principal.json`:
- frozen : 지난 연도 = 확정값. 한 번 적재 후 재조회 안 함 (0인 연도는 저장 생략)
- current: 올해 창(0101~오늘) 누적. 갱신 대상은 이것뿐 → 계좌당 1콜
- seen : 마지막 갱신 시점의 "당일 입출금" 스냅샷 — 이벤트 감지 기준
갱신은 별도 트리거 없이 behive_web 렌더 경로에서 이벤트로만 일어난다.
당일 입출금(이미 kt00015로 조회 중)이 seen과 다를 때만 올해 창을 재조회하므로
입출금 없는 평시에는 kt00016 호출이 0이다. 안전망으로 7일 초과 시 강제 갱신.
CLI: python3 scripts/investment_principal.py {bootstrap|refresh|show}
"""
from __future__ import annotations
import json
import os
import sys
import time
from contextlib import contextmanager
from datetime import datetime, timedelta, timezone
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import kiwoom_client as kc
KST = timezone(timedelta(hours=9))
WORKSPACE = Path('/Users/snowoyh/.openclaw/agents/stock/workspace')
STATE_FILE = WORKSPACE / 'state' / 'investment_principal.json'
# 부트스트랩 하한 연도. 계좌 개설 전 기간은 kt00016이 에러 없이 전 필드 0을 주므로
# 넉넉히 잡아도 안전하다 (현재 최고(最古) 계좌는 2018년 개설).
FLOOR_YEAR = 2010
PACE_SEC = 0.35 # 연속 조회 rate limit 페이싱
STALE_DAYS = 7 # 이벤트 감지가 못 잡은 경우의 안전망
# 적요에 이 중 하나가 들어가면 '내가 넣은/뺀 돈'(원금 반영), 아니면 투자 성과(원금 무관).
# 원금 반영 : 이체입금(지급결제)·이체오픈뱅킹입금·이체입금·대체입금·소액이체인증입금
# ·ISA가입인증입금 / 이체출금·대체출금
# 원금 무관 : 배당금입금·수익분배금입금·예탁금이용료(이자)입금·공모주환불금입금
# ·쿠폰현금지급(국내)입금·단주매각대금입금·대여수수료입금
# / 공모불입출금·청약수수료출금
# ⚠️ 추측이 아니라 실증된 규칙 — 이 분류의 합이 kt00016 기간내총입금/총출금과
# 4계좌 18개 (계좌,연도) 구간 전부에서 오차 0으로 일치했다(2026-07-29).
# 새 적요가 등장하면 reconciled=False로 드러나므로 조용히 틀리지 않는다.
_PRINCIPAL_RMRK_KEYS = ('이체', '대체', '인증')
def is_principal_flow(rmrk: str) -> bool:
"""적요가 투자원금에 반영되는 외부 입출금인지. 배당·이자 등은 False."""
return any(k in (rmrk or '') for k in _PRINCIPAL_RMRK_KEYS)
# ---- 캐시 I/O ----
@contextmanager
def _lock():
"""STATE_FILE 직렬화 — behive_web _stock_notes_lock 과 동일 패턴, 별도 lock 파일."""
import fcntl
lock_path = STATE_FILE.with_suffix(STATE_FILE.suffix + '.lock')
lock_path.parent.mkdir(parents=True, exist_ok=True)
f = open(lock_path, 'a')
try:
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
yield
finally:
try:
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
finally:
f.close()
def _load() -> dict | None:
"""캐시 로드. 파일 없거나 깨졌으면 None (호출측이 '미집계'로 처리)."""
if not STATE_FILE.exists():
return None
try:
d = json.loads(STATE_FILE.read_text())
except Exception:
return None
if not isinstance(d, dict) or 'current' not in d:
return None
d.setdefault('frozen', {})
d.setdefault('seen', {})
d['current'].setdefault('labels', {})
return d
def _save(d: dict) -> None:
d['refreshed_at'] = datetime.now(KST).isoformat(timespec='seconds')
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = STATE_FILE.with_suffix('.json.tmp')
tmp.write_text(json.dumps(d, ensure_ascii=False, indent=2))
os.replace(tmp, STATE_FILE)
# ---- 조회 ----
def _today_str() -> str:
return datetime.now(KST).strftime('%Y%m%d')
def _this_year() -> int:
return datetime.now(KST).year
def _window(year: int) -> tuple[str, str]:
"""연도별 조회 창. 올해는 오늘까지, 지난 연도는 12/31까지 (1년 제한 내)."""
if year == _this_year():
return f'{year}0101', _today_str()
return f'{year}0101', f'{year}1231'
def _fetch_year(label: str, year: int) -> dict:
"""한 (계좌, 연도) 창의 합계(kt00016) + 입출금 행(kt00015). 2콜.
flows 행의 `pr`은 원금 반영 여부. 분류 합이 kt00016 합계와 맞는지 `rec`에 남겨
새 적요가 등장해 분류가 어긋나면 화면에서 드러나게 한다.
"""
fr, to = _window(year)
r = kc.get_period_return(label, fr, to)
time.sleep(PACE_SEC)
try:
raw = kc.get_cash_flow(label, strt_dt=fr, end_dt=to)
except Exception as e:
sys.stderr.write(f'cash flow {label} {year} 실패: {e}\n')
raw = None
flows = None if raw is None else [
{'d': f['date'], 'io': f['io_tp'], 'amt': f['amount'],
'rk': f['rmrk'], 'pr': is_principal_flow(f['rmrk'])}
for f in raw
]
rec = None
if flows is not None:
pin = sum(f['amt'] for f in flows if f['io'] == 'IN' and f['pr'])
pout = sum(f['amt'] for f in flows if f['io'] == 'OUT' and f['pr'])
rec = (pin == r['cash_in'] and pout == r['cash_out'])
return {
'in': r['cash_in'], 'out': r['cash_out'],
'qin': r['qty_in'], 'qout': r['qty_out'],
'nf': r['net_fr'], 'nt': r['net_to'], 'pl': r['eval_pl'],
'flows': flows, 'rec': rec,
}
def _is_empty(y: dict) -> bool:
"""계좌 개설 전(또는 전면 무활동) 연도 — 저장 생략 대상."""
return not any(y[k] for k in ('in', 'out', 'qin', 'qout', 'nf', 'nt', 'pl'))
def _labels(labels: list[str] | None = None) -> list[str]:
all_lb = [a['label'] for a in kc.list_accounts()]
if labels is None:
return all_lb
wanted = set(labels)
return [lb for lb in all_lb if lb in wanted]
def _flow_totals(cash_flow_by_label: dict) -> dict:
"""behive_web의 kt00015 당일 입출금 → {label: {'in': n, 'out': n}}."""
out: dict[str, dict] = {}
for lb, flows in (cash_flow_by_label or {}).items():
ci = sum(f['amount'] for f in (flows or []) if f.get('io_tp') == 'IN')
co = sum(f['amount'] for f in (flows or []) if f.get('io_tp') == 'OUT')
out[lb] = {'in': ci, 'out': co}
return out
# ---- 공개 API ----
def bootstrap(labels: list[str] | None = None) -> dict:
"""FLOOR_YEAR ~ 올해 전수 조회로 캐시를 새로 만든다 (계좌×연도, 4×17≈68콜 ≈ 25초).
조기 종료 없음 — 잔고가 0으로 비었던 휴면 연도가 중간에 끼어도 그 앞 연도를 놓치지 않는다.
⚠️ 1회성 CLI 전용. 렌더 경로에서 절대 호출하지 말 것.
"""
lbs = _labels(labels)
this_year = _this_year()
frozen: dict[str, dict] = {}
current: dict[str, dict] = {}
for lb in lbs:
frozen[lb] = {}
for year in range(FLOOR_YEAR, this_year + 1):
y = _fetch_year(lb, year)
time.sleep(PACE_SEC)
if year == this_year:
current[lb] = y
elif not _is_empty(y):
frozen[lb][year_key(year)] = y
with _lock():
d = _load() or {}
d['frozen'] = {**(d.get('frozen') or {}), **frozen}
cur = d.get('current') or {}
if cur.get('year') != this_year:
cur = {'year': this_year, 'labels': {}}
cur['labels'] = {**(cur.get('labels') or {}), **current}
d['current'] = cur
d['seen'] = {**(d.get('seen') or {}), **_seen_now(lbs)}
_save(d)
return _load()
def year_key(year: int) -> str:
return str(year)
def _seen_now(labels: list[str]) -> dict:
"""현재 당일 입출금을 seen 형식으로. (CLI refresh/bootstrap 용 — kt00015 계좌당 1콜)"""
today = datetime.now(KST).strftime('%Y-%m-%d')
totals = _flow_totals(kc.get_cash_flow_all(None, labels))
return {lb: {'date': today, **totals.get(lb, {'in': 0, 'out': 0})} for lb in labels}
def refresh_current(labels: list[str], cash_flow_by_label: dict | None = None) -> None:
"""올해 창만 재조회 (라벨당 1콜) + 연도 롤오버 시 작년을 확정값으로 이관.
cash_flow_by_label: 호출측이 이미 들고 있는 kt00015 당일 입출금.
None이면 직접 조회. seen 기록에 쓴다 — kt00016 호출 **전** 시점의 값이어야
그 사이 들어온 입금이 seen에 앞서 반영돼 영구 미갱신되는 일이 없다.
"""
lbs = _labels(labels)
if not lbs:
return
if cash_flow_by_label is None:
seen = _seen_now(lbs)
else:
today = datetime.now(KST).strftime('%Y-%m-%d')
totals = _flow_totals(cash_flow_by_label)
seen = {lb: {'date': today, **totals.get(lb, {'in': 0, 'out': 0})} for lb in lbs}
this_year = _this_year()
with _lock():
d = _load()
if d is None:
return # 부트스트랩 전 — 렌더 경로를 68콜로 막지 않는다
cur = d['current']
prev_year = cur.get('year')
# 연도 롤오버: current에 담긴 작년치는 to_dt가 12/31이 아니라 미완결이므로
# 전체 창(0101~1231)으로 다시 받아 확정 이관한다.
if prev_year and prev_year != this_year:
for lb in lbs:
y = _fetch_year(lb, prev_year)
time.sleep(PACE_SEC)
if not _is_empty(y):
d['frozen'].setdefault(lb, {})[year_key(prev_year)] = y
cur = {'year': this_year, 'labels': {}}
for lb in lbs:
cur['labels'][lb] = _fetch_year(lb, this_year)
time.sleep(PACE_SEC)
cur['year'] = this_year
d['current'] = cur
d['seen'].update(seen)
_save(d)
def needs_refresh(labels: list[str], cash_flow_by_label: dict) -> bool:
"""올해 창 재조회가 필요한지 판정 — 순수 캐시 읽기(키움 콜 0).
캐시가 없으면 False — 부트스트랩 미실행 상태에서 페이지 로드를 68콜로 막지 않는다.
"""
d = _load()
if d is None:
return False
if d['current'].get('year') != _this_year():
return True
today = datetime.now(KST).strftime('%Y-%m-%d')
totals = _flow_totals(cash_flow_by_label)
for lb in labels:
if lb not in totals:
continue # owner 부분 갱신 — 조회 안 된 계좌는 판단 보류
s = d['seen'].get(lb) or {}
base = s if s.get('date') == today else {'in': 0, 'out': 0}
if totals[lb]['in'] != base.get('in', 0) or totals[lb]['out'] != base.get('out', 0):
return True
ts = d.get('refreshed_at')
if not ts:
return True
try:
age = datetime.now(KST) - datetime.fromisoformat(ts)
except Exception:
return True
return age > timedelta(days=STALE_DAYS)
def get_principal(labels: list[str]) -> dict:
"""캐시만 읽어 누적 원금 산출 (키움 콜 0).
반환 principal이 None이면 '미집계' — 요청 라벨 중 부트스트랩 안 된 계좌가 있다는 뜻.
(0으로 떨어뜨리면 그 계좌 입금이 조용히 빠져 원금이 과소계상된다.)
qty_flow=True는 주식 입출고(타사대체) 이력 — 현금 기준 원금이 과소계상됨을 알림.
by_label은 계좌별 순입금 {label: 원} — 자세히보기 토스트용.
"""
miss = {'principal': None, 'cash_in': None, 'cash_out': None,
'qty_flow': False, 'by_label': {}}
d = _load()
if d is None:
return miss
cur = d['current']['labels']
total_in = total_out = 0
qty_flow = False
by_label: dict[str, int] = {}
for lb in labels:
if lb not in cur:
return miss
recs = list((d['frozen'].get(lb) or {}).values()) + [cur[lb]]
lb_in = lb_out = 0
for y in recs:
lb_in += y.get('in', 0)
lb_out += y.get('out', 0)
qty_flow = qty_flow or bool(y.get('qin') or y.get('qout'))
by_label[lb] = lb_in - lb_out
total_in += lb_in
total_out += lb_out
return {
'principal': total_in - total_out,
'cash_in': total_in,
'cash_out': total_out,
'qty_flow': qty_flow,
'by_label': by_label,
}
def get_flows(labels: list[str]) -> dict:
"""입출금 내역 (최신순) — 캐시만 읽는다 (키움 콜 0).
반환:
- rows: [{date, io('IN'|'OUT'), amount, rmrk, principal(bool), label}] 최신순
- other_in / other_out: 원금 무관 입출금 합계 (배당·이자 등)
- reconciled: 전 구간에서 적요 분류 합이 kt00016 합계와 일치했는지
False면 새 적요가 등장해 분류가 어긋났다는 신호
- available: 내역이 캐시에 있는지 (부트스트랩 전 or 조회 실패 시 False)
"""
d = _load()
if d is None:
return {'rows': [], 'other_in': 0, 'other_out': 0,
'reconciled': True, 'available': False}
cur = d['current']['labels']
rows: list[dict] = []
reconciled = True
available = False
for lb in labels:
recs = list((d['frozen'].get(lb) or {}).values())
if lb in cur:
recs.append(cur[lb])
for y in recs:
flows = y.get('flows')
if flows is None:
continue
available = True
if y.get('rec') is False:
reconciled = False
for f in flows:
rows.append({'date': f['d'], 'io': f['io'], 'amount': f['amt'],
'rmrk': f['rk'], 'principal': f['pr'], 'label': lb})
rows.sort(key=lambda r: (r['date'], r['label']), reverse=True)
return {
'rows': rows,
'other_in': sum(r['amount'] for r in rows if r['io'] == 'IN' and not r['principal']),
'other_out': sum(r['amount'] for r in rows if r['io'] == 'OUT' and not r['principal']),
'reconciled': reconciled,
'available': available,
}
# ---- CLI ----
def _show() -> int:
d = _load()
if d is None:
print(f'캐시 없음: {STATE_FILE}\n → python3 scripts/investment_principal.py bootstrap')
return 1
from stock_portfolio_report import derive_owner
print(f'갱신: {d.get("refreshed_at")} (올해 창 {d["current"].get("year")})\n')
lbs = list(d['current']['labels'].keys())
bad = 0
for lb in lbs:
years = sorted((d['frozen'].get(lb) or {}).items())
years.append((year_key(d['current']['year']), d['current']['labels'][lb]))
print(f'===== {lb} =====')
tin = tout = 0
for yk, y in years:
# 항등식: 순자산초 + 입금 − 출금 + 평가손익 = 순자산말
err = y['nf'] + y['in'] - y['out'] + y['pl'] - y['nt']
bad += 1 if err else 0
tin += y['in']
tout += y['out']
q = f' 입고 {y["qin"]:,} 출고 {y["qout"]:,}' if (y['qin'] or y['qout']) else ''
nf = len(y.get('flows') or []) if y.get('flows') is not None else None
rc = {True: '분류OK', False: '분류불일치!', None: '내역없음'}[y.get('rec')]
print(f' {yk}: 입금 {y["in"]:>12,} 출금 {y["out"]:>11,} '
f'순자산 {y["nf"]:>12,}{y["nt"]:>12,} 손익 {y["pl"]:>12,} '
f'항등식오차 {err:,} 내역 {nf if nf is not None else "-":>3}{rc}{q}')
print(f' >> 누적 입금 {tin:,} 출금 {tout:,} = 순입금 {tin - tout:,}\n')
print('항등식 검증:', '전 행 오차 0 ✅' if not bad else f'⚠️ 오차 있는 행 {bad}')
fl = get_flows(lbs)
print(f'적요 분류 검증: {"전 구간 일치 ✅" if fl["reconciled"] else "⚠️ 불일치 구간 있음 — 새 적요 확인 필요"}'
f' (내역 {len(fl["rows"])}행, 원금무관 입금 {fl["other_in"]:,}원 / 출금 {fl["other_out"]:,}원)')
# 라이브 교차검증 — 원금 대비 총수익 vs 실제 순자산
print('\n===== 원금 대비 총수익 (라이브 교차검증) =====')
owners: dict[str, list[str]] = {}
for lb in lbs:
owners.setdefault(derive_owner(lb), []).append(lb)
for owner, olbs in owners.items():
pr = get_principal(olbs)
net = 0
for lb in olbs:
net += sum(p['evlt_amt'] for p in kc.get_positions(lb)) + kc.get_balance(lb)['d2_entra']
p = pr['principal']
ret = net - p
warn = ' ⚠️ 주식 입출고 있음(원금 과소계상)' if pr['qty_flow'] else ''
print(f'[{owner}] 투자원금 {p:,}원 (입금 {pr["cash_in"]:,} 출금 {pr["cash_out"]:,}){warn}')
print(f' 순자산 {net:,}원 → 총수익 {ret:+,}원 ({ret / p * 100:+.2f}%)' if p else ' 원금 0')
return 0 if not bad else 1
def main(argv: list[str]) -> int:
cmd = argv[1] if len(argv) > 1 else 'show'
if cmd == 'bootstrap':
lbs = _labels()
n = len(lbs) * (_this_year() - FLOOR_YEAR + 1)
print(f'{len(lbs)}계좌 × {_this_year() - FLOOR_YEAR + 1}년 = {n}콜 (약 {n * PACE_SEC:.0f}초)...')
bootstrap()
print(f'완료 → {STATE_FILE}\n')
return _show()
if cmd == 'refresh':
refresh_current(_labels())
print(f'올해 창 갱신 완료 → {STATE_FILE}\n')
return _show()
if cmd == 'show':
return _show()
print(__doc__)
return 2
if __name__ == '__main__':
raise SystemExit(main(sys.argv))