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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hyowons
2026-06-27 02:00:02 +09:00
parent 03989e9df4
commit 6c89c54fe5
178 changed files with 2051 additions and 3931 deletions
@@ -18,6 +18,7 @@ from __future__ import annotations
import json
import os
import re
import sys
import subprocess
import threading
@@ -1121,6 +1122,22 @@ def _fmt_pct_signed(v: float) -> str:
return f'{v:+.2f}%'
_EM_RE = re.compile(r'\*\*(.+?)\*\*')
def _emphasize(escaped: str) -> str:
"""이스케이프된 텍스트의 **강조** → 붉은 강조 글씨. 입력은 이미 _esc 처리된 상태."""
return _EM_RE.sub(r'<span class="rpt-em">\1</span>', escaped)
def _fmt_eok_jo(eok) -> str:
"""억 단위 금액 → 조 단위. 1,997,447억 → 199.7조. 1조 미만은 소수 둘째자리. 비숫자는 —."""
if not isinstance(eok, (int, float)):
return ''
jo = eok / 10000
return f'{jo:,.2f}' if abs(jo) < 1 else f'{jo:,.1f}'
def _fmt_market_cap(eok) -> str:
"""키움 mac(억원) → 사람이 읽는 '2,084조 1,983억원'. 0/None은 ''."""
if not isinstance(eok, (int, float)) or eok <= 0:
@@ -1135,10 +1152,20 @@ def _fmt_market_cap(eok) -> str:
def _fmt_date(s) -> str:
"""20260519 2026-05-19. 형식 다르면 원본 반환."""
"""날짜를 yy/mm/dd로. 20260519 · 2026-05-19 · 2026/05/19 → 26/05/19. 형식 다르면 원본."""
s = str(s or '').strip()
if len(s) == 8 and s.isdigit():
return f'{s[:4]}-{s[4:6]}-{s[6:]}'
d = s.replace('-', '').replace('/', '').replace('.', '')
if len(d) == 8 and d.isdigit():
return f'{d[2:4]}/{d[4:6]}/{d[6:]}'
return s
def _fmt_period(s) -> str:
"""회계기간 2021/12 · 2021.12 · 202112 → 21/12. 형식 다르면 원본."""
s = str(s or '').strip()
d = s.replace('-', '').replace('/', '').replace('.', '')
if len(d) == 6 and d.isdigit():
return f'{d[2:4]}/{d[4:6]}'
return s
@@ -1171,9 +1198,9 @@ _KV_HINTS = {
'BPS': '주당순자산. 1주당 회사가 가진 순자산 가치(원).',
'외국인 보유비율': '전체 발행 주식 중 외국인이 보유한 비율(%).',
# ── 성장성·펀더멘털 (FnGuide) ──
'매출액()': '회사가 1년에 물건·서비스를 팔아 벌어들인 총액(원). 회사 덩치가 커지는지 보는 지표.',
'매출액()': '회사가 1년에 물건·서비스를 팔아 벌어들인 총액(원). 회사 덩치가 커지는지 보는 지표.',
'매출증가율': '작년보다 매출이 몇 % 늘었는지. +면 성장, −면 역성장. 꾸준히 +면 좋아요.',
'영업이익()': '본업으로 번 이익(원). 매출에서 원가·판관비를 뺀 것. 회사가 장사를 잘하는지.',
'영업이익()': '본업으로 번 이익(원). 매출에서 원가·판관비를 뺀 것. 회사가 장사를 잘하는지.',
'EPS(원)': '주당순이익. 1주가 1년에 번 순이익(원). 높을수록, 그리고 늘어날수록 좋아요.',
'EPS증가율': '작년보다 주당순이익이 몇 % 늘었는지. 이익이 실제로 커지는지 보는 핵심 지표.',
'목표주가': '증권사들이 "이 정도까진 오를 만하다"고 본 가격의 평균. 현재가보다 높으면 상승 기대.',
@@ -1186,7 +1213,7 @@ _KV_HINTS = {
'목표주가 리비전': '최근 3개월간 증권사 목표주가 평균이 오르고(+) 있는지 내리고(−) 있는지. 오르는 중이면 시장 기대가 좋아지는 신호.',
'추정치 리비전': '최근 3개월간 예상 실적(EPS 등)이 상향(+)/하향(−)되는 중인지. 상향이면 전망이 밝아지는 거예요.',
'서프라이즈': '실제 발표 실적이 직전 예상치보다 잘 나왔으면(+) 어닝 서프라이즈, 못 나왔으면(−) 어닝 쇼크.',
'실적()': '실제로 확정된 작년 실적 금액(원). 예상치가 아니라 진짜 나온 숫자.',
'실적()': '실제로 확정된 작년 실적 금액(원). 예상치가 아니라 진짜 나온 숫자.',
}
@@ -1278,23 +1305,23 @@ def _render_fundamentals_html(fund: dict | None) -> str:
roe = r.get('roe')
body.append(
'<tr>'
f'<td>{_esc(r.get("period",""))}</td>'
f'<td style="text-align:right">{_fmt_num(round(sales)) if isinstance(sales,(int,float)) else ""}</td>'
f'<td>{_esc(_fmt_period(r.get("period","")))}</td>'
f'<td style="text-align:right">{_fmt_eok_jo(sales)}</td>'
f'<td style="text-align:right">{_fmt_growth(r.get("sales_growth"))}</td>'
f'<td style="text-align:right">{_fmt_num(round(oper)) if isinstance(oper,(int,float)) else ""}</td>'
f'<td style="text-align:right">{_fmt_eok_jo(oper)}</td>'
f'<td style="text-align:right">{_fmt_num(round(eps)) if isinstance(eps,(int,float)) else ""}</td>'
f'<td style="text-align:right">{_fmt_growth(r.get("eps_growth"))}</td>'
f'<td style="text-align:right">{(str(roe)+"%") if isinstance(roe,(int,float)) else ""}</td>'
'</tr>'
)
table_html = (
'<table class="flowtable">'
'<div class="tbl-scroll"><table class="flowtable">'
'<thead><tr>'
f'<th>기간</th><th>{_lbl("매출액()")}</th><th>{_lbl("매출증가율")}</th>'
f'<th>{_lbl("영업이익()")}</th><th>{_lbl("EPS(원)")}</th>'
f'<th>기간</th><th>{_lbl("매출액()")}</th><th>{_lbl("매출증가율")}</th>'
f'<th>{_lbl("영업이익()")}</th><th>{_lbl("EPS(원)")}</th>'
f'<th>{_lbl("EPS증가율")}</th><th>{_lbl("ROE")}</th>'
'</tr></thead>'
'<tbody>' + ''.join(body) + '</tbody></table>'
'<tbody>' + ''.join(body) + '</tbody></table></div>'
)
# ---- 컨센서스 카드 ----
@@ -1320,7 +1347,7 @@ def _render_fundamentals_html(fund: dict | None) -> str:
parts.append(f'<tr><th>{_lbl("참여 기관수")}</th><td>{int(oc)}개</td></tr>')
if parts:
cdate = consensus.get('date')
date_html = (f'<span style="color:#7a8493;font-size:11px"> · 기준 {_esc(cdate)}</span>'
date_html = (f'<span style="color:#7a8493;font-size:11px"> · 기준 {_esc(_fmt_date(cdate))}</span>'
if cdate else '')
cons_html = (
f'<h4 style="margin:10px 0 4px">📋 컨센서스{date_html}</h4>'
@@ -1385,7 +1412,7 @@ def _render_consensus_html(cons: dict | None) -> str:
sup_rows.append(
'<tr>'
f'<td>{_esc(label)}</td>'
f'<td style="text-align:right">{_fmt_num(round(act)) if isinstance(act,(int,float)) else ""}</td>'
f'<td style="text-align:right">{_fmt_eok_jo(act)}</td>'
f'<td style="text-align:right">{_fmt_growth(sp)}</td>'
'</tr>'
)
@@ -1395,7 +1422,7 @@ def _render_consensus_html(cons: dict | None) -> str:
f'<h4 style="margin:10px 0 4px">🎯 어닝 서프라이즈 '
f'<span style="color:#7a8493;font-size:11px">({_esc(any_year)} 실적 vs 직전 컨센서스)</span></h4>'
'<table class="flowtable">'
f'<thead><tr><th>항목</th><th>{_lbl("실적()")}</th><th>{_lbl("서프라이즈")}</th></tr></thead>'
f'<thead><tr><th>항목</th><th>{_lbl("실적()")}</th><th>{_lbl("서프라이즈")}</th></tr></thead>'
'<tbody>' + ''.join(sup_rows) + '</tbody></table>'
)
@@ -1409,10 +1436,10 @@ def _render_consensus_html(cons: dict | None) -> str:
sales = r.get('sales'); op = r.get('op'); eps = r.get('eps'); roe = r.get('roe')
body.append(
'<tr' + (' style="opacity:.85"' if est else '') + '>'
f'<td>{_esc(r.get("period",""))}{tag}</td>'
f'<td style="text-align:right">{_fmt_num(round(sales)) if isinstance(sales,(int,float)) else ""}</td>'
f'<td>{_esc(_fmt_period(r.get("period","")))}{tag}</td>'
f'<td style="text-align:right">{_fmt_eok_jo(sales)}</td>'
f'<td style="text-align:right">{_fmt_growth(r.get("sales_yoy"))}</td>'
f'<td style="text-align:right">{_fmt_num(round(op)) if isinstance(op,(int,float)) else ""}</td>'
f'<td style="text-align:right">{_fmt_eok_jo(op)}</td>'
f'<td style="text-align:right">{_fmt_num(round(eps)) if isinstance(eps,(int,float)) else ""}</td>'
f'<td style="text-align:right">{(str(roe)+"%") if isinstance(roe,(int,float)) else ""}</td>'
'</tr>'
@@ -1420,10 +1447,10 @@ def _render_consensus_html(cons: dict | None) -> str:
parts.append(
'<h4 style="margin:10px 0 4px">📅 연도별 추정 재무 '
'<span style="color:#7a8493;font-size:11px">(IFRS연결 · A 실적 / E 추정)</span></h4>'
'<table class="flowtable">'
f'<thead><tr><th>기간</th><th>{_lbl("매출액()")}</th><th>{_lbl("YoY")}</th>'
f'<th>{_lbl("영업이익()")}</th><th>{_lbl("EPS(원)")}</th><th>{_lbl("ROE")}</th></tr></thead>'
'<tbody>' + ''.join(body) + '</tbody></table>'
'<div class="tbl-scroll"><table class="flowtable">'
f'<thead><tr><th>기간</th><th>{_lbl("매출액()")}</th><th>{_lbl("YoY")}</th>'
f'<th>{_lbl("영업이익()")}</th><th>{_lbl("EPS(원)")}</th><th>{_lbl("ROE")}</th></tr></thead>'
'<tbody>' + ''.join(body) + '</tbody></table></div>'
)
if not parts:
@@ -1601,11 +1628,11 @@ def build_report_html(snap: dict, parsed: dict) -> str:
'<thead><tr><th>일자</th><th>종가</th><th>외국인</th><th>기관</th><th>개인</th></tr></thead>'
'<tbody>' + ''.join(flow_rows) + '</tbody>'
'</table>'
f'<p class="flowsum">20일 누적'
f'<p class="flowsum"><b>20일 누적</b><br>'
f'<b style="color:{"#ef4444" if fs["foreign_net"]>0 else "#3b82f6"}">외국인 {fs["foreign_net"]*1000:+,}주</b> '
f'(매수일 {fs["foreign_buy_days"]}/20) · '
f'(매수일 {fs["foreign_buy_days"]}/20)<br>'
f'<b style="color:{"#ef4444" if fs["institution_net"]>0 else "#3b82f6"}">기관 {fs["institution_net"]*1000:+,}주</b> '
f'(매수일 {fs["institution_buy_days"]}/20) · '
f'(매수일 {fs["institution_buy_days"]}/20)<br>'
f'개인 {fs["individual_net"]*1000:+,}주</p>'
)
@@ -1634,7 +1661,7 @@ def build_report_html(snap: dict, parsed: dict) -> str:
paras = [p.strip() for p in s['body'].split('\n\n') if p.strip()]
if not paras:
paras = [s['body']]
body_html = ''.join(f'<p>{_esc(para).replace(chr(10), "<br>")}</p>' for para in paras)
body_html = ''.join(f'<p>{_emphasize(_esc(para)).replace(chr(10), "<br>")}</p>' for para in paras)
comment_html_parts.append(
f'<div class="rpt-llm-block"><h4>{title}</h4>{body_html}</div>'
)
@@ -1804,16 +1831,19 @@ body{background:#0f1419;color:#cfd5df;font-family:-apple-system,BlinkMacSystemFo
/* 보조 섹션 2단(넓으면 다단) 그리드 — 한 화면에 모이게 */
.rpt-cols{display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:14px;align-items:start;margin:14px 0}
.rpt-cols>.rpt-section{margin:0;height:100%}
.kvtable{border-collapse:collapse;width:100%}
.kvtable th{text-align:left;padding:7px 10px;color:#7a8493;font-weight:500;width:160px;border-bottom:1px solid #1f2937;font-size:13px}
.kvtable td{padding:7px 10px;border-bottom:1px solid #1f2937}
.flowtable{border-collapse:collapse;width:100%;font-size:13px}
.flowtable th{padding:6px 10px;color:#7a8493;border-bottom:1px solid #2a2f3a;text-align:left}
.flowtable td{padding:5px 10px;border-bottom:1px solid #1a1f28}
.flowsum{margin:10px 0 0;padding:10px;background:#0f1419;border-radius:6px;font-size:13px}
.kvtable{border-collapse:collapse;width:100%;font-size:12px}
.kvtable th{text-align:left;padding:6px 9px;color:#7a8493;font-weight:500;width:160px;border-bottom:1px solid #1f2937;font-size:12px}
.kvtable td{padding:6px 9px;border-bottom:1px solid #1f2937}
.tbl-scroll{overflow-x:auto;-webkit-overflow-scrolling:touch}
.tbl-scroll>table{min-width:100%;width:max-content}
.flowtable{border-collapse:collapse;width:100%;font-size:12px}
.flowtable th{padding:5px 9px;color:#7a8493;border-bottom:1px solid #2a2f3a;text-align:left;white-space:nowrap}
.flowtable td{padding:5px 9px;border-bottom:1px solid #1a1f28;white-space:nowrap}
.flowsum{margin:10px 0 0;padding:10px;background:#0f1419;border-radius:6px;font-size:13px;line-height:1.8}
.rpt-llm-block{margin:16px 0;padding:14px;background:#0f1419;border-radius:6px}
.rpt-llm-block h4{margin:0 0 10px;color:#fbbf24;font-size:14px}
.rpt-llm-block p{margin:8px 0;text-align:justify}
.rpt-em{color:#ff6b6b;font-weight:600}
.rpt-verdict{background:linear-gradient(135deg,#1f2937 0%,#161b22 100%)}
.verdict-line{font-size:16px;font-weight:600;margin:10px 0 6px;color:#fff}
.rpt-disclaimer{color:#7a8493;font-size:12px;margin:6px 0 0}
@@ -1854,8 +1884,8 @@ body{background:#0f1419;color:#cfd5df;font-family:-apple-system,BlinkMacSystemFo
.peertable th:not(:first-child){text-align:right}
.peertable td{padding:7px 10px;border-bottom:1px solid #1a1f28}
.peers-note{margin:10px 0 0;color:#7a8493;font-size:11px}
.basicpeertable{border-collapse:collapse;width:100%;font-size:13px;table-layout:fixed}
.basicpeertable th.lbl{text-align:left;padding:7px 10px;color:#7a8493;font-weight:500;width:140px;border-bottom:1px solid #1f2937;font-size:13px;vertical-align:top}
.basicpeertable{border-collapse:collapse;width:100%;font-size:12px;table-layout:fixed}
.basicpeertable th.lbl{text-align:left;padding:6px 9px;color:#7a8493;font-weight:500;width:130px;border-bottom:1px solid #1f2937;font-size:12px;vertical-align:top}
.basicpeertable thead th{padding:8px 10px;color:#cfd5df;border-bottom:1px solid #2a2f3a;font-weight:600;text-align:left;background:#0f1419}
.basicpeertable thead th:nth-child(2){color:#fbbf24}
.basicpeertable td{padding:7px 10px;border-bottom:1px solid #1f2937;vertical-align:top;word-break:break-word}