Drop the "요약 모델" footer from rendered briefings and the model badge from the dashboard (degraded briefings keep a "요약 없음" chip). The model is still recorded in the DB/result JSON for diagnostics. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
435 lines
15 KiB
Python
435 lines
15 KiB
Python
"""Rendering: MarketPayload (+ optional LLM Report) -> Korean Markdown, and Markdown -> Telegram HTML chunks.
|
|
|
|
Every number in the output comes from the payload. The Report is used for prose only.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import unicodedata
|
|
from datetime import UTC, datetime
|
|
from urllib.parse import quote
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from us2kr.models import EarningsItem, EconEvent, MarketPayload, NewsItem, Quote, Report
|
|
from us2kr.symbols import GROUP_TITLES_KO, POINT_CHANGE_GROUPS, SYMBOL_GROUPS
|
|
|
|
KST = ZoneInfo("Asia/Seoul")
|
|
MAX_NEWS_ITEMS = 12
|
|
DASH = "—"
|
|
|
|
_TIMING_KO = {"amc": "장마감후", "bmo": "장전", "unknown": ""}
|
|
|
|
|
|
# ------------------------------------------------------------------ formatting helpers
|
|
|
|
|
|
def _kst(dt: datetime) -> datetime:
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=UTC)
|
|
return dt.astimezone(KST)
|
|
|
|
|
|
def _fmt_num(v: float | None, decimals: int = 2) -> str:
|
|
return DASH if v is None else f"{v:,.{decimals}f}"
|
|
|
|
|
|
def _fmt_pct(v: float | None) -> str:
|
|
if v is None:
|
|
return DASH
|
|
if abs(v) < 0.005: # avoid printing "-0.00%"
|
|
v = 0.0
|
|
return f"{v:+.2f}%"
|
|
|
|
|
|
def _fmt_bp(change_abs: float | None) -> str:
|
|
return DASH if change_abs is None else f"{change_abs * 100:+.1f}bp"
|
|
|
|
|
|
def _fmt_money(v: float | None) -> str:
|
|
if v is None:
|
|
return DASH
|
|
if abs(v) >= 1e9:
|
|
return f"{v / 1e9:,.2f}B"
|
|
if abs(v) >= 1e6:
|
|
return f"{v / 1e6:,.1f}M"
|
|
return f"{v:,.2f}"
|
|
|
|
|
|
def _cell(text: str) -> str:
|
|
"""Escape a Markdown table cell (pipes, newlines)."""
|
|
return text.replace("|", "\\|").replace("\n", " ").strip()
|
|
|
|
|
|
def _text(s: object) -> str:
|
|
"""Normalise a prose string from the Report: strip, collapse whitespace/newlines."""
|
|
return re.sub(r"\s*\n\s*", " ", str(s or "")).strip()
|
|
|
|
|
|
def _link_text(s: str) -> str:
|
|
return _text(s).replace("[", "(").replace("]", ")")
|
|
|
|
|
|
# ------------------------------------------------------------------ Markdown sections
|
|
|
|
|
|
def _header(payload: MarketPayload, report: Report | None) -> list[str]:
|
|
lines = [
|
|
f"# 🇺🇸→🇰🇷 미국 마감 브리핑 — {payload.krx_date.isoformat()} (미 {payload.session_date.isoformat()} 세션)"
|
|
]
|
|
close_kst = _kst(payload.us_close_time).strftime("%Y-%m-%d %H:%M")
|
|
gen_kst = _kst(payload.generated_at).strftime("%Y-%m-%d %H:%M")
|
|
lines.append(f"미국 마감: {close_kst} KST · 생성: {gen_kst} KST")
|
|
if not payload.krx_open_today:
|
|
lines.append("⚠️ 오늘 한국 휴장")
|
|
if report is None:
|
|
lines.append("🧩 축소 모드(LLM 요약 없음)")
|
|
return lines
|
|
|
|
|
|
def _bullets(items: list[str]) -> list[str]:
|
|
return [f"- {_text(it)}" for it in items if _text(it)]
|
|
|
|
|
|
def _section(title: str, body: list[str]) -> list[str]:
|
|
if not body:
|
|
return []
|
|
return [f"## {title}", *body, ""]
|
|
|
|
|
|
def _report_sections(report: Report) -> list[str]:
|
|
out: list[str] = []
|
|
if _text(report.headline):
|
|
out += _section("총평", [f"**{_text(report.headline)}**"])
|
|
if _text(report.market_overview):
|
|
out += _section("시장 개요", [_text(report.market_overview)])
|
|
out += _section("주요 요인", _bullets(report.key_drivers))
|
|
if _text(report.rates_fx_commodities):
|
|
out += _section("금리·환율·원자재", [_text(report.rates_fx_commodities)])
|
|
out += _section("한국 장 시사점", _bullets(report.korea_implications))
|
|
out += _section("오늘 볼 것", _bullets(report.watchlist_today))
|
|
out += _section("리스크", _bullets(report.risk_flags))
|
|
return out
|
|
|
|
|
|
def _ordered_groups(payload: MarketPayload) -> list[tuple[str, str, list[Quote]]]:
|
|
"""(group key, Korean title, quotes) in GROUP_TITLES_KO order; unknown groups appended."""
|
|
by_group = payload.quotes_by_group()
|
|
ordered: list[tuple[str, str, list[Quote]]] = []
|
|
for g, title in GROUP_TITLES_KO.items():
|
|
if g in by_group:
|
|
ordered.append((g, title, _order_quotes(g, by_group[g])))
|
|
for g, qs in by_group.items():
|
|
if g not in GROUP_TITLES_KO:
|
|
ordered.append((g, g, qs))
|
|
return ordered
|
|
|
|
|
|
def _order_quotes(group: str, quotes: list[Quote]) -> list[Quote]:
|
|
order = list(SYMBOL_GROUPS.get(group, {}))
|
|
rank = {t: i for i, t in enumerate(order)}
|
|
return sorted(quotes, key=lambda q: (rank.get(q.symbol, len(rank)), q.symbol))
|
|
|
|
|
|
def _quote_table(group: str, quotes: list[Quote]) -> list[str]:
|
|
points = group in POINT_CHANGE_GROUPS
|
|
has_after = any(q.after_hours_price is not None for q in quotes)
|
|
header = ["종목", "종가", "변동"] + (["애프터"] if has_after else [])
|
|
lines = ["| " + " | ".join(header) + " |", "|" + "|".join("---" for _ in header) + "|"]
|
|
for q in quotes:
|
|
name = _cell(q.name or q.symbol)
|
|
if points:
|
|
close = _fmt_num(q.close, 3)
|
|
change = _fmt_bp(q.change_abs)
|
|
else:
|
|
close = _fmt_num(q.close, 2)
|
|
change = _fmt_pct(q.change_pct)
|
|
cells = [name, close, change]
|
|
if has_after:
|
|
if q.after_hours_price is None:
|
|
cells.append(DASH)
|
|
else:
|
|
ah = _fmt_num(q.after_hours_price, 3 if points else 2)
|
|
if q.after_hours_change_pct is not None:
|
|
ah += f" ({_fmt_pct(q.after_hours_change_pct)})"
|
|
cells.append(ah)
|
|
lines.append("| " + " | ".join(cells) + " |")
|
|
return lines
|
|
|
|
|
|
# Normal URL punctuation and existing %XX escapes stay as-is; '(' ')' and whitespace become %28 %29 %20 so
|
|
# they cannot terminate the Markdown ``(url)`` destination early (see _LINK_RE).
|
|
_URL_SAFE = ":/?#[]@!$&'*+,;=%"
|
|
|
|
|
|
def _link_url(url: str) -> str:
|
|
"""Percent-encode characters that would break a Markdown / Telegram link destination."""
|
|
return quote(url.strip(), safe=_URL_SAFE)
|
|
|
|
|
|
def _news_lines(news: list[NewsItem]) -> list[str]:
|
|
lines = []
|
|
for n in news[:MAX_NEWS_ITEMS]:
|
|
when = _kst(n.published).strftime("%H:%M")
|
|
lines.append(f"- [{_link_text(n.title)}]({_link_url(n.link)}) — {_text(n.source)}, {when} KST")
|
|
return lines
|
|
|
|
|
|
def _event_line(e: EconEvent) -> str:
|
|
star = "★ " if e.importance == "high" else ""
|
|
when = f" {_kst(e.time).strftime('%m/%d %H:%M')} KST" if e.time else ""
|
|
if e.kind == "released":
|
|
return (
|
|
f"- [발표{when}] {star}{_text(e.name)}: 실제 {e.actual or DASH} / 예상 {e.forecast or DASH}"
|
|
f" / 이전 {e.previous or DASH}"
|
|
)
|
|
tail = f" (예상 {e.forecast})" if e.forecast else ""
|
|
return f"- [예정{when}] {star}{_text(e.name)}{tail}"
|
|
|
|
|
|
def _earnings_line(it: EarningsItem) -> str:
|
|
timing = _TIMING_KO.get(it.timing, "")
|
|
tag = f"실적·{timing}" if timing else "실적"
|
|
parts = []
|
|
if it.eps_actual is not None or it.eps_estimate is not None:
|
|
eps = f"EPS {_fmt_num(it.eps_actual)}"
|
|
if it.eps_estimate is not None:
|
|
eps += f" (예상 {_fmt_num(it.eps_estimate)})"
|
|
parts.append(eps)
|
|
if it.revenue_actual is not None or it.revenue_estimate is not None:
|
|
rev = f"매출 {_fmt_money(it.revenue_actual)}"
|
|
if it.revenue_estimate is not None:
|
|
rev += f" (예상 {_fmt_money(it.revenue_estimate)})"
|
|
parts.append(rev)
|
|
detail = " · ".join(parts) if parts else "발표 예정"
|
|
return f"- [{tag}] {_text(it.name)}({it.symbol}) {it.report_date.isoformat()}: {detail}"
|
|
|
|
|
|
def render_markdown(payload: MarketPayload, report: Report | None, model_used: str | None) -> str:
|
|
"""Render the Korean Markdown briefing. Works with ``report=None`` (degraded: tables only)."""
|
|
lines: list[str] = _header(payload, report)
|
|
lines.append("")
|
|
if report is not None:
|
|
lines += _report_sections(report)
|
|
for g, title, qs in _ordered_groups(payload):
|
|
lines += _section(f"📊 {title}", _quote_table(g, qs))
|
|
lines += _section("히스토리 노트", _bullets(payload.history_notes))
|
|
lines += _section("주요 뉴스", _news_lines(payload.news))
|
|
ev_lines = [_event_line(e) for e in payload.events] + [_earnings_line(e) for e in payload.earnings]
|
|
lines += _section("경제지표·실적", ev_lines)
|
|
lines += _section("수집 누락", _bullets(payload.data_gaps))
|
|
return "\n".join(lines).rstrip() + "\n"
|
|
|
|
|
|
# ------------------------------------------------------------------ Telegram HTML
|
|
|
|
_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)\s]+)\)")
|
|
_BOLD_RE = re.compile(r"\*\*(.+?)\*\*")
|
|
_HEADER_RE = re.compile(r"^(#{1,6})\s+(.*)$")
|
|
_BULLET_RE = re.compile(r"^\s*[-*]\s+")
|
|
_HR_RE = re.compile(r"^\s*(-{3,}|\*{3,}|_{3,})\s*$")
|
|
_SEP_CELL_RE = re.compile(r":?-{2,}:?")
|
|
_PRE_OPEN, _PRE_CLOSE = "<pre>", "</pre>"
|
|
|
|
_Unit = tuple[str, list[str]] # ("text", [html_line]) | ("pre", [escaped_lines])
|
|
|
|
|
|
def _esc(s: str) -> str:
|
|
return s.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
|
|
|
|
def _inline(md: str) -> str:
|
|
"""Escape HTML and convert inline Markdown (links, bold) to Telegram HTML."""
|
|
out: list[str] = []
|
|
pos = 0
|
|
for m in _LINK_RE.finditer(md):
|
|
out.append(_esc(md[pos : m.start()]))
|
|
url = _esc(m.group(2)).replace('"', """)
|
|
out.append(f'<a href="{url}">{_esc(m.group(1))}</a>')
|
|
pos = m.end()
|
|
out.append(_esc(md[pos:]))
|
|
return _BOLD_RE.sub(r"<b>\1</b>", "".join(out))
|
|
|
|
|
|
def _width(s: str) -> int:
|
|
"""Approximate display width (CJK / emoji count as 2 columns)."""
|
|
w = 0
|
|
for ch in s:
|
|
if unicodedata.combining(ch):
|
|
continue
|
|
if unicodedata.east_asian_width(ch) in ("W", "F") or ord(ch) >= 0x1F000:
|
|
w += 2
|
|
else:
|
|
w += 1
|
|
return w
|
|
|
|
|
|
def _split_row(line: str) -> list[str]:
|
|
inner = line.strip().removeprefix("|")
|
|
if inner.endswith("|") and not inner.endswith("\\|"):
|
|
inner = inner[:-1]
|
|
return [c.strip().replace("\\|", "|") for c in re.split(r"(?<!\\)\|", inner)]
|
|
|
|
|
|
def _format_table(md_rows: list[str]) -> list[str]:
|
|
rows: list[list[str]] = []
|
|
for r in md_rows:
|
|
cells = _split_row(r)
|
|
if cells and all(_SEP_CELL_RE.fullmatch(c) for c in cells if c):
|
|
continue
|
|
rows.append(cells)
|
|
if not rows:
|
|
return []
|
|
ncol = max(len(r) for r in rows)
|
|
rows = [r + [""] * (ncol - len(r)) for r in rows]
|
|
widths = [max(_width(r[i]) for r in rows) for i in range(ncol)]
|
|
out = []
|
|
for r in rows:
|
|
cells = []
|
|
for i, c in enumerate(r):
|
|
pad = " " * (widths[i] - _width(c))
|
|
cells.append(c + pad if i == 0 else pad + c)
|
|
out.append(_esc(" ".join(cells).rstrip()))
|
|
return out
|
|
|
|
|
|
def _to_units(lines: list[str]) -> list[_Unit]:
|
|
units: list[_Unit] = []
|
|
i = 0
|
|
while i < len(lines):
|
|
line = lines[i]
|
|
if line.lstrip().startswith("|"):
|
|
table = []
|
|
while i < len(lines) and lines[i].lstrip().startswith("|"):
|
|
table.append(lines[i])
|
|
i += 1
|
|
pre = _format_table(table)
|
|
if pre:
|
|
units.append(("pre", pre))
|
|
continue
|
|
i += 1
|
|
if _HR_RE.match(line):
|
|
continue
|
|
if not line.strip():
|
|
if units and units[-1] == ("text", [""]):
|
|
continue # collapse blank runs
|
|
units.append(("text", [""]))
|
|
continue
|
|
m = _HEADER_RE.match(line)
|
|
if m:
|
|
units.append(("text", [f"<b>{_inline(m.group(2).strip())}</b>"]))
|
|
elif _BULLET_RE.match(line):
|
|
units.append(("text", ["• " + _inline(_BULLET_RE.sub("", line, count=1))]))
|
|
else:
|
|
units.append(("text", [_inline(line)]))
|
|
while units and units[0] == ("text", [""]):
|
|
units.pop(0)
|
|
while units and units[-1] == ("text", [""]):
|
|
units.pop()
|
|
return units
|
|
|
|
|
|
def _split_sections(markdown: str) -> list[list[str]]:
|
|
sections: list[list[str]] = []
|
|
cur: list[str] = []
|
|
for line in markdown.splitlines():
|
|
if line.startswith("## ") and cur:
|
|
sections.append(cur)
|
|
cur = []
|
|
cur.append(line)
|
|
if cur:
|
|
sections.append(cur)
|
|
return sections
|
|
|
|
|
|
def _units_html(units: list[_Unit]) -> str:
|
|
parts: list[str] = []
|
|
for kind, lines in units:
|
|
if kind == "pre":
|
|
parts.append("\n".join([_PRE_OPEN, *lines, _PRE_CLOSE]))
|
|
else:
|
|
parts.extend(lines)
|
|
return "\n".join(parts)
|
|
|
|
|
|
def _joined_len(lines: list[str]) -> int:
|
|
return sum(len(x) for x in lines) + max(len(lines) - 1, 0)
|
|
|
|
|
|
def _hard_split(line: str, limit: int) -> list[str]:
|
|
return [line[i : i + limit] for i in range(0, len(line), limit)] or [""]
|
|
|
|
|
|
def _pack_units(units: list[_Unit], limit: int) -> list[str]:
|
|
"""Pack one oversized section line by line; <pre> blocks are closed/reopened across chunks."""
|
|
chunks: list[str] = []
|
|
buf: list[str] = []
|
|
|
|
def flush() -> None:
|
|
while buf and not buf[-1]:
|
|
buf.pop()
|
|
if buf:
|
|
chunks.append("\n".join(buf))
|
|
buf.clear()
|
|
|
|
for kind, lines in units:
|
|
if kind == "text":
|
|
for line in lines:
|
|
if not line and not buf:
|
|
continue
|
|
if _joined_len([*buf, line]) > limit:
|
|
flush()
|
|
if len(line) > limit:
|
|
for piece in _hard_split(line, limit):
|
|
flush()
|
|
buf.append(piece)
|
|
continue
|
|
buf.append(line)
|
|
continue
|
|
pre: list[str] = []
|
|
for line in lines:
|
|
if _joined_len([*buf, _PRE_OPEN, *pre, line, _PRE_CLOSE]) <= limit:
|
|
pre.append(line)
|
|
continue
|
|
if pre:
|
|
buf.extend([_PRE_OPEN, *pre, _PRE_CLOSE])
|
|
pre = []
|
|
flush()
|
|
pre.append(line) # may exceed limit only if a single line is longer than limit
|
|
if pre:
|
|
buf.extend([_PRE_OPEN, *pre, _PRE_CLOSE])
|
|
flush()
|
|
return chunks
|
|
|
|
|
|
def render_telegram_chunks(markdown: str, limit: int = 4000) -> list[str]:
|
|
"""Convert the briefing Markdown to Telegram HTML and split it into chunks of at most ``limit`` chars.
|
|
|
|
Sections (``## `` headers) are packed greedily; an oversized section is split on lines, and
|
|
``<pre>`` blocks are closed and reopened so every chunk is balanced HTML.
|
|
"""
|
|
chunks: list[str] = []
|
|
cur = ""
|
|
for section in _split_sections(markdown):
|
|
units = _to_units(section)
|
|
if not units:
|
|
continue
|
|
html = _units_html(units)
|
|
candidate = html if not cur else f"{cur}\n\n{html}"
|
|
if len(candidate) <= limit:
|
|
cur = candidate
|
|
continue
|
|
if cur:
|
|
chunks.append(cur)
|
|
cur = ""
|
|
if len(html) <= limit:
|
|
cur = html
|
|
continue
|
|
parts = _pack_units(units, limit)
|
|
if parts:
|
|
chunks.extend(parts[:-1])
|
|
cur = parts[-1]
|
|
if cur:
|
|
chunks.append(cur)
|
|
return chunks
|