auto: 일일 백업 2026-06-27 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -34,6 +34,7 @@ WORKSPACE = Path(__file__).resolve().parent.parent
|
||||
WATCHLIST = WORKSPACE / 'state' / 'behive_watchlist.json'
|
||||
INTERESTS = WORKSPACE / 'state' / 'behive_interests.json'
|
||||
STOCK_TAGS = WORKSPACE / 'state' / 'behive_stock_tags.json'
|
||||
STOCK_NOTES = WORKSPACE / 'state' / 'behive_stock_notes.json'
|
||||
ALERTS_STATE = WORKSPACE / 'state' / 'watchlist_alerts.json'
|
||||
HOLIDAYS_FILE = WORKSPACE / 'state' / 'market_holidays.json'
|
||||
SNAPSHOT_FILE = WORKSPACE / 'state' / 'portfolio_daily_snapshot.json'
|
||||
@@ -1699,6 +1700,103 @@ def _set_stock_tag(code: str | None, name: str | None, text: str, leader: bool)
|
||||
tmp.replace(STOCK_TAGS)
|
||||
|
||||
|
||||
def _stock_notes_lock():
|
||||
"""STOCK_NOTES 파일 직렬화 — _stock_tags_lock 과 동일 패턴, 별도 lock 파일."""
|
||||
import fcntl as _fcntl
|
||||
from contextlib import contextmanager as _cm
|
||||
|
||||
@_cm
|
||||
def _ctx():
|
||||
lock_path = STOCK_NOTES.with_suffix(STOCK_NOTES.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()
|
||||
return _ctx()
|
||||
|
||||
|
||||
def _load_stock_notes() -> dict:
|
||||
"""{'by_code': {code: text}, 'by_name': {name: text}}. 종목별 자유 메모 저장소."""
|
||||
if not STOCK_NOTES.exists():
|
||||
return {'by_code': {}, 'by_name': {}}
|
||||
try:
|
||||
d = json.loads(STOCK_NOTES.read_text())
|
||||
if 'by_code' not in d:
|
||||
d['by_code'] = {}
|
||||
if 'by_name' not in d:
|
||||
d['by_name'] = {}
|
||||
return d
|
||||
except Exception:
|
||||
return {'by_code': {}, 'by_name': {}}
|
||||
|
||||
|
||||
def _get_stock_note(code: str | None, name: str | None) -> str:
|
||||
"""메모 텍스트 반환. 없으면 ''. code 우선 → name fallback."""
|
||||
if not code and not name:
|
||||
return ''
|
||||
notes = _load_stock_notes()
|
||||
for src_key, key in (('by_code', code), ('by_name', name)):
|
||||
if not key:
|
||||
continue
|
||||
v = notes.get(src_key, {}).get(key)
|
||||
if isinstance(v, str) and v.strip():
|
||||
return v.strip()
|
||||
return ''
|
||||
|
||||
|
||||
def _set_stock_note(code: str | None, name: str | None, text: str) -> None:
|
||||
"""메모 저장. 빈 텍스트면 항목 제거. 1000자 cap."""
|
||||
text = (text or '').strip()[:1000]
|
||||
code = (code or '').strip() or None
|
||||
name = (name or '').strip() or None
|
||||
if not code and not name:
|
||||
raise ValueError('종목코드 또는 종목명 중 하나는 필요합니다')
|
||||
STOCK_NOTES.parent.mkdir(parents=True, exist_ok=True)
|
||||
with _stock_notes_lock():
|
||||
d = _load_stock_notes()
|
||||
if text:
|
||||
if code:
|
||||
d['by_code'][code] = text
|
||||
if name:
|
||||
d['by_name'][name] = text
|
||||
else:
|
||||
if code:
|
||||
d['by_code'].pop(code, None)
|
||||
if name:
|
||||
d['by_name'].pop(name, None)
|
||||
tmp = STOCK_NOTES.with_suffix('.json.tmp')
|
||||
tmp.write_text(json.dumps(d, ensure_ascii=False, indent=2))
|
||||
tmp.replace(STOCK_NOTES)
|
||||
|
||||
|
||||
def _note_button_html(code: str, name: str) -> str:
|
||||
"""detail actions의 '메모' 버튼. 클릭 시 note-modal 열리며 현재 메모가 prefill.
|
||||
메모가 있으면 ● 표시 + has-note 클래스로 강조."""
|
||||
code_s = (code or '').strip()
|
||||
name_s = (name or '').strip()
|
||||
if not code_s and not name_s:
|
||||
return ''
|
||||
text = _get_stock_note(code_s, name_s)
|
||||
code_attr = html.escape(code_s, quote=True)
|
||||
name_attr = html.escape(name_s, quote=True)
|
||||
has = bool(text)
|
||||
cls = 'btn-note has-note' if has else 'btn-note'
|
||||
label = '📝 메모 ●' if has else '📝 메모'
|
||||
title = html.escape(text[:60], quote=True) if has else '메모 작성'
|
||||
return (
|
||||
f'<button type="button" class="{cls}" data-note-edit="1"'
|
||||
f' data-note-stock="{name_attr}" data-note-code="{code_attr}"'
|
||||
f' data-note-text="{html.escape(text, quote=True)}"'
|
||||
f' title="{title}">{label}</button>'
|
||||
)
|
||||
|
||||
|
||||
def _apply_interests_action(action: str, stock: str, payload: dict | None = None) -> None:
|
||||
"""관심종목 add/delete. 자동 적재 없는 수동 리스트라 pending_delete trash 단계 없이 즉시 삭제."""
|
||||
INTERESTS.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -2061,10 +2159,12 @@ def _render_row(c: dict, source: str = 'watchlist') -> str:
|
||||
f' data-edit-memo="{html.escape(memo_val, quote=True)}"'
|
||||
)
|
||||
tag_add_btn = _tag_add_button_html(c.get('code') or '', c.get('stock') or '')
|
||||
note_btn = _note_button_html(c.get('code') or '', c.get('stock') or '')
|
||||
info_btn = _info_button_html(c.get('code') or '', c.get('stock') or '')
|
||||
actions_html = (
|
||||
'<div class="actions">'
|
||||
f'{tag_add_btn}'
|
||||
f'{note_btn}'
|
||||
f'{analyze_btn}'
|
||||
f'{info_btn}'
|
||||
f'{trade_btn}'
|
||||
@@ -2083,10 +2183,12 @@ def _render_row(c: dict, source: str = 'watchlist') -> str:
|
||||
cls = f'{cls} trash'
|
||||
else:
|
||||
wl_tag_add_btn = _tag_add_button_html(c.get('code') or '', c.get('stock') or '')
|
||||
wl_note_btn = _note_button_html(c.get('code') or '', c.get('stock') or '')
|
||||
wl_info_btn = _info_button_html(c.get('code') or '', c.get('stock') or '')
|
||||
actions_html = (
|
||||
'<div class="actions">'
|
||||
f'{wl_tag_add_btn}'
|
||||
f'{wl_note_btn}'
|
||||
f'{analyze_btn}'
|
||||
f'{wl_info_btn}'
|
||||
f'{trade_btn}'
|
||||
@@ -2763,9 +2865,11 @@ def _render_holding_row(r: dict, total_value: int, show_day_change: bool = False
|
||||
|
||||
row_key = (code or stock) + key_suffix
|
||||
tag_add_btn = _tag_add_button_html(r.get('code') or '', r.get('stock') or '')
|
||||
note_btn = _note_button_html(r.get('code') or '', r.get('stock') or '')
|
||||
trade_btn = (
|
||||
f'<div class="actions">'
|
||||
f'{tag_add_btn}'
|
||||
f'{note_btn}'
|
||||
f'{_info_button_html(r.get("code") or "", r.get("stock") or "")}'
|
||||
f'<button type="button" class="btn-trades" data-trade-code="{code}" data-trade-stock="{stock}">거래내역</button>'
|
||||
f'<button type="button" class="btn-order" data-order-code="{code}" data-order-stock="{html.escape(stock, quote=True)}" data-order-side="SELL" title="매수/매도">💰 거래</button>'
|
||||
@@ -4468,6 +4572,9 @@ dl.pending-detail .pending-line-meta { margin-top: 2px; }
|
||||
.add-form-grid label { display: flex; flex-direction: column; gap: 4px; font-size: 11px; color: #8b8f9a; }
|
||||
.add-form-grid input { background: #14171f; border: 1px solid #1f2330; border-radius: 6px; padding: 9px 10px; color: #e6e6e6; font-size: 16px; font-family: inherit; }
|
||||
.add-form-grid input:focus { outline: none; border-color: #ff4d5e; }
|
||||
.add-form-grid label:has(textarea) { grid-column: 1 / -1; }
|
||||
.add-form-grid textarea { background: #14171f; border: 1px solid #1f2330; border-radius: 6px; padding: 9px 10px; color: #e6e6e6; font-size: 16px; font-family: inherit; resize: vertical; min-height: 96px; }
|
||||
.add-form-grid textarea:focus { outline: none; border-color: #ff4d5e; }
|
||||
@media (max-width: 480px) {
|
||||
.add-form-grid { grid-template-columns: 1fr; }
|
||||
.modal { padding: max(56px, calc(env(safe-area-inset-top) + 60px)) 12px 12px; }
|
||||
@@ -4488,6 +4595,20 @@ dl.pending-detail .pending-line-meta { margin-top: 2px; }
|
||||
.actions .btn-info { color: #7dd3a8; border: 1px solid rgba(125,211,168,0.35); background: rgba(125,211,168,0.06); border-radius: 8px; padding: 5px 10px; font-size: 11px; font-weight: 600; cursor: pointer; font-family: inherit; }
|
||||
.actions .btn-info:hover { background: rgba(125,211,168,0.14); border-color: #7dd3a8; }
|
||||
.actions .btn-info:active { transform: translateY(1px); }
|
||||
.actions .btn-note { color: #c9b27d; border: 1px solid rgba(201,178,125,0.35); background: rgba(201,178,125,0.06); border-radius: 8px; padding: 5px 10px; font-size: 11px; font-weight: 600; cursor: pointer; font-family: inherit; }
|
||||
.actions .btn-note:hover { background: rgba(201,178,125,0.14); border-color: #c9b27d; }
|
||||
.actions .btn-note:active { transform: translateY(1px); }
|
||||
.actions .btn-note.has-note { color: #f0c95a; border-color: rgba(240,201,90,0.5); background: rgba(240,201,90,0.12); }
|
||||
.note-head-left { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.note-del-wrap { position: relative; display: inline-flex; }
|
||||
.note-confirm { position: absolute; top: calc(100% + 6px); left: 0; display: flex; align-items: center; gap: 6px; background: #1b1f2a; border: 1px solid #3a2030; border-radius: 8px; padding: 6px 8px; box-shadow: 0 6px 18px rgba(0,0,0,0.4); white-space: nowrap; z-index: 5; }
|
||||
.note-confirm.hidden { display: none; }
|
||||
.note-confirm-msg { font-size: 11px; color: #e6c0c0; }
|
||||
.note-confirm-yes, .note-confirm-no { font-size: 11px; font-weight: 600; padding: 3px 9px; border-radius: 6px; cursor: pointer; font-family: inherit; border: 1px solid transparent; }
|
||||
.note-confirm-yes { background: #ff4d5e; color: #fff; }
|
||||
.note-confirm-yes:hover { background: #e23a4b; }
|
||||
.note-confirm-no { background: transparent; color: #aab2c4; border-color: rgba(160,170,190,0.35); }
|
||||
.note-confirm-no:hover { background: rgba(160,170,190,0.10); }
|
||||
.info-body { display: flex; flex-direction: column; gap: 4px; }
|
||||
.info-grid { display: grid; grid-template-columns: max-content 1fr; gap: 7px 16px; align-items: baseline; }
|
||||
.info-grid dt { color: #8b8f9a; font-size: 12px; }
|
||||
@@ -6123,6 +6244,44 @@ def _render_tag_modal() -> str:
|
||||
)
|
||||
|
||||
|
||||
def _render_note_modal() -> str:
|
||||
"""종목별 자유 메모 모달. 어느 탭의 '메모' 버튼이든 동일하게 연다.
|
||||
저장은 텍스트 그대로, 삭제는 빈 텍스트 submit 으로 항목 제거."""
|
||||
return (
|
||||
'<div id="note-modal" class="modal hidden" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="note-modal-title">'
|
||||
'<div class="modal-overlay" data-modal-close="1"></div>'
|
||||
'<div class="modal-box" role="document">'
|
||||
'<div class="modal-head">'
|
||||
'<div class="note-head-left">'
|
||||
'<div class="modal-title" id="note-modal-title">메모 — <span data-note-title></span></div>'
|
||||
'<span class="note-del-wrap">'
|
||||
'<button type="button" class="btn-delete" data-note-delete>삭제</button>'
|
||||
'<div class="note-confirm hidden" data-note-confirm>'
|
||||
'<span class="note-confirm-msg">삭제할까요?</span>'
|
||||
'<button type="button" class="note-confirm-yes" data-note-confirm-yes>예</button>'
|
||||
'<button type="button" class="note-confirm-no" data-note-confirm-no>아니오</button>'
|
||||
'</div>'
|
||||
'</span>'
|
||||
'</div>'
|
||||
'<button type="button" class="modal-close" data-modal-close="1" aria-label="닫기">×</button>'
|
||||
'</div>'
|
||||
'<form class="add-form" id="note-form" method="post" action="/notes/set">'
|
||||
'<input type="hidden" name="stock">'
|
||||
'<input type="hidden" name="code">'
|
||||
'<div class="add-form-grid">'
|
||||
'<label>메모 <textarea name="note" rows="6" maxlength="1000" placeholder="이 종목에 대한 메모 (최대 1000자)" autocomplete="off"></textarea></label>'
|
||||
'</div>'
|
||||
'<div class="form-feedback" data-feedback hidden></div>'
|
||||
'</form>'
|
||||
'<div class="modal-actions">'
|
||||
'<button type="button" class="btn-cancel" data-modal-close="1">취소</button>'
|
||||
'<button type="submit" form="note-form" class="btn-add">저장</button>'
|
||||
'</div>'
|
||||
'</div>'
|
||||
'</div>'
|
||||
)
|
||||
|
||||
|
||||
def _render_candidate_modal() -> str:
|
||||
"""다중 매칭 시 사용자가 종목을 선택하는 별도 팝업. 추가 모달과 분리된 z-index 위층."""
|
||||
return (
|
||||
@@ -7775,6 +7934,7 @@ def render_html() -> str:
|
||||
candidate_modal_html = _render_candidate_modal()
|
||||
interest_edit_modal_html = _render_interests_edit_modal()
|
||||
tag_modal_html = _render_tag_modal()
|
||||
note_modal_html = _render_note_modal()
|
||||
trade_modal_html = _render_trade_modal()
|
||||
info_modal_html = _render_info_modal()
|
||||
info_desc_modal_html = _render_info_desc_modal()
|
||||
@@ -8366,6 +8526,20 @@ def render_html() -> str:
|
||||
'if(curLeader)b.classList.add("active");else b.classList.remove("active");'
|
||||
'});'
|
||||
'}'
|
||||
'function populateNoteForm(trigger){'
|
||||
'var nm=document.getElementById("note-modal");if(!nm)return;'
|
||||
'var name=trigger.getAttribute("data-note-stock")||"";'
|
||||
'var code=trigger.getAttribute("data-note-code")||"";'
|
||||
'var curText=trigger.getAttribute("data-note-text")||"";'
|
||||
'var title=nm.querySelector("[data-note-title]");'
|
||||
'if(title)title.textContent=name+(code?(" ("+code+")"):"");'
|
||||
'var f=nm.querySelector("#note-form");if(!f)return;'
|
||||
'f.querySelector("input[name=stock]").value=name;'
|
||||
'f.querySelector("input[name=code]").value=code;'
|
||||
'f.querySelector("textarea[name=note]").value=curText;'
|
||||
'var pop=nm.querySelector("[data-note-confirm]");'
|
||||
'if(pop)pop.classList.add("hidden");'
|
||||
'}'
|
||||
'document.addEventListener("click",function(e){'
|
||||
'var t=e.target;'
|
||||
'var closeAttr=t.closest&&t.closest("[data-modal-close]");'
|
||||
@@ -8454,6 +8628,41 @@ def render_html() -> str:
|
||||
'openModal("tag-modal");'
|
||||
'return;'
|
||||
'}'
|
||||
'var noteDelBtn=t.closest&&t.closest("[data-note-delete]");'
|
||||
'if(noteDelBtn){'
|
||||
'e.preventDefault();'
|
||||
'var ndm=document.getElementById("note-modal");if(!ndm)return;'
|
||||
'var pop=ndm.querySelector("[data-note-confirm]");'
|
||||
'if(pop)pop.classList.remove("hidden");'
|
||||
'return;'
|
||||
'}'
|
||||
'var noteConfNo=t.closest&&t.closest("[data-note-confirm-no]");'
|
||||
'if(noteConfNo){'
|
||||
'e.preventDefault();'
|
||||
'var ndmN=document.getElementById("note-modal");if(!ndmN)return;'
|
||||
'var popN=ndmN.querySelector("[data-note-confirm]");'
|
||||
'if(popN)popN.classList.add("hidden");'
|
||||
'return;'
|
||||
'}'
|
||||
'var noteConfYes=t.closest&&t.closest("[data-note-confirm-yes]");'
|
||||
'if(noteConfYes){'
|
||||
'e.preventDefault();'
|
||||
'var ndmY=document.getElementById("note-modal");if(!ndmY)return;'
|
||||
'var popY=ndmY.querySelector("[data-note-confirm]");'
|
||||
'if(popY)popY.classList.add("hidden");'
|
||||
'var ndf=ndmY.querySelector("#note-form");if(!ndf)return;'
|
||||
'ndf.querySelector("textarea[name=note]").value="";'
|
||||
'submitForm(ndf);'
|
||||
'return;'
|
||||
'}'
|
||||
'var noteEditBtn=t.closest&&t.closest("[data-note-edit]");'
|
||||
'if(noteEditBtn){'
|
||||
'e.preventDefault();'
|
||||
'e.stopPropagation();'
|
||||
'populateNoteForm(noteEditBtn);'
|
||||
'openModal("note-modal");'
|
||||
'return;'
|
||||
'}'
|
||||
'var openBtn=t.closest&&t.closest("[data-modal-open]");'
|
||||
'if(openBtn){'
|
||||
'e.preventDefault();'
|
||||
@@ -8505,7 +8714,7 @@ def render_html() -> str:
|
||||
'document.addEventListener("submit",function(e){'
|
||||
'var form=e.target;'
|
||||
'if(!form)return;'
|
||||
'if(form.id==="interest-add-form"||form.id==="interest-edit-form"||form.id==="tag-form"){'
|
||||
'if(form.id==="interest-add-form"||form.id==="interest-edit-form"||form.id==="tag-form"||form.id==="note-form"){'
|
||||
'e.preventDefault();'
|
||||
'submitForm(form);'
|
||||
'return;'
|
||||
@@ -9761,6 +9970,7 @@ window.openPinModal = openPinModal;
|
||||
{interest_modal_html}
|
||||
{interest_edit_modal_html}
|
||||
{tag_modal_html}
|
||||
{note_modal_html}
|
||||
{candidate_modal_html}
|
||||
{trade_modal_html}
|
||||
{info_modal_html}
|
||||
@@ -11043,6 +11253,43 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
if self.path == '/notes/set':
|
||||
# 종목 자유 메모 — stock(name) 또는 code 둘 중 하나는 필수.
|
||||
# note 가 빈 텍스트면 항목 제거.
|
||||
code = (params.get('code') or [''])[0].strip()
|
||||
note_raw = (params.get('note') or [''])[0]
|
||||
if not stock and not code:
|
||||
if wants_json:
|
||||
self._send_json(400, {'ok': False, 'error': '종목 식별자 없음 (stock 또는 code 필요)'})
|
||||
else:
|
||||
self.send_error(400, 'Bad Request', explain='종목 식별자 없음')
|
||||
return
|
||||
try:
|
||||
_set_stock_note(code or None, stock or None, note_raw)
|
||||
_invalidate_panels_cache()
|
||||
except ValueError as e:
|
||||
if wants_json:
|
||||
self._send_json(400, {'ok': False, 'error': str(e)})
|
||||
else:
|
||||
self.send_error(400, 'Bad Request', explain=str(e))
|
||||
return
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
if wants_json:
|
||||
self._send_json(500, {'ok': False, 'error': str(e)})
|
||||
else:
|
||||
self.send_error(500, 'Internal Error', explain=str(e))
|
||||
return
|
||||
sys.stdout.write(f'[{self.log_date_time_string()}] {self.address_string()} POST {self.path} stock={stock} code={code} note_len={len(note_raw.strip())} → ok\n')
|
||||
sys.stdout.flush()
|
||||
if wants_json:
|
||||
self._send_json(200, {'ok': True})
|
||||
else:
|
||||
self.send_response(303)
|
||||
self.send_header('Location', '/')
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
# /stock/<code>/generate — 새 보고서 큐 등록
|
||||
if self.path.startswith('/stock/') and self.path.endswith('/generate'):
|
||||
tail = self.path[len('/stock/'):-len('/generate')]
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user