auto: 일일 백업 2026-10-03 02:00

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hyowons
2026-10-03 02:00:04 +09:00
parent 5e47a15547
commit 5384b1d670
135 changed files with 2190 additions and 1855 deletions
+274 -147
View File
@@ -20,6 +20,7 @@ import argparse
import gzip
import html
import json
import re
import sys
import threading
import time
@@ -50,6 +51,8 @@ IPO_SUBSCRIBED = WORKSPACE / 'state' / 'behive_ipo_subscribed.json'
ALERTS_STATE = WORKSPACE / 'state' / 'watchlist_alerts.json'
HOLIDAYS_FILE = WORKSPACE / 'state' / 'market_holidays.json'
SNAPSHOT_FILE = WORKSPACE / 'state' / 'portfolio_daily_snapshot.json'
# 스냅샷 이전 구간(2025-01~2026-04) 일별 순자산·입출금 — net_worth_backfill.py(키움 kt00002) 1회 적재.
NET_WORTH_HISTORY_FILE = WORKSPACE / 'state' / 'net_worth_history.json'
TRADE_JOURNAL_FILE = WORKSPACE / 'state' / 'trade_journal.jsonl'
MARKET_HISTORY_FILE = WORKSPACE / 'state' / 'market_indicators_history.jsonl'
ADR_TREND_DAYS = 30 # 시장정보 sub-tab ADR 추세 차트 default 기간 (영업일 기준 최근 N행)
@@ -57,8 +60,6 @@ ADR_TREND_DAYS = 30 # 시장정보 sub-tab ADR 추세 차트 default 기간 (
ADR_TREND_PRESETS = [('1주', 7), ('1달', 30), ('3달', 90), ('6달', 180), ('1년', 365), ('전체', -1)]
ADR_MA_WINDOW = 20 # ADR 이동평균 윈도우. 카드 수치·차트 모두 이 값 사용. 가용 데이터 < window 면 평균 산출 안 함
NET_WORTH_CHART_DAYS = -2 # default = 이번달. 클라이언트가 ?chart_days로 override
# (label, days) — 클라이언트 UI 옵션. days=-1 전체, -2 이번달(KST 기준), -3 이번주(이번 ISO 월요일~오늘).
NET_WORTH_CHART_PRESETS = [('전체보기', -1), ('올해', -4), ('이번달', -2), ('이번주', -3)]
# 차트 단위 토글 — 각 구간 마지막 영업일 net_worth만 표시. 'day'=기본 일별.
NET_WORTH_UNIT_PRESETS = [('일별', 'day'), ('주별', 'week'), ('월별', 'month'), ('연별', 'year')]
NET_WORTH_CHART_UNIT = 'day'
@@ -86,6 +87,7 @@ sys.path.insert(0, str(WORKSPACE)) # sim 패키지(from sim import signals, con
_holidays_cache: dict = {'mtime': 0.0, 'set': set()}
_nw_history_cache: dict = {'mtime': 0.0, 'days': {}}
_traded_codes_cache: dict = {'mtime': 0.0, 'set': set()}
_holding_since_cache: dict = {'mtime': None, 'map': {}}
_quote_cache: dict[str, tuple[dict, float]] = {}
@@ -4966,6 +4968,36 @@ def _aggregate_by_unit(series: list[tuple[str, int]], unit: str) -> list[tuple[s
return sorted(groups.values(), key=lambda x: x[0])
def _load_nw_history() -> dict:
"""net_worth_history.json 의 {date: {owner: {net, cash_in?, cash_out?}}}. 없거나 깨지면 {} (차트는 스냅샷만)."""
try:
mtime = NET_WORTH_HISTORY_FILE.stat().st_mtime
except OSError:
return {}
if mtime != _nw_history_cache['mtime']:
try:
_nw_history_cache['days'] = json.loads(NET_WORTH_HISTORY_FILE.read_text()).get('days') or {}
_nw_history_cache['mtime'] = mtime
except Exception as e:
sys.stderr.write(f'net_worth_history load failed: {e}\n')
return {}
return _nw_history_cache['days']
def _first_full_snapshot_date(snap: dict, owner: str) -> str | None:
"""스냅샷이 holdings+deposit 형식으로 시작하는 날 — 그 이전은 history 가 대신한다."""
return next((d for d in sorted(snap) if 'holdings' in ((snap.get(d) or {}).get(owner) or {})), None)
def _history_points(snap: dict, owner: str) -> tuple[str | None, list[tuple[str, dict]]]:
"""(경계일, [(date, rec)]) — 스냅샷 첫 정식 날짜 이전의 history 행. 경계일이 없으면 history 전부."""
hist = _load_nw_history()
first = _first_full_snapshot_date(snap, owner)
rows = [(d, v[owner]) for d, v in sorted(hist.items())
if owner in v and (first is None or d < first)]
return first, rows
def _load_cash_flow_by_date(owner: str) -> dict[str, dict]:
"""portfolio_daily_snapshot에서 owner별 일자별 입출금 dict.
@@ -4985,6 +5017,12 @@ def _load_cash_flow_by_date(owner: str) -> dict[str, dict]:
co = int(owner_snap.get('cash_out', 0) or 0)
if ci or co:
out[date_key] = {'cash_in': ci, 'cash_out': co}
# 스냅샷 이전 구간 입출금 — history(kt00015 캐시에서 적재, 스냅샷 cash_in 과 같은 의미).
_, hist_rows = _history_points(snap, owner)
for d, rec in hist_rows:
ci, co = int(rec.get('cash_in', 0) or 0), int(rec.get('cash_out', 0) or 0)
if ci or co:
out[d] = {'cash_in': ci, 'cash_out': co}
return out
@@ -5051,6 +5089,32 @@ def _aggregate_cash_flows_by_unit(daily_cf: dict[str, dict], aggregated_dates: l
return out
_CHART_YM_RE = {
'year': re.compile(r'^\d{4}$'),
'month': re.compile(r'^\d{4}-\d{2}$'),
'range_day': re.compile(r'^(\d{4}-\d{2}-\d{2})~(\d{4}-\d{2}-\d{2})$'),
'range_month': re.compile(r'^(\d{4}-\d{2})~(\d{4}-\d{2})$'),
}
def _parse_chart_ym(s: str) -> dict | None:
"""차트 지정기간(chart_ym) 해석 — 형식이 곧 단위다.
'YYYY'=연도별(월 단위) / 'YYYY-MM'=월별(일 단위) / 'D~D'=기간·일 / 'YYYY-MM~YYYY-MM'=기간·월.
반환 {kind, unit, start, end, is_current} — start/end 는 비교용 문자열(포함 경계). 형식 위반은 None."""
s = (s or '').strip()
now = datetime.now(KST)
if _CHART_YM_RE['year'].match(s):
return {'kind': 'year', 'unit': 'month', 'start': s, 'end': s, 'is_current': s == now.strftime('%Y')}
if _CHART_YM_RE['month'].match(s):
return {'kind': 'month', 'unit': 'day', 'start': s, 'end': s, 'is_current': s == now.strftime('%Y-%m')}
for key, unit, today in (('range_day', 'day', now.strftime('%Y-%m-%d')), ('range_month', 'month', now.strftime('%Y-%m'))):
m = _CHART_YM_RE[key].match(s)
if m:
a, b = sorted((m.group(1), m.group(2)))
return {'kind': 'range', 'unit': unit, 'start': a, 'end': b, 'is_current': b >= today}
return None
def _available_chart_periods() -> dict:
"""스냅샷에 데이터가 있는 연도·연월 목록 (연도별/월별 드롭다운 채움용). 최신이 앞.
반환: {'years': ['2026','2025',...], 'months': {'2026': ['06','05',...], ...}}"""
@@ -5063,10 +5127,13 @@ def _available_chart_periods() -> dict:
return empty
years: set = set()
months: dict = {}
for date_key in snap.keys():
first_date = ''
for date_key in list(snap.keys()) + list(_load_nw_history().keys()):
if len(date_key) < 7 or date_key[4] != '-':
continue
y, m = date_key[:4], date_key[5:7]
if not first_date or date_key < first_date:
first_date = date_key[:10]
years.add(y)
months.setdefault(y, set()).add(m)
# 현재 연·월은 스냅샷이 아직 없어도(월초 EOD 적재 전) 드롭다운에 노출 — 지정기간 선택 가능하게.
@@ -5077,6 +5144,7 @@ def _available_chart_periods() -> dict:
return {
'years': sorted(years, reverse=True),
'months': {y: sorted(ms, reverse=True) for y, ms in months.items()},
'first_date': first_date, # 기간설정 입력칸 min
}
@@ -5110,13 +5178,26 @@ def _load_net_worth_series(owner: str, days: int = NET_WORTH_CHART_DAYS, unit: s
continue
eval_total = sum(int(v.get('eval_value', 0) or 0) for v in holdings.values())
daily_series.append((date_key, eval_total + deposit))
# 스냅샷 이전 구간은 history(키움 kt00002)로 채운다 — 있으면 그 구간의 옛 형식 스냅샷(예수금 없음)은 버린다.
_first, _hist = _history_points(snap, owner)
if _hist:
daily_series = [(d, int(r['net'])) for d, r in _hist] + [s_ for s_ in daily_series if _first is None or s_[0] >= _first]
aggregated = _aggregate_by_unit(daily_series, unit)
range_start: str | None = None # 기간 필터의 시작 경계 — fallback도 이 안에서만
# filtered 에 baseline 점이 prepend 되었으면 그게 series[0] = PnL baseline 역할 → prev_baseline 별도 안 넘김
baseline_prepended = False
if period: # 절대 선택 — 'YYYY'(연도별·월별집계) 또는 'YYYY-MM'(월별·일별). caller가 unit을 맞춰 호출.
_p = _parse_chart_ym(period) if period else None
if _p and _p['kind'] == 'range': # 기간설정 — 시작·끝 포함. caller가 unit을 맞춰 호출.
if unit == 'month':
# 월 대표점은 그 달 마지막 영업일 날짜라 끝 경계는 월 문자열(d[:7])로 비교해야 끝 달이 포함된다.
range_start = _p['start'] + '-01'
filtered = [(d, v) for d, v in aggregated if range_start <= d and d[:7] <= _p['end']]
else:
range_start = _p['start']
filtered = [(d, v) for d, v in aggregated if _p['start'] <= d <= _p['end']]
elif period: # 절대 선택 — 'YYYY'(연도별·월 단위) 또는 'YYYY-MM'(월별·일 단위). caller가 unit을 맞춰 호출.
range_start = (period + '-01-01') if len(period) == 4 else (period + '-01')
filtered = [(d, v) for d, v in aggregated if d.startswith(period)]
elif days == -2: # 이번달 — KST 기준 이번 달 1일 이후만
@@ -5266,7 +5347,7 @@ def _expand_progress_group(filtered: list[tuple[str, int]], daily_series: list[t
return baseline + in_group
def _render_net_worth_chart(series: list[tuple[str, int]], current_net: int | None = None, selected_days: int = NET_WORTH_CHART_DAYS, selected_unit: str = NET_WORTH_CHART_UNIT, cash_flow_by_date: dict | None = None, today_cash_flow: dict | None = None, mode: str = NET_WORTH_CHART_MODE, prev_baseline_point: tuple[str, int] | None = None, rt_owner: str | None = None, rt_base_net: int | None = None) -> str:
def _render_net_worth_chart(series: list[tuple[str, int]], current_net: int | None = None, selected_days: int = NET_WORTH_CHART_DAYS, selected_unit: str = NET_WORTH_CHART_UNIT, cash_flow_by_date: dict | None = None, today_cash_flow: dict | None = None, mode: str = NET_WORTH_CHART_MODE, prev_baseline_point: tuple[str, int] | None = None, rt_owner: str | None = None, rt_base_net: int | None = None, period_label: str = '') -> str:
"""꺾은선 SVG (순자산 / 손익누적 공용). 외부 의존성 없음. 데이터 < 2점이면 안내문 반환.
mode='net'(기본): series 는 raw 순자산 시계열. 입출금 마커·툴팁 행 표시.
@@ -5508,7 +5589,9 @@ def _render_net_worth_chart(series: list[tuple[str, int]], current_net: int | No
title_n = len(series)
_unit_word = {'week': '주', 'month': '월', 'year': '연'}.get(selected_unit, '영업일')
_title_metric = '손익' if per_period else ('손익누적' if is_pnl else '총자산')
title = f'최근 {title_n}{_unit_word} {_title_metric}' + (' · 오늘 잠정' if has_live else '')
# 지정기간은 '최근 N월'(N개월인지 N월인지 모호) 대신 선택한 기간 자체를 제목으로.
_title_head = period_label or f'최근 {title_n}{_unit_word}'
title = f'{_title_head} {_title_metric}' + (' · 오늘 잠정' if has_live else '')
# 영역 채우기는 종가 구간만 (실선 영역). 점선 구간은 area 미적용.
area_d = ''
@@ -7352,25 +7435,34 @@ table.adr-info-table td.adr-breakdown { font-size: 11px; color: #8b8f9a; white-s
.calc-line.he-detail .calc-lbl { font-size: 12px; }
.calc-line.he-detail .calc-val { font-size: 14px; font-weight: 600; color: #c9ccd3; }
/* 차트 컨트롤 — 자산정보 탭 상단의 공통 기간·단위 select 묶음. */
.chart-controls { display: flex; flex-direction: column; gap: 6px; margin: 10px 14px 4px; }
.chart-controls .cc-line { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
.chart-controls .cc-line-ym[hidden] { display: none; } /* 지정기간 아닐 때 2번째 줄 숨김 ([hidden] UA 스타일이 flex에 가려지지 않도록 명시) */
.chart-controls .cc-field { display: flex; align-items: center; gap: 6px; }
.chart-controls .cc-label { color: #8b8f9a; font-size: 11px; }
.chart-controls .nw-select {
appearance: none; -webkit-appearance: none;
background: #11141c url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'><path fill='%238b8f9a' d='M2 4l4 4 4-4'/></svg>") no-repeat right 8px center;
background-size: 10px;
color: #d6d8dd; border: 1px solid #2a2e36; border-radius: 8px;
padding: 6px 26px 6px 10px; font-size: 12px; cursor: pointer; font-family: inherit;
min-width: 96px;
/* 차트 컨트롤 — [연도별▾] [월별▾] [기간설정] [자산형태▾] 한 줄 4칸.
보이는 건 .cc-txt(작은 글씨), 실제 select 는 위에 투명하게 덮어 탭을 받는다(iOS 16px 줌 회피). */
.chart-controls { margin: 10px 14px 4px; }
.chart-controls .cc-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.4fr) minmax(0, 1fr); gap: 4px; max-width: 440px; } /* 기간설정 칸은 "08.14~09.03" 이 들어가게 넓게 */
.chart-controls .cc-pick {
position: relative; display: flex; align-items: center; justify-content: center; min-width: 0; box-sizing: border-box;
height: 28px; padding: 0 6px; background: #11141c; border: 1px solid #2a2e36; border-radius: 7px;
color: #8b8f9a; font-size: 11px; font-family: inherit; cursor: pointer;
}
.chart-controls .nw-select:focus { outline: 1px solid #4a4f5a; outline-offset: 0; }
/* 2번째 줄(유형/연도/월) — 연도/월은 내용 크기로(월 줄바뀜 방지), 유형은 기간과 동일 폭. 글씨는 1번째 줄과 동일. */
.chart-controls .cc-line-ym { gap: 8px; }
.chart-controls .cc-line-ym .nw-select { min-width: 0; }
.chart-controls .cc-line-ym .nw-select[data-nw-ymtype] { min-width: 96px; } /* 유형 = 기간 select 와 동일 폭 */
.chart-controls .cc-dd { padding-right: 15px; }
.chart-controls .cc-dd::after { content: ""; position: absolute; right: 6px; top: 50%; margin-top: -2px; border: 3.5px solid transparent; border-top: 4px solid #8b8f9a; pointer-events: none; }
.chart-controls .cc-txt { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; pointer-events: none; }
.chart-controls .cc-pick .nw-select { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; font-size: 16px; cursor: pointer; -webkit-appearance: none; appearance: none; }
.chart-controls .cc-pick:has(select[data-nw-mode]) { color: #d6d8dd; } /* 자산형태는 항상 값이 있다 */
/* 지금 적용 중인 기간 컨트롤 — 나머지 둘은 이름만 흐리게. */
.chart-controls .cc-pick.cc-active { color: #f2f3f5; border-color: #ff4d5e; font-weight: 600; }
.chart-controls .cc-pick:focus-within { outline: 1px solid #4a4f5a; outline-offset: 0; }
@media (max-width: 340px) { /* 320px 폰 — 여백만 줄여 '2026.10' 이 잘리지 않게 */
.chart-controls .cc-pick { padding: 0 4px; }
.chart-controls .cc-dd { padding-right: 12px; }
.chart-controls .cc-dd::after { right: 4px; }
}
/* 기간설정 팝업 */
.range-form { display: flex; flex-direction: column; gap: 10px; margin-top: 4px; }
.range-row { display: flex; align-items: center; gap: 10px; font-size: 13px; color: #8b8f9a; }
.range-row > span { flex: 0 0 34px; }
.range-in { flex: 1 1 auto; min-width: 0; box-sizing: border-box; background: #11141c; color: #d6d8dd; border: 1px solid #2a2e36; border-radius: 8px; padding: 9px 10px; font-size: 14px; font-family: inherit; color-scheme: dark; }
.range-err { min-height: 16px; font-size: 12px; color: #ff6b6b; }
@media (max-width: 600px) {
nav.tabs .tab-label { padding: 10px 12px; font-size: 12px; }
@@ -7550,7 +7642,7 @@ input[data-order-qty].qty-over { color: #ff8a95; }
@media (pointer: coarse) {
input, select, textarea { font-size: 16px; }
.igrp-select, .group-name-input, .info-search-input, .info-symbols-select,
.order-symbol-select, .chart-controls .nw-select { font-size: 16px; }
.order-symbol-select, .chart-controls .nw-select, .range-in { font-size: 16px; }
}
'''
@@ -7568,86 +7660,62 @@ def _owner_full_title(owner: str) -> str:
return OWNER_LABELS.get(owner, f'{owner} 자산')
def _render_chart_controls(selected_days: int, selected_unit: str, selected_mode: str = NET_WORTH_CHART_MODE, chart_ym: str = '', avail: dict | None = None) -> str:
"""차트용 공통 컨트롤 — 기간 / 단위 / 모드(순자산·손익누적·기간손익) select.
기간에 '지정기간' 추가 → 유형(연도별/월별) + 연도 + 월(월별일 때) 보조 드롭다운.
period(chart_ym) 활성 시 단위는 강제·비활성. 의미없는 단위(이번주+월별 등)는 disabled."""
def _render_chart_controls(selected_mode: str = NET_WORTH_CHART_MODE, chart_ym: str = '', avail: dict | None = None) -> str:
"""차트 컨트롤 한 줄 — [연도별▾] [월별▾] [기간설정] [자산형태▾].
연도별=그 해를 월 단위 / 월별=그 달을 일 단위 / 기간설정=팝업(range-modal)에서 단위·시작·종료.
셋 중 지금 적용된 것만 값이 보이고(cc-active 강조), 나머지는 이름(placeholder)만 보인다."""
avail = avail or {'years': [], 'months': {}}
period = (chart_ym or '').strip()
is_year = len(period) == 4
is_month = len(period) == 7
period_active = is_year or is_month
_ym = _parse_chart_ym(chart_ym)
kind = _ym['kind'] if _ym else ''
years = avail.get('years') or []
months_map = avail.get('months') or {}
allowed_units = NET_WORTH_ALLOWED_UNITS_BY_DAYS.get(
selected_days, tuple(u for _, u in NET_WORTH_UNIT_PRESETS)
)
range_opts = ''.join(
f'<option value="{d}"{" selected" if (not period_active and d == selected_days) else ""}>{html.escape(lbl)}</option>'
for lbl, d in NET_WORTH_CHART_PRESETS
)
range_opts += f'<option value="custom"{" selected" if period_active else ""}>지정기간</option>'
def _ph(label: str, active: bool) -> str:
# 비활성일 때 이름을 보여주는 placeholder — 선택 불가라 목록에서 다시 고를 수 없다.
return f'<option value="" disabled{"" if active else " selected"}>{label}</option>'
# 유형: 연도별(year) / 월별(month) — period_active 일 때만 노출.
type_opts = (
f'<option value="year"{" selected" if not is_month else ""}>연도별</option>'
f'<option value="month"{" selected" if is_month else ""}>월별</option>'
)
cur_year = period[:4] if period_active else (years[0] if years else '')
cur_month = period[5:7] if is_month else ''
year_opts = ''.join(
f'<option value="{y}"{" selected" if y == cur_year else ""}>{html.escape(y)}년</option>'
year_opts = _ph('연도별', kind == 'year') + ''.join(
f'<option value="{y}"{" selected" if (kind == "year" and chart_ym == y) else ""}>{y}년</option>'
for y in years
)
# 월 — 월별일 때만 실제 월 목록(활성), 연도별이면 '-' 한 칸 비활성.
if is_month:
month_opts = ''.join(
f'<option value="{m}"{" selected" if m == cur_month else ""}>{int(m)}월</option>'
for m in months_map.get(cur_year, [])
)
_month_dis = ''
else:
month_opts = '<option value="">-</option>'
_month_dis = ' disabled'
_customhide = '' if period_active else ' hidden'
months_json = html.escape(json.dumps(months_map, ensure_ascii=False), quote=True)
# 지정기간은 단위 일별 고정 → 단위 select 비활성, '일별' 선택 표시.
unit_opts = ''.join(
f'<option value="{u}"'
f'{" selected" if u == selected_unit else ""}'
f'{" disabled" if u not in allowed_units else ""}>'
f'{html.escape(lbl)}</option>'
for lbl, u in NET_WORTH_UNIT_PRESETS
ym_opts = _ph('월별', kind == 'month') + ''.join(
f'<option value="{y}-{m}"{" selected" if (kind == "month" and chart_ym == f"{y}-{m}") else ""}>{y}년 {int(m)}월</option>'
for y in years for m in months_map.get(y, [])
)
if kind == 'range':
_a, _b = _ym['start'], _ym['end']
if _ym['unit'] == 'day': # 같은 해면 연도 생략해 짧게 (버튼 폭)
range_lbl = f'{_a[5:].replace("-", ".")}~{_b[5:].replace("-", ".")}' if _a[:4] == _b[:4] else f'{_a[2:].replace("-", ".")}~{_b[2:].replace("-", ".")}'
else:
range_lbl = f'{_a[2:].replace("-", ".")}~{_b[2:].replace("-", ".")}'
else:
range_lbl = '기간설정'
mode_opts = ''.join(
f'<option value="{m}"{" selected" if m == selected_mode else ""}>{html.escape(lbl)}</option>'
for lbl, m in NET_WORTH_MODE_PRESETS
)
_unit_dis = ' disabled' if period_active else ''
# 1번째 줄: 기간 / 단위 / 차트타입(모드). 2번째 줄(지정기간일 때만): 유형 / 연도 / 월.
line1 = (
f'<div class="cc-line">'
f'<label class="cc-field"><span class="cc-label">기간</span>'
f'<select class="nw-select" data-nw-range>{range_opts}</select></label>'
f'<label class="cc-field cc-unit"><span class="cc-label">단위</span>'
f'<select class="nw-select" data-nw-unit{_unit_dis}>{unit_opts}</select>'
f'<select class="nw-select nw-mode-inline" data-nw-mode aria-label="차트 모드">{mode_opts}</select></label>'
f'</div>'
def _act(k: str) -> str:
return ' cc-active' if kind == k else ''
# 한 줄 4칸 — 보이는 건 작은 글씨 라벨(cc-txt), 실제 select 는 그 위에 투명하게 겹친다.
# select 를 직접 작게 하면 iOS 가 16px 미만 폼 포커스 시 화면을 확대하므로 select 자체는 16px 유지.
year_txt = f'{chart_ym}년' if kind == 'year' else '연도별'
ym_txt = chart_ym.replace('-', '.') if kind == 'month' else '월별'
mode_txt = next((lbl for lbl, m in NET_WORTH_MODE_PRESETS if m == selected_mode), '')
def _pick(k: str, txt: str, sel_html: str) -> str:
return f'<label class="cc-pick cc-dd{_act(k) if k else ""}"><span class="cc-txt">{html.escape(txt)}</span>{sel_html}</label>'
return (
'<div class="chart-controls"><div class="cc-grid">'
+ _pick('year', year_txt, f'<select class="nw-select" data-nw-year aria-label="연도별">{year_opts}</select>')
+ _pick('month', ym_txt, f'<select class="nw-select" data-nw-ym aria-label="월별">{ym_opts}</select>')
+ f'<button type="button" class="cc-pick{_act("range")}" data-nw-range-open'
f' data-first="{html.escape(avail.get("first_date") or "")}" aria-label="기간설정"><span class="cc-txt">{html.escape(range_lbl)}</span></button>'
+ _pick('', mode_txt, f'<select class="nw-select" data-nw-mode aria-label="자산형태">{mode_opts}</select>')
+ '</div></div>'
)
line2 = (
f'<div class="cc-line cc-line-ym"{_customhide}>'
f'<label class="cc-field"><span class="cc-label">유형</span>'
f'<select class="nw-select" data-nw-ymtype>{type_opts}</select></label>'
f'<label class="cc-field"><span class="cc-label">연도</span>'
f'<select class="nw-select" data-nw-year>{year_opts}</select></label>'
f'<label class="cc-field"><span class="cc-label">월</span>'
f'<select class="nw-select" data-nw-month{_month_dis}>{month_opts}</select></label>'
f'</div>'
)
return f'<div class="chart-controls" data-ym-months="{months_json}">{line1}{line2}</div>'
def _format_signed_billion(v: int | None) -> tuple[str, str]:
@@ -8476,19 +8544,29 @@ def _render_summary_panel(ordered_owners: list[str], owner_data: dict, balances:
balances = balances or {}
journal_by_label = journal_by_label or {}
# 연도별/월별 절대 선택 — chart_ym('YYYY'=연도별·월별집계 / 'YYYY-MM'=월별·일별) 있으면 단위 강제 + period 필터.
period = (chart_ym or '').strip()
eff_unit = 'day' if period else chart_unit # 지정기간(연도별·월별)은 단위 일별 고정.
# 절대 선택이 과거 기간(이번달·올해가 아님)이면 오늘 라이브 점·라이브 갱신을 끈다 —
# 지정기간 — chart_ym 형식이 단위를 정한다(_parse_chart_ym): 연도별=월 / 월별=일 / 기간설정=선택 단위.
# 프리셋(전체보기·올해·이번달·이번주) UI 는 2026-10-02 제거 — 미지정이면 이번 달(월별)이 기본.
if not _parse_chart_ym(chart_ym):
chart_ym = datetime.now(KST).strftime('%Y-%m')
_ym = _parse_chart_ym(chart_ym)
period = (chart_ym or '').strip() if _ym else ''
eff_unit = _ym['unit'] if _ym else chart_unit
# 절대 선택이 오늘을 포함하지 않으면 오늘 라이브 점·라이브 갱신을 끈다 —
# 과거 차트(예: 4월)에 today 순자산이 마지막 점·델타·하단값으로 섞이는 것 방지. 프리셋은 항상 today 포함.
_now_dt = datetime.now(KST)
period_is_current = True
if period:
period_is_current = (period == _now_dt.strftime('%Y')) if len(period) == 4 else (period == _now_dt.strftime('%Y-%m'))
period_is_current = _ym['is_current'] if _ym else True
period_label = ''
if _ym:
_a, _b = _ym['start'].replace('-', '.'), _ym['end'].replace('-', '.')
if _ym['kind'] == 'year':
period_label = f'{_a}년'
elif _ym['kind'] == 'month':
period_label = f'{_a[:4]}년 {int(_a[5:7])}월'
else:
period_label = f'{_a}~{_b}'
consolidated_parts: list[str] = []
by_account_parts: list[str] = []
chart_parts: list[str] = [_render_chart_controls(chart_days, eff_unit, chart_mode, chart_ym=period, avail=_available_chart_periods())]
chart_parts: list[str] = [_render_chart_controls(chart_mode, chart_ym=period, avail=_available_chart_periods())]
for owner in ordered_owners:
d = owner_data.get(owner)
if not d:
@@ -8543,6 +8621,7 @@ def _render_summary_panel(ordered_owners: list[str], owner_data: dict, balances:
mode=chart_mode,
prev_baseline_point=prev_baseline,
rt_owner=(owner if period_is_current else None), rt_base_net=d.get('total_net'),
period_label=period_label,
))
else:
# net 모드도 baseline 점(예: 5/1) prepend → 첫 영업일 변동이 0 기점에서 분기되는 모양. baseline 구간은 점선.
@@ -8556,6 +8635,7 @@ def _render_summary_panel(ordered_owners: list[str], owner_data: dict, balances:
mode='net',
prev_baseline_point=prev_baseline,
rt_owner=(owner if period_is_current else None), rt_base_net=d.get('total_net'),
period_label=period_label,
))
market_card = _render_market_indicators_card()
@@ -8754,6 +8834,32 @@ def _render_note_modal() -> str:
)
def _render_range_modal() -> str:
"""차트보기 '기간설정' 팝업 — 단위(일별/월별) + 시작·종료. 입력 type·min·max 는 열 때 JS 가 채운다."""
return (
'<div id="range-modal" class="modal hidden" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="range-modal-title">'
'<div class="modal-overlay" data-modal-close="1"></div>'
'<div class="modal-box" role="document">'
'<div class="modal-head">'
'<div class="modal-title" id="range-modal-title">기간설정</div>'
'<button type="button" class="modal-close" data-modal-close="1" aria-label="닫기">×</button>'
'</div>'
'<div class="range-form">'
'<label class="range-row"><span>단위</span><select class="range-in" data-range-unit>'
'<option value="day">일별</option><option value="month">월별</option></select></label>'
'<label class="range-row"><span>시작</span><input type="date" class="range-in" data-range-from></label>'
'<label class="range-row"><span>종료</span><input type="date" class="range-in" data-range-to></label>'
'<div class="range-err" data-range-err></div>'
'</div>'
'<div class="modal-actions">'
'<button type="button" class="btn-cancel" data-modal-close="1">취소</button>'
'<button type="button" class="btn-add" data-range-apply>적용</button>'
'</div>'
'</div>'
'</div>'
)
def _render_calc_modal() -> str:
"""등락률 계산기 모달. ☰ 옵션 팝업의 '계산기' 항목이 연다.
기준가·현재가를 입력하면 등락률(%)과 차이 금액을 클라이언트에서 즉시 계산.
@@ -9705,12 +9811,11 @@ def render_html() -> str:
'if(v!==7&&v!==30&&v!==90&&v!==180&&v!==365&&v!==-1)return null;'
'return v;'
'}'
# chart_ym — 연도별('YYYY')/월별('YYYY-MM') 절대 선택. 빈값이면 프리셋(chartDays) 사용.
# chart_ym — 지정기간 절대 선택(서버 _parse_chart_ym 과 같은 4형식). 빈값이면 프리셋(chartDays) 사용.
'function chartYM(){'
'var v=localStorage.getItem("behive.chartYM")||"";'
'var p=v.split("-");'
'if(p.length===1&&/^\\d{4}$/.test(p[0]))return v;'
'if(p.length===2&&/^\\d{4}$/.test(p[0])&&/^\\d{2}$/.test(p[1]))return v;'
'if(/^\\d{4}(-\\d{2})?$/.test(v))return v;'
'if(/^\\d{4}-\\d{2}-\\d{2}~\\d{4}-\\d{2}-\\d{2}$/.test(v)||/^\\d{4}-\\d{2}~\\d{4}-\\d{2}$/.test(v))return v;'
'return "";'
'}'
'function setYM(v){try{if(v)localStorage.setItem("behive.chartYM",v);else localStorage.removeItem("behive.chartYM");}catch(_){}}'
@@ -9720,6 +9825,7 @@ def render_html() -> str:
'window.__behive_adr_days=adrDays;'
'window.__behive_chart_ym=chartYM;'
'window.__behive_set_ym=setYM;'
'function chartSig(){return [chartDays(),chartUnit(),chartMode(),adrDays(),chartYM()].join("|");}'
'function load(owner,fresh){'
# 5분 이상 stale 면 전체로 승격하되, 화면은 클리어하지 않고 기존 내용 유지 → fetch 완료 시 교체(swap).
'if(lastLoadAt&&(Date.now()-lastLoadAt)>STALE_MS){owner=null;}'
@@ -9732,9 +9838,11 @@ def render_html() -> str:
'var ad=adrDays();if(ad!==null)qs.push("adr_days="+ad);'
'var ym=chartYM();if(ym)qs.push("chart_ym="+encodeURIComponent(ym));'
'var url="/api/panels"+(qs.length?("?"+qs.join("&")):"");'
# 차트 설정이 요청 뒤에 바뀌었으면 응답 폐기 — 옛 설정 응답이 늦게 도착해 새 차트를 덮어쓰는 경합 차단(새 설정 요청이 따로 그린다).
'var sig=chartSig();'
'var pr=fetch(url,{cache:"no-store",credentials:"same-origin"})'
'.then(function(r){if(!r.ok)throw new Error("HTTP "+r.status);return r.json();})'
'.then(function(j){apply(j);lastLoadAt=Date.now();})'
'.then(function(j){if(chartSig()!==sig)return;apply(j);lastLoadAt=Date.now();})'
'.catch(function(){failVisible("갱신 실패 — 다시 시도해주세요");});'
'var done=function(){if(!fresh)delete inFlight[key];};'
'pr.finally?pr.finally(done):pr.then(done,done);'
@@ -10169,46 +10277,67 @@ def render_html() -> str:
'try{localStorage.setItem("behive.adrGraphOpen",open?"1":"0");}catch(_){}'
'applyAdrGraph(open);'
'});'
# select 드롭다운(기간·단위) 변경 핸들러 — 자산정보 탭 상단의 공통 컨트롤.
# chip 흐름과 동일한 localStorage 키 사용 → 백엔드 변경 없음.
# 차트 컨트롤 — [연도별▾] [월별▾] [기간설정] [자산형태▾]. 값은 chart_ym(localStorage)·chartMode 로 저장 후 패널 재조회.
'function __nwReload(){var ow=window.__behive_active_owner?window.__behive_active_owner():null;if(window.__behive_load)window.__behive_load(ow,true);}'
'document.addEventListener("change",function(e){'
'var sel=e.target;'
'if(!sel||sel.tagName!=="SELECT"||!sel.classList||!sel.classList.contains("nw-select"))return;'
'function ymFirstMonth(y){var cc=document.querySelector(".chart-controls");try{var mp=JSON.parse(cc.getAttribute("data-ym-months")||"{}");var a=mp[y]||[];return a.length?a[0]:"";}catch(_){return "";}}'
'function ymSet(tp,y){if(!y)return;if(tp==="month"){var mm=ymFirstMonth(y);window.__behive_set_ym(mm?(y+"-"+mm):y);}else{window.__behive_set_ym(y);}}'
'function curYear(){var e=document.querySelector(\'select.nw-select[data-nw-year]\');return e&&e.value?e.value:"";}'
'function curType(){var e=document.querySelector(\'select.nw-select[data-nw-ymtype]\');return e&&e.value?e.value:"year";}'
'if(sel.hasAttribute("data-nw-range")){'
'var rv=sel.value;'
'if(rv==="custom"){'
# 지정기간 진입 — 유형(기본 연도별)·연도(기본 최신)로 chartYM 세팅.
'var y=curYear();if(!y)return;' # 데이터 없음
'ymSet(curType(),y);'
'}else{'
'window.__behive_set_ym("");' # 프리셋 → 절대선택 해제
'try{localStorage.setItem("behive.chartDaysV2",rv);}catch(_){}'
# 기간 변경 시 현재 단위가 비허용이면 첫 허용으로 자동 보정 (서버 sanity와 일치).
'var allowedMap={"-3":["day","week"],"-2":["day","week","month"],"-4":["day","week","month","year"],"-1":["day","week","month","year"]};'
'var allowed=allowedMap[rv]||["day","week","month","year"];'
'var unitSel=document.querySelector(\'select.nw-select[data-nw-unit]\');'
'var curU=unitSel?unitSel.value:"day";'
'if(allowed.indexOf(curU)<0){try{localStorage.setItem("behive.chartUnit",allowed[0]);}catch(_){}}'
'}'
'}else if(sel.hasAttribute("data-nw-ymtype")){'
'ymSet(sel.value,curYear());'
'}else if(sel.hasAttribute("data-nw-year")){'
'ymSet(curType(),sel.value);'
'}else if(sel.hasAttribute("data-nw-month")){'
'var ry=document.querySelector(\'select.nw-select[data-nw-year]\');'
'window.__behive_set_ym((ry?ry.value:"")+"-"+sel.value);'
'}else if(sel.hasAttribute("data-nw-unit")){'
'try{localStorage.setItem("behive.chartUnit",sel.value);}catch(_){}'
'if(!sel||!sel.classList||!sel.classList.contains("nw-select"))return;'
'if(sel.hasAttribute("data-nw-year")||sel.hasAttribute("data-nw-ym")){'
'if(!sel.value)return;window.__behive_set_ym(sel.value);'
'document.querySelectorAll(".chart-controls .cc-pick").forEach(function(p){p.classList.remove("cc-active");});'
'sel.parentNode.classList.add("cc-active");'
'}else if(sel.hasAttribute("data-nw-mode")){'
'try{localStorage.setItem("behive.chartMode",sel.value);}catch(_){}'
'}else{return;}'
'var ow=window.__behive_active_owner?window.__behive_active_owner():null;'
'if(window.__behive_load)window.__behive_load(ow,true);'
# 재조회 응답 전까지도 고른 값이 보이게 라벨 즉시 갱신.
'var tx=sel.parentNode.querySelector(".cc-txt"),so=sel.selectedOptions&&sel.selectedOptions[0];'
'if(tx&&so)tx.textContent=sel.hasAttribute("data-nw-ym")?sel.value.replace("-","."):so.textContent;'
'__nwReload();'
'});'
# 기간설정 팝업(range-modal, 셸 직속) — 단위(일별/월별)에 따라 입력칸이 date↔month 로 바뀐다.
'(function(){'
'function p2(n){return (n<10?"0":"")+n;}'
'function todayStr(){var d=new Date();return d.getFullYear()+"-"+p2(d.getMonth()+1)+"-"+p2(d.getDate());}'
'function lastDay(ym){var a=ym.split("-");var d=new Date(+a[0],+a[1],0);return ym+"-"+p2(d.getDate());}'
'function q(s){var m=document.getElementById("range-modal");return m?m.querySelector(s):null;}'
# 단위 적용 — 입력 type·min·max 교체 + 기존 값 변환(일→월: 연-월로 자름 / 월→일: 1일~말일, 오늘 상한).
'function setUnit(u,a,b){'
'var fi=q("[data-range-from]"),ti=q("[data-range-to]"),m=document.getElementById("range-modal");'
'var first=m.getAttribute("data-first")||"",t=todayStr();'
'if(u==="month"){a=a?a.slice(0,7):"";b=b?b.slice(0,7):"";}'
'else{if(a&&a.length===7)a=a+"-01";if(b&&b.length===7){var e2=lastDay(b);b=e2>t?t:e2;}}'
'[fi,ti].forEach(function(el){el.type=(u==="month"?"month":"date");'
'el.min=(u==="month"?first.slice(0,7):first);el.max=(u==="month"?t.slice(0,7):t);});'
'fi.value=a||"";ti.value=b||"";'
'q("[data-range-unit]").value=u;'
'}'
'document.addEventListener("click",function(e){'
'var t=e.target;'
'var ob=t.closest&&t.closest("[data-nw-range-open]");'
'if(ob){e.preventDefault();'
'var m=document.getElementById("range-modal");if(!m)return;'
'm.setAttribute("data-first",ob.getAttribute("data-first")||"");'
'var ym=(window.__behive_chart_ym&&window.__behive_chart_ym())||"",u="day",a="",b="";'
'if(ym.indexOf("~")>0){var pr=ym.split("~");a=pr[0];b=pr[1];u=a.length===7?"month":"day";}'
'else{var td=todayStr();a=td.slice(0,8)+"01";b=td;}' # 기본: 이번 달 1일 ~ 오늘
'setUnit(u,a,b);q("[data-range-err]").textContent="";'
'if(window.__behiveOpenModal)window.__behiveOpenModal("range-modal");'
'return;'
'}'
'if(t.closest&&t.closest("[data-range-apply]")){e.preventDefault();'
'var a2=q("[data-range-from]").value,b2=q("[data-range-to]").value;'
'if(!a2||!b2){q("[data-range-err]").textContent="시작과 종료를 모두 선택해 주세요.";return;}'
'if(a2>b2){var x=a2;a2=b2;b2=x;}'
'window.__behive_set_ym(a2+"~"+b2);'
'if(window.__behiveCloseModal)window.__behiveCloseModal(document.getElementById("range-modal"));'
'__nwReload();'
'}'
'});'
'document.addEventListener("change",function(e){'
'var s=e.target;if(!s||!s.hasAttribute||!s.hasAttribute("data-range-unit"))return;'
'setUnit(s.value,q("[data-range-from]").value,q("[data-range-to]").value);'
'});'
'})();'
'function scan(){document.querySelectorAll(".net-chart[data-pts]").forEach(init);restoreSubs();restoreAccountViews();restoreIgrp();restoreAdrGraph();}'
'if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",scan);'
'else scan();'
@@ -11289,6 +11418,7 @@ def render_html() -> str:
interest_edit_modal_html = _render_interests_edit_modal()
tag_modal_html = _render_tag_modal()
note_modal_html = _render_note_modal()
range_modal_html = _render_range_modal()
calc_modal_html = _render_calc_modal()
avg_calc_modal_html = _render_avg_calc_modal()
holdeval_modal_html = _render_holdeval_modal()
@@ -11336,6 +11466,7 @@ def render_html() -> str:
'clearFeedback(m);'
'unlockScroll();'
'}'
'window.__behiveOpenModal=openModal;window.__behiveCloseModal=closeModal;' # 차트 기간설정 팝업(별도 스크립트)용
'function clearFeedback(m){var fb=m&&m.querySelector("[data-feedback]");if(!fb)return;'
'fb.hidden=true;fb.innerHTML="";'
'}'
@@ -14808,6 +14939,7 @@ window.openPinModal = openPinModal;
{interest_edit_modal_html}
{tag_modal_html}
{note_modal_html}
{range_modal_html}
{calc_modal_html}
{avg_calc_modal_html}
{holdeval_modal_html}
@@ -15958,14 +16090,9 @@ class Handler(BaseHTTPRequestHandler):
adr_days = ADR_TREND_DAYS
if adr_days not in {d for _, d in ADR_TREND_PRESETS}:
adr_days = ADR_TREND_DAYS
# chart_ym — 'YYYY'(연도별) 또는 'YYYY-MM'(월별). 형식 위반은 무시(빈값=프리셋).
# chart_ym — 지정기간(_parse_chart_ym 형식 4종). 형식 위반은 무시(빈값=프리셋).
chart_ym = (qs.get('chart_ym') or [''])[0]
_ymp = (chart_ym or '').split('-')
_ym_ok = (
(len(_ymp) == 1 and len(_ymp[0]) == 4 and _ymp[0].isdigit())
or (len(_ymp) == 2 and len(_ymp[0]) == 4 and _ymp[0].isdigit() and len(_ymp[1]) == 2 and _ymp[1].isdigit())
)
if not _ym_ok:
if not _parse_chart_ym(chart_ym):
chart_ym = ''
try:
body = _get_cached_panels_json(owner=owner, fresh=fresh, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days, chart_ym=chart_ym)
@@ -0,0 +1,128 @@
#!/usr/bin/env python3
"""자산웹 손익차트 과거 구간 채우기 — 키움 kt00002(일별추정예탁자산현황) → state/net_worth_history.json.
portfolio_daily_snapshot 은 2026-05-01 부터만 쓸 수 있는 형식이라(4월은 예수금 없는 옛 형식) 그 이전
차트가 비어 있다. 키움 계좌별 일별 추정예탁자산으로 그 앞 구간을 채운다. 조회 전용, 1회성 CLI.
- 조회기간 한 번에 6개월 미만(`501067`) → 170일 창으로 쪼갠다. 계좌당 3~4콜.
- kt00002 는 주말·휴장일도 행이 온다 → KOSPI 일별종가가 있는 날(sim index_history)만 남긴다.
market_holidays.json 은 2026년부터라 2025 휴장일을 못 거른다.
- 값 기준이 스냅샷과 다르다(KRX 종가 − 추정 매도비용 ≈0.18%, 스냅샷은 20:10 NXT 가격) →
겹치는 첫날(스냅샷 첫 날짜)의 비율로 owner 별 스케일을 맞춰 경계일 단차를 없앤다.
- 입출금은 investment_principal 캐시(kt00015, 키움 콜 0)에서 — 스냅샷 cash_in 과 같은 의미
(배당·이자 포함 전체 입출금).
CLI: python3 net_worth_backfill.py [--from YYYY-MM-DD] [--dry-run]
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
from datetime import date, datetime, timedelta
from pathlib import Path
from zoneinfo import ZoneInfo
sys.path.insert(0, str(Path(__file__).resolve().parent))
import investment_principal as ip # noqa: E402
import kiwoom_client as kc # noqa: E402
KST = ZoneInfo('Asia/Seoul')
STATE = Path(__file__).resolve().parent.parent / 'state'
SNAPSHOT_FILE = STATE / 'portfolio_daily_snapshot.json'
INDEX_FILE = STATE / 'sim' / 'index_history.json'
OUT_FILE = STATE / 'net_worth_history.json'
OWNERS = {'본인': ['일반', 'ISA'], '가희': ['가희_일반', '가희_ISA']}
WINDOW_DAYS = 170
def _first_snapshot_date(snap: dict, owner: str) -> str | None:
"""스냅샷에서 차트가 쓰는 형식(holdings+deposit)이 시작되는 날짜."""
return next((d for d in sorted(snap) if 'holdings' in ((snap.get(d) or {}).get(owner) or {})), None)
def _fetch_account(label: str, start: date, end: date) -> dict[str, int]:
"""{YYYY-MM-DD: 추정예탁자산}. 계좌 개설 전 구간은 '내역 없음' 에러라 빈 창으로 친다."""
out: dict[str, int] = {}
cur = start
while cur <= end:
w_end = min(cur + timedelta(days=WINDOW_DAYS), end)
try:
r = kc._call_paginated(label, 'kt00002',
{'start_dt': cur.strftime('%Y%m%d'), 'end_dt': w_end.strftime('%Y%m%d')},
'daly_prsm_dpst_aset_amt_prst')
for x in r.get('daly_prsm_dpst_aset_amt_prst') or []:
v = kc._to_int(x.get('prsm_dpst_aset_amt'))
if v:
d = x['dt']
out[f'{d[:4]}-{d[4:6]}-{d[6:]}'] = v
except RuntimeError as e:
if '506476' not in str(e): # 506476 = 내역 없음(개설 전)
raise
time.sleep(0.4)
cur = w_end + timedelta(days=1)
return out
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument('--from', dest='frm', default='2025-01-01')
ap.add_argument('--dry-run', action='store_true')
a = ap.parse_args()
snap = json.loads(SNAPSHOT_FILE.read_text())
idx = json.loads(INDEX_FILE.read_text()).get('KOSPI') or {}
trading = {f'{d[:4]}-{d[4:6]}-{d[6:]}' for d in idx}
start = date.fromisoformat(a.frm)
if f'{start:%Y-%m-%d}' < min(trading):
print(f'⚠️ 거래일 달력(KOSPI)이 {min(trading)} 부터라 그 이전은 거를 수 없다', file=sys.stderr)
return 1
days: dict[str, dict] = {}
scale: dict[str, float] = {}
for owner, labels in OWNERS.items():
first = _first_snapshot_date(snap, owner)
if not first:
print(f'{owner}: 스냅샷 없음 — 건너뜀', file=sys.stderr)
continue
end = date.fromisoformat(first) # 겹치는 첫날까지 받아 스케일 기준으로 쓴다
per_acct = {lb: _fetch_account(lb, start, end) for lb in labels}
# 기준일(스냅샷 첫날)은 휴장일일 수 있어(2026-05-01 근로자의 날) 거래일 필터는 저장 행에만 건다.
total = {d: sum(per_acct[lb].get(d, 0) for lb in labels) for d in set().union(*per_acct.values())}
o_snap = snap[first][owner]
snap_net = sum(int(h.get('eval_value', 0) or 0) for h in o_snap['holdings'].values()) + int(o_snap.get('deposit', 0) or 0)
if not total.get(first):
print(f'{owner}: 기준일 {first} kt00002 값 없음 — 중단', file=sys.stderr)
return 1
scale[owner] = snap_net / total[first]
flows: dict[str, dict] = {}
for f in ip.get_flows(labels)['rows']:
d = f'{f["date"][:4]}-{f["date"][4:6]}-{f["date"][6:]}'
k = 'cash_in' if f['io'] == 'IN' else 'cash_out'
flows.setdefault(d, {'cash_in': 0, 'cash_out': 0})[k] += int(f['amount'])
n = 0
for d in sorted(total):
if d >= first or d not in trading:
continue
rec = {'net': round(total[d] * scale[owner])}
rec.update(flows.get(d) or {})
days.setdefault(d, {})[owner] = rec
n += 1
print(f'{owner}: {n}일 ({min(days)} ~ {first} 직전), 스케일 {scale[owner]:.5f} (기준 {first} 스냅샷 {snap_net:,} / 키움 {total[first]:,})')
doc = {'source': 'kiwoom kt00002', 'fetched_at': datetime.now(KST).isoformat(timespec='seconds'),
'scale': scale, 'days': dict(sorted(days.items()))}
if a.dry_run:
print('dry-run — 저장 안 함')
return 0
tmp = OUT_FILE.with_suffix('.tmp')
tmp.write_text(json.dumps(doc, ensure_ascii=False, indent=1))
os.replace(tmp, OUT_FILE)
print(f'저장: {OUT_FILE} ({len(days)}일)')
return 0
if __name__ == '__main__':
sys.exit(main())