auto: 일일 백업 2026-08-06 02:00

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hyowons
2026-08-06 02:00:01 +09:00
parent c03b3f2659
commit 307f7844c4
161 changed files with 2822 additions and 2167 deletions
+61 -29
View File
@@ -1198,7 +1198,9 @@ def _build_owner_data(positions_by_acc: dict, balances: dict, journal_by_label:
# 예수금은 d2_entra(D+2 정산예수금) — 매수 즉시 차감/매도 즉시 가산되어
# 평가액 변동과 일관성 유지. entr은 D+2 결제 전까지 변하지 않아
# 신규 매수 시 손익이 매수원가만큼 부풀려지는 버그가 있다.
deposit = sum(balances.get(lb, {}).get('d2_entra', 0) for lb in lbls)
# 라벨별 dict 경유 — KPI 예수금 행의 [!] 버튼 노출 여부(계좌 2개 이상)를 여기서 판단한다.
deposit_by_label = {lb: (balances.get(lb, {}).get('d2_entra', 0) or 0) for lb in lbls}
deposit = sum(deposit_by_label.values())
total_net = total_value + deposit
traded_today = [r for r in consolidated if r.get('traded_today')]
@@ -1254,6 +1256,7 @@ def _build_owner_data(positions_by_acc: dict, balances: dict, journal_by_label:
'total_profit': total_profit,
'total_profit_rate': total_profit_rate,
'deposit': deposit,
'deposit_by_label': deposit_by_label,
'total_net': total_net,
'traded_today': traded_today,
'prev_net': prev_net,
@@ -1390,8 +1393,10 @@ def _fetch_all_data(entries: list[dict], only_owner: str | None = None) -> dict:
held_codes_set.add(_p['code'])
ohlc_map_krx: dict[str, dict] = {}
ohlc_map_nxt: dict[str, dict] = {}
ohlc_map_al: dict[str, dict] = {}
# KRX/NX 2콜은 가격 보정뿐 아니라 보유종목 행의 KRX·NXT 듀얼 OHLC 미니(r['ohlc_krx']·['ohlc_nxt'])
# 데이터원이다. _AL 허브는 거래소별 단일 최신값만 들어 두 컬럼을 동시에 못 주므로 skip 불가 — WS 연결과 무관하게 호출.
# AL(통합) 1콜은 요약 행 단일 캔들(r['ohlc']) 전용 — 시가는 NXT 프리마켓 시가, 고저는 두 시장 극값.
if held_codes_set:
try:
ohlc_map_krx = kc.get_watchlist_quotes(list(held_codes_set), exchange='KRX')
@@ -1401,6 +1406,10 @@ def _fetch_all_data(entries: list[dict], only_owner: str | None = None) -> dict:
ohlc_map_nxt = kc.get_watchlist_quotes(list(held_codes_set), exchange='NX')
except Exception as e:
sys.stderr.write(f'NXT positions price fetch failed: {e}\n')
try:
ohlc_map_al = kc.get_watchlist_quotes(list(held_codes_set), exchange='AL')
except Exception as e:
sys.stderr.write(f'AL positions price fetch failed: {e}\n')
_phase = _market_phase_state().get('phase')
active_map = ohlc_map_nxt if _phase == 'nxt' else (ohlc_map_krx if _phase == 'regular' else None)
if active_map:
@@ -1583,7 +1592,7 @@ def _fetch_all_data(entries: list[dict], only_owner: str | None = None) -> dict:
elif code in wl_nxt_map:
c['_pred_close_nxt'] = wl_nxt_map[code]
# 매수 미체결 묶임 금액을 balances에 라벨별로 주입 (KPI·계좌별 예수금 부연 표시용)
# 매수 미체결 묶임 금액을 balances에 라벨별로 주입 (합산·계좌별 KPI 예수금 부연 표시용)
for lb in balances:
balances[lb]['pending_buy_locked'] = pending_buy_lock_by_label.get(lb, 0)
@@ -1655,8 +1664,8 @@ def _fetch_all_data(entries: list[dict], only_owner: str | None = None) -> dict:
# 보유 종목 OHLC batch — 카드 펼치면 보이는 오늘 단일 캔들 + 자세히 보기 mini-table용.
# ohlc_map_krx (exchange='KRX')와 ohlc_map_nxt (exchange='NX'=NXT 단독)는 positions 보정에서 이미 호출 — 재사용.
# 각 컬럼은 그 시장 데이터로 정확히 채움 (서로 섞이지 않도록). primary 단일 캔들은 phase 우선.
if ohlc_map_krx or ohlc_map_nxt:
# 각 컬럼은 그 시장 데이터로 정확히 채움 (서로 섞이지 않도록). primary 단일 캔들은 통합(AL) 기준.
if ohlc_map_krx or ohlc_map_nxt or ohlc_map_al:
_phase = _market_phase_state().get('phase')
for od in owner_data.values():
for r in od.get('consolidated', []):
@@ -1679,9 +1688,15 @@ def _fetch_all_data(entries: list[dict], only_owner: str | None = None) -> dict:
'change': qn.get('change', 0), 'volume': qn.get('volume', 0),
}
# 단일 캔들 primary — 4값(open/high/low/price) 모두 있는 응답만 후보. 거래 없는 시간대 0값 응답 배제.
# AL(통합) 우선 — 시간대에 따라 KRX↔NXT 로 캔들이 뒤바뀌지 않고 하루 한 개로 유지된다.
# NXT 미거래 종목은 AL == KRX 라 결과가 같다. AL 응답이 불완전할 때만 phase 우선순위로 폴백.
qa = ohlc_map_al.get(code) if code else None
qk_full = qk_valid and qk.get('open') and qk.get('high') and qk.get('low')
qn_full = qn_valid and qn.get('open') and qn.get('high') and qn.get('low')
if _phase == 'regular':
qa_full = bool(qa and qa.get('price') and qa.get('open') and qa.get('high') and qa.get('low'))
if qa_full:
primary = qa
elif _phase == 'regular':
primary = qk if qk_full else (qn if qn_full else None)
else:
primary = qn if qn_full else (qk if qk_full else None)
@@ -3232,6 +3247,36 @@ def _render_principal_body(owner: str) -> str:
)
def _render_deposit_body(owner: str) -> str:
"""예수금 팝업 본문 — 계좌별 D+2 예수금. 렌더 경로가 이미 조회한 balances 를 읽어 키움 콜 0.
캐시를 쓰는 이유: 팝업 금액이 그걸 띄운 KPI 행과 항상 같고, 매수 묶임(ka10075 기반)
거기에만 들어 있다. 캐시가 비어 있으면(서버 재시작 직후) kt00001 계좌당 1 경로엔
매수 묶임이 없다.
"""
from stock_portfolio_report import derive_owner
import kiwoom_client as kc
bal_map = (_owner_data_cache.get(owner) or {}).get('balances') or {}
if not bal_map:
labels = [a['label'] for a in kc.list_accounts() if derive_owner(a['label']) == owner]
bal_map = {lb: kc.get_balance(lb) for lb in labels}
def srow(label: str, value: str, cls: str = '') -> str:
return (f'<div class="pm-srow{cls}"><span>{html.escape(label)}</span>'
f'<b>{value}</b></div>')
deposits = {lb: (b.get('d2_entra', 0) or 0) for lb, b in bal_map.items()}
head = [srow('예수금', f'{sum(deposits.values()):,}', ' strong'),
'<div class="pm-shead">계좌별</div>']
head += [srow(_acct_short(lb), f'{v:,}') for lb, v in deposits.items()]
locked = {lb: (b.get('pending_buy_locked', 0) or 0) for lb, b in bal_map.items()}
if any(locked.values()):
head.append('<div class="pm-shead">매수 묶임</div>')
head += [srow(_acct_short(lb), f'{v:,}') for lb, v in locked.items() if v]
return f'<div class="pm-sum">{"".join(head)}</div>'
def _render_cashflow_body(owner: str) -> str:
"""당일 입출금 팝업 본문 — 입금/출금 합계 + 건별 내역 (kt00015 tp='1' 당일).
@@ -3342,6 +3387,11 @@ def _render_owner_kpi(owner: str, d: dict, owner_label_text: str, compact: bool
deposit_locked_html = (
f'<span class="muted small"> · 매수 묶임 {locked:,}원</span>' if locked > 0 else ''
)
# 계좌별 예수금은 라벨 옆 [!] 팝업으로 — owner 탭 하단에 있던 '계좌별 예수금' 목록을 대체한다.
# 계좌가 1개면 총액과 같은 값이 반복될 뿐이라 버튼을 달지 않는다.
deposit_row = ('예수금', f'{deposit_pct_html}{d["deposit"]:,}{deposit_locked_html}')
if len(d.get('deposit_by_label') or {}) > 1:
deposit_row += (_kpi_more_button(f'/api/deposit?owner={_urlq(owner)}', '예수금'),)
# 구분 없는 단일 목록 (관리자님 지정 순서).
# 라벨 규칙: '총'은 계좌 전체(현금 포함) 기준일 때만 쓴다.
# 총자산 = 평가금액 + 예수금 / 총수익 = 총자산 − 투자원금
@@ -3380,7 +3430,7 @@ def _render_owner_kpi(owner: str, d: dict, owner_label_text: str, compact: bool
('평가금액', f'{d["total_value"]:,}'),
('평가손익', f'<span class="{pcls}">{sign}{profit:,}원 ({sign}{profit_rate:.2f}%)</span>'),
('당일 평가손익', day_pl_html),
('예수금', f'{deposit_pct_html}{d["deposit"]:,}{deposit_locked_html}'),
deposit_row,
]
if cashflow_row:
kpis.append(cashflow_row)
@@ -5133,24 +5183,9 @@ def _render_net_worth_chart(series: list[tuple[str, int]], current_net: int | No
def _render_owner_panel(owner: str, d: dict, balances: dict, owner_label_text: str, show_day_change: bool = False, chart_days: int = NET_WORTH_CHART_DAYS, chart_unit: str = NET_WORTH_CHART_UNIT) -> str:
# 순자산 차트는 자산정보 탭(_render_summary_panel)로 통합 이동. owner 탭엔 KPI + 보유종목 흐름만.
# chart_days/chart_unit 인자는 시그니처 호환 위해 보존 (호출처 영향 최소).
# balances도 같은 이유로 보존 — 계좌별 예수금이 KPI 예수금 [!] 팝업으로 옮겨가며 여기선 미사용.
parts = [_render_owner_kpi(owner, d, owner_label_text)]
cash_lines: list[str] = []
for lb in d['labels']:
b = balances.get(lb)
if not b:
continue
lk = b.get('pending_buy_locked', 0) or 0
lock_html = f'<span class="muted small"> · 매수 묶임 {lk:,}원</span>' if lk > 0 else ''
cash_lines.append(
f'<li><span class="muted">[{html.escape(lb)}]</span> 예수금 {b["d2_entra"]:,}{lock_html}</li>'
)
if cash_lines:
parts.append(
'<div class="section-label">계좌별 예수금</div>'
f'<ul class="cash-list">{"".join(cash_lines)}</ul>'
)
held = [r for r in d['consolidated'] if not r.get('phantom')]
traded_held = [r for r in held if r.get('traded_today')]
phantoms = [r for r in d['traded_today'] if r.get('phantom')]
@@ -6176,9 +6211,6 @@ section.tab-content { min-height: calc(100dvh - 126px); }
.pm-row .pm-amt.in { color: #ff4d5e; }
.pm-row .pm-amt.out { color: #4a8cf0; }
.cash-list { list-style: none; margin: 4px 14px 12px; padding: 8px 12px; background: #0d1018; border: 1px solid #14171f; border-radius: 8px; font-size: 12px; color: #c9ccd3; font-variant-numeric: tabular-nums; }
.cash-list li { padding: 2px 0; }
.detail dl .num { font-variant-numeric: tabular-nums; }
.detail .up { color: #ff4d5e; font-weight: 600; }
.detail .down { color: #4a8cf0; font-weight: 600; }
@@ -6708,7 +6740,6 @@ table.adr-info-table td.adr-breakdown { font-size: 11px; color: #8b8f9a; white-s
.owner-card { margin: 10px 10px 6px; padding: 12px 14px; }
.owner-card table.kpi th { width: auto; font-size: 11px; }
.owner-card table.kpi td { font-size: 12px; }
.cash-list { margin: 4px 10px 10px; font-size: 11px; }
.net-chart { margin: 10px 10px 0; padding: 8px 10px 6px; }
/* 모바일은 viewBox(800) > viewport scale 0.5 정도 viewBox unit 폰트를 크게 잡아 화면에서 적당히 보이게. */
.net-chart-svg .axis { font-size: 22px; }
@@ -13722,14 +13753,15 @@ class Handler(BaseHTTPRequestHandler):
self._send_json(200, {'connected': connected, 'sub_count': sub_count, 'quotes': quotes, 'vi': vi, 'fill_seq': fill_seq})
return
# KPI 라벨 [!] 팝업 본문 — 서버가 HTML 조립, 클라이언트는 innerHTML만.
if parsed.path in ('/api/principal', '/api/cashflow'):
if parsed.path in ('/api/principal', '/api/cashflow', '/api/deposit'):
qs = parse_qs(parsed.query)
owner_q = (qs.get('owner') or [''])[0].strip()
if not owner_q:
self._send_json(400, {'error': 'owner required'})
return
render = (_render_principal_body if parsed.path == '/api/principal'
else _render_cashflow_body)
render = {'/api/principal': _render_principal_body,
'/api/cashflow': _render_cashflow_body,
'/api/deposit': _render_deposit_body}[parsed.path]
try:
self._send_json(200, {'html': render(owner_q)})
except Exception as e: