dartlab / app.py
github-actions[bot]
sync from 8ccf9d6
8f2d3af
"""DartLab Streamlit Demo β€” AI μ±„νŒ… 기반 κΈ°μ—… 뢄석."""
from __future__ import annotations
import gc
import io
import os
import re
import pandas as pd
import streamlit as st
import dartlab
# ── μ„€μ • ──────────────────────────────────────────────
_MAX_CACHE = 2
_LOGO_URL = "https://raw.githubusercontent.com/eddmpython/dartlab/master/.github/assets/logo.png"
_BLOG_URL = "https://eddmpython.github.io/dartlab/blog/dartlab-easy-start/"
_DOCS_URL = "https://eddmpython.github.io/dartlab/docs/getting-started/quickstart"
_COLAB_URL = "https://colab.research.google.com/github/eddmpython/dartlab/blob/master/notebooks/showcase/01_quickstart.ipynb"
_REPO_URL = "https://github.com/eddmpython/dartlab"
_HAS_OPENAI = bool(os.environ.get("OPENAI_API_KEY"))
if _HAS_OPENAI:
dartlab.llm.configure(provider="openai", api_key=os.environ["OPENAI_API_KEY"])
# ── νŽ˜μ΄μ§€ μ„€μ • ──────────────────────────────────────
st.set_page_config(
page_title="DartLab β€” AI κΈ°μ—… 뢄석",
page_icon=None,
layout="centered",
)
# ── CSS ───────────────────────────────────────────────
st.markdown("""
<style>
/* 닀크 ν…Œλ§ˆ κ°•μ œ */
html, body, [data-testid="stAppViewContainer"],
[data-testid="stApp"], .main, .block-container {
background-color: #050811 !important;
color: #f1f5f9 !important;
}
[data-testid="stHeader"] { background: #050811 !important; }
[data-testid="stSidebar"] { background: #0f1219 !important; }
/* μž…λ ₯ ν•„λ“œ */
input, textarea,
[data-baseweb="input"] input, [data-baseweb="textarea"] textarea,
[data-baseweb="input"], [data-baseweb="base-input"] {
background-color: #0f1219 !important;
color: #f1f5f9 !important;
border-color: #1e2433 !important;
}
/* μ…€λ ‰νŠΈ/λ“œλ‘­λ‹€μš΄ */
[data-baseweb="select"] > div {
background-color: #0f1219 !important;
border-color: #1e2433 !important;
color: #f1f5f9 !important;
}
[data-baseweb="popover"], [data-baseweb="menu"] {
background-color: #0f1219 !important;
}
[data-baseweb="menu"] li { color: #f1f5f9 !important; }
[data-baseweb="menu"] li:hover { background-color: #1a1f2b !important; }
/* λΌλ””μ˜€ */
[data-testid="stRadio"] label { color: #f1f5f9 !important; }
/* λ²„νŠΌ β€” dartlab primary 톡일 */
button, [data-testid="stBaseButton-primary"],
[data-testid="stBaseButton-secondary"],
[data-testid="stFormSubmitButton"] button,
[data-testid="stChatInputSubmitButton"] {
background-color: #ea4647 !important;
color: #fff !important;
border: none !important;
font-weight: 600 !important;
}
button:hover, [data-testid="stBaseButton-primary"]:hover,
[data-testid="stChatInputSubmitButton"]:hover {
background-color: #c83232 !important;
}
[data-testid="stDownloadButton"] button {
background-color: #0f1219 !important;
color: #f1f5f9 !important;
border: 1px solid #1e2433 !important;
}
[data-testid="stDownloadButton"] button:hover {
border-color: #ea4647 !important;
color: #ea4647 !important;
background-color: #0f1219 !important;
}
/* expander 토글은 배경색 제거 */
[data-testid="stExpander"] button {
background-color: transparent !important;
color: #f1f5f9 !important;
}
/* Expander */
[data-testid="stExpander"] {
background-color: #0f1219 !important;
border-color: #1e2433 !important;
}
/* Chat */
[data-testid="stChatMessage"] {
background-color: #0a0e17 !important;
border-color: #1e2433 !important;
}
[data-testid="stChatInput"], [data-testid="stChatInput"] textarea {
background-color: #0f1219 !important;
border-color: #1e2433 !important;
color: #f1f5f9 !important;
}
/* ν…μŠ€νŠΈ */
p, span, label, h1, h2, h3, h4, h5, h6,
[data-testid="stMarkdownContainer"],
[data-testid="stMarkdownContainer"] p {
color: #f1f5f9 !important;
}
[data-testid="stCaption"] { color: #64748b !important; }
/* DataFrame */
[data-testid="stDataFrame"] { font-variant-numeric: tabular-nums; }
/* μ»€μŠ€ν…€ */
.dl-header {
text-align: center;
padding: 1.5rem 0 0.5rem;
}
.dl-header img {
border-radius: 50%;
box-shadow: 0 0 48px rgba(234,70,71,0.25);
}
.dl-header h1 {
background: linear-gradient(135deg, #ea4647, #f87171, #ea4647);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
font-size: 2.4rem !important;
font-weight: 800 !important;
margin: 0.5rem 0 0.1rem !important;
letter-spacing: -0.03em;
}
.dl-header .tagline { color: #94a3b8 !important; font-size: 1rem; margin: 0; }
.dl-header .sub { color: #64748b !important; font-size: 0.82rem; margin: 0.15rem 0 0; }
.dl-card {
background: linear-gradient(135deg, #0f1219 0%, #0a0d16 100%);
border: 1px solid #1e2433;
border-radius: 12px;
padding: 1.2rem 1.5rem;
margin: 0.8rem 0;
position: relative;
overflow: hidden;
}
.dl-card::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 3px;
background: linear-gradient(90deg, #ea4647, #f87171, #fb923c);
}
.dl-card h3 { color: #f1f5f9 !important; font-size: 1.3rem !important; margin: 0 0 0.8rem !important; }
.dl-card .meta { display: flex; gap: 2.5rem; flex-wrap: wrap; }
.dl-card .meta-item { display: flex; flex-direction: column; gap: 0.1rem; }
.dl-card .meta-label {
color: #64748b !important; font-size: 0.72rem;
text-transform: uppercase; letter-spacing: 0.08em;
}
.dl-card .meta-value {
color: #e2e8f0 !important; font-size: 1.1rem; font-weight: 600;
font-family: 'JetBrains Mono', monospace;
}
.dl-section {
color: #ea4647 !important;
font-weight: 700 !important;
font-size: 1.05rem !important;
border-bottom: 2px solid #ea4647;
padding-bottom: 0.3rem;
margin: 1rem 0 0.6rem;
}
.dl-footer {
text-align: center;
padding: 1.5rem 0 0.8rem;
border-top: 1px solid #1e2433;
margin-top: 2rem;
color: #475569 !important;
font-size: 0.82rem;
}
.dl-footer a { color: #94a3b8 !important; text-decoration: none; margin: 0 0.5rem; }
.dl-footer a:hover { color: #ea4647 !important; }
.dl-hero-glow {
position: fixed;
top: 0; left: 50%;
transform: translateX(-50%);
width: 600px; height: 400px;
background: radial-gradient(ellipse at top, rgba(234,70,71,0.05) 0%, transparent 60%);
pointer-events: none; z-index: 0;
}
</style>
""", unsafe_allow_html=True)
# ── μœ ν‹Έ ──────────────────────────────────────────────
def _toPandas(df):
"""Polars/pandas DataFrame -> pandas."""
if df is None:
return None
if hasattr(df, "to_pandas"):
return df.to_pandas()
return df
def _formatDf(df: pd.DataFrame) -> pd.DataFrame:
"""숫자λ₯Ό μ²œλ‹¨μœ„ 콀마 λ¬Έμžμ—΄λ‘œ λ³€ν™˜ (μ†Œμˆ˜μ  제거)."""
if df is None or df.empty:
return df
result = df.copy()
for col in result.columns:
if pd.api.types.is_numeric_dtype(result[col]):
result[col] = result[col].apply(
lambda x: f"{int(x):,}" if pd.notna(x) and x == x else ""
)
return result
def _toExcel(df: pd.DataFrame) -> bytes:
"""DataFrame -> Excel bytes."""
buf = io.BytesIO()
df.to_excel(buf, index=False, engine="openpyxl")
return buf.getvalue()
def _showDf(df: pd.DataFrame, key: str = "", downloadName: str = ""):
"""DataFrame ν‘œμ‹œ + Excel λ‹€μš΄λ‘œλ“œ."""
if df is None or df.empty:
st.caption("데이터 μ—†μŒ")
return
st.dataframe(_formatDf(df), use_container_width=True, hide_index=True, key=key or None)
if downloadName:
st.download_button(
label="Excel λ‹€μš΄λ‘œλ“œ",
data=_toExcel(df),
file_name=f"{downloadName}.xlsx",
mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
key=f"dl_{key}" if key else None,
)
@st.cache_resource(max_entries=_MAX_CACHE)
def _getCompany(code: str):
"""μΊμ‹œλœ Company."""
gc.collect()
return dartlab.Company(code)
# ── μ’…λͺ©μ½”λ“œ μΆ”μΆœ ────────────────────────────────────
def _extractCode(message: str) -> str | None:
"""λ©”μ‹œμ§€μ—μ„œ μ’…λͺ©μ½”λ“œ/νšŒμ‚¬λͺ… μΆ”μΆœ."""
msg = message.strip()
# 6자리 숫자
m = re.search(r"\b(\d{6})\b", msg)
if m:
return m.group(1)
# 영문 티컀 (단독 λŒ€λ¬Έμž 1~5자)
m = re.search(r"\b([A-Z]{1,5})\b", msg)
if m:
return m.group(1)
# ν•œκΈ€ νšŒμ‚¬λͺ… β†’ dartlab.search
cleaned = re.sub(
r"(에\s*λŒ€ν•΄|에\s*λŒ€ν•œ|μ—λŒ€ν•΄|μ’€|의|λ₯Ό|을|은|λŠ”|이|κ°€|도|만|λΆ€ν„°|κΉŒμ§€|ν•˜κ³ |μ΄λž‘|λž‘|둜|으둜|와|κ³Ό|ν•œν…Œ|μ—μ„œ|μ—κ²Œ)\b",
" ",
msg,
)
# λΆˆν•„μš”ν•œ 동사/쑰동사 제거
cleaned = re.sub(
r"\b(μ•Œλ €μ€˜|λ³΄μ—¬μ€˜|뢄석|ν•΄μ€˜|해봐|μ–΄λ•Œ|보자|볼래|쀘|ν•΄|μ’€|μš”)\b",
" ",
cleaned,
)
tokens = re.findall(r"[κ°€-힣A-Za-z0-9]+", cleaned)
# κΈ΄ 토큰 μš°μ„  (νšŒμ‚¬λͺ…일 κ°€λŠ₯μ„± λ†’μŒ)
tokens.sort(key=len, reverse=True)
for token in tokens:
if len(token) >= 2:
try:
results = dartlab.search(token)
if results is not None and len(results) > 0:
return str(results[0, "μ’…λͺ©μ½”λ“œ"])
except Exception:
continue
return None
def _detectTopic(message: str) -> str | None:
"""λ©”μ‹œμ§€μ—μ„œ νŠΉμ • topic ν‚€μ›Œλ“œ 감지."""
topicMap = {
"λ°°λ‹Ή": "dividend",
"μ£Όμ£Ό": "majorHolder",
"λŒ€μ£Όμ£Ό": "majorHolder",
"직원": "employee",
"μž„μ›": "executive",
"μž„μ›λ³΄μˆ˜": "executivePay",
"보수": "executivePay",
"μ„Έκ·Έλ¨ΌνŠΈ": "segments",
"λΆ€λ¬Έ": "segments",
"사업뢀": "segments",
"μœ ν˜•μžμ‚°": "tangibleAsset",
"λ¬΄ν˜•μžμ‚°": "intangibleAsset",
"μ›μž¬λ£Œ": "rawMaterial",
"수주": "salesOrder",
"μ œν’ˆ": "productService",
"μžνšŒμ‚¬": "subsidiary",
"쒅속": "subsidiary",
"뢀채": "contingentLiability",
"우발": "contingentLiability",
"νŒŒμƒ": "riskDerivative",
"사채": "bond",
"μ΄μ‚¬νšŒ": "boardOfDirectors",
"감사": "audit",
"μžλ³Έλ³€λ™": "capitalChange",
"μžκΈ°μ£Όμ‹": "treasuryStock",
"μ‚¬μ—…κ°œμš”": "business",
"사업보고": "business",
"μ—°ν˜": "companyHistory",
}
msg = message.lower()
for keyword, topic in topicMap.items():
if keyword in msg:
return topic
return None
# ── AI ────────────────────────────────────────────────
def _askAi(stockCode: str, question: str) -> str:
"""AI 질문. OpenAI μš°μ„ , HF 무료 fallback."""
if _HAS_OPENAI:
try:
q = f"{stockCode} {question}" if stockCode else question
answer = dartlab.ask(q, stream=False, raw=False)
return answer or "응닡 μ—†μŒ"
except Exception as e:
return f"뢄석 μ‹€νŒ¨: {e}"
try:
from huggingface_hub import InferenceClient
token = os.environ.get("HF_TOKEN")
client = InferenceClient(
model="meta-llama/Llama-3.1-8B-Instruct",
token=token if token else None,
)
context = _buildAiContext(stockCode)
systemMsg = (
"당신은 ν•œκ΅­ κΈ°μ—… 재무 뢄석 μ „λ¬Έκ°€μž…λ‹ˆλ‹€. "
"μ•„λž˜ 재무 데이터λ₯Ό λ°”νƒ•μœΌλ‘œ μ‚¬μš©μžμ˜ μ§ˆλ¬Έμ— ν•œκ΅­μ–΄λ‘œ λ‹΅λ³€ν•˜μ„Έμš”. "
"μˆ«μžλŠ” μ²œλ‹¨μœ„ 콀마λ₯Ό μ‚¬μš©ν•˜κ³ , κ·Όκ±°λ₯Ό λͺ…ν™•νžˆ μ œμ‹œν•˜μ„Έμš”.\n\n"
f"{context}"
)
response = client.chat_completion(
messages=[
{"role": "system", "content": systemMsg},
{"role": "user", "content": question},
],
max_tokens=1024,
)
return response.choices[0].message.content or "응닡 μ—†μŒ"
except Exception as e:
return f"AI 뢄석 μ‹€νŒ¨: {e}"
def _buildAiContext(stockCode: str) -> str:
"""AI μ»¨ν…μŠ€νŠΈ ꡬ성."""
try:
c = _getCompany(stockCode)
except Exception:
return f"μ’…λͺ©μ½”λ“œ: {stockCode}"
parts = [f"κΈ°μ—…: {c.corpName} ({c.stockCode}), μ‹œμž₯: {c.market}"]
for name, attr in [("μ†μ΅κ³„μ‚°μ„œ", "IS"), ("μž¬λ¬΄μƒνƒœν‘œ", "BS"), ("μž¬λ¬΄λΉ„μœ¨", "ratios")]:
try:
df = _toPandas(getattr(c, attr, None))
if df is not None and not df.empty:
parts.append(f"\n[{name}]\n{df.head(15).to_string()}")
except Exception:
pass
return "\n".join(parts)
# ── λŒ€μ‹œλ³΄λ“œ λ Œλ”λ§ ──────────────────────────────────
def _renderCompanyCard(c):
"""κΈ°μ—… μΉ΄λ“œ."""
currency = ""
if hasattr(c, "currency") and c.currency:
currency = c.currency
currencyHtml = (
f"<div class='meta-item'><span class='meta-label'>톡화</span>"
f"<span class='meta-value'>{currency}</span></div>"
if currency else ""
)
st.markdown(f"""
<div class="dl-card">
<h3>{c.corpName}</h3>
<div class="meta">
<div class="meta-item">
<span class="meta-label">μ’…λͺ©μ½”λ“œ</span>
<span class="meta-value">{c.stockCode}</span>
</div>
<div class="meta-item">
<span class="meta-label">μ‹œμž₯</span>
<span class="meta-value">{c.market}</span>
</div>
{currencyHtml}
</div>
</div>
""", unsafe_allow_html=True)
def _renderFullDashboard(c, code: str):
"""전체 재무 λŒ€μ‹œλ³΄λ“œ."""
_renderCompanyCard(c)
# μž¬λ¬΄μ œν‘œ
st.markdown('<div class="dl-section">μž¬λ¬΄μ œν‘œ</div>', unsafe_allow_html=True)
for label, attr in [("IS (μ†μ΅κ³„μ‚°μ„œ)", "IS"), ("BS (μž¬λ¬΄μƒνƒœν‘œ)", "BS"),
("CF (ν˜„κΈˆνλ¦„ν‘œ)", "CF"), ("ratios (μž¬λ¬΄λΉ„μœ¨)", "ratios")]:
with st.expander(label, expanded=(attr == "IS")):
try:
df = _toPandas(getattr(c, attr, None))
_showDf(df, key=f"dash_{attr}", downloadName=f"{code}_{attr}")
except Exception:
st.caption("λ‘œλ“œ μ‹€νŒ¨")
# Sections
topics = []
try:
topics = list(c.topics) if c.topics else []
except Exception:
pass
if topics:
st.markdown('<div class="dl-section">κ³΅μ‹œ 데이터</div>', unsafe_allow_html=True)
selectedTopic = st.selectbox("topic", topics, label_visibility="collapsed", key="dash_topic")
if selectedTopic:
try:
result = c.show(selectedTopic)
if result is not None:
if hasattr(result, "to_pandas"):
_showDf(_toPandas(result), key="dash_sec", downloadName=f"{code}_{selectedTopic}")
else:
st.markdown(str(result))
except Exception as e:
st.caption(f"쑰회 μ‹€νŒ¨: {e}")
def _renderTopicData(c, code: str, topic: str):
"""νŠΉμ • topic λ°μ΄ν„°λ§Œ λ Œλ”λ§."""
try:
result = c.show(topic)
if result is not None:
if hasattr(result, "to_pandas"):
_showDf(_toPandas(result), key=f"topic_{topic}", downloadName=f"{code}_{topic}")
else:
st.markdown(str(result))
else:
st.caption(f"'{topic}' 데이터 μ—†μŒ")
except Exception as e:
st.caption(f"쑰회 μ‹€νŒ¨: {e}")
# ── ν”„λ¦¬λ‘œλ“œ ──────────────────────────────────────────
@st.cache_resource
def _warmup():
"""listing μΊμ‹œ."""
try:
dartlab.search("μ‚Όμ„±μ „μž")
except Exception:
pass
return True
_warmup()
# ── 헀더 ──────────────────────────────────────────────
st.markdown(f"""
<div class="dl-hero-glow"></div>
<div class="dl-header">
<img src="{_LOGO_URL}" width="80" height="80" alt="DartLab">
<h1>DartLab</h1>
<p class="tagline">μ’…λͺ©μ½”λ“œ ν•˜λ‚˜. κΈ°μ—…μ˜ 전체 이야기.</p>
<p class="sub">DART / EDGAR κ³΅μ‹œ 데이터λ₯Ό κ΅¬μ‘°ν™”ν•˜μ—¬ μ œκ³΅ν•©λ‹ˆλ‹€</p>
</div>
""", unsafe_allow_html=True)
# ── μ„Έμ…˜ μ΄ˆκΈ°ν™” ──────────────────────────────────────
if "messages" not in st.session_state:
st.session_state.messages = []
if "code" not in st.session_state:
st.session_state.code = ""
# ── λŒ€μ‹œλ³΄λ“œ μ˜μ—­ (μ’…λͺ©μ΄ 있으면 ν‘œμ‹œ) ────────────────
if st.session_state.code:
try:
_dashCompany = _getCompany(st.session_state.code)
_renderFullDashboard(_dashCompany, st.session_state.code)
except Exception as e:
st.error(f"κΈ°μ—… λ‘œλ“œ μ‹€νŒ¨: {e}")
st.markdown("---")
# ── μ±„νŒ… μ˜μ—­ ────────────────────────────────────────
# νžˆμŠ€ν† λ¦¬ ν‘œμ‹œ
for msg in st.session_state.messages:
with st.chat_message(msg["role"]):
st.markdown(msg["content"])
# μž…λ ₯
if prompt := st.chat_input("μ‚Όμ„±μ „μžμ— λŒ€ν•΄ μ•Œλ €μ€˜, λ°°λ‹Ή ν˜„ν™©μ€? ..."):
# μ‚¬μš©μž λ©”μ‹œμ§€ ν‘œμ‹œ
st.session_state.messages.append({"role": "user", "content": prompt})
with st.chat_message("user"):
st.markdown(prompt)
# μ’…λͺ©μ½”λ“œ μΆ”μΆœ μ‹œλ„
newCode = _extractCode(prompt)
if newCode and newCode != st.session_state.code:
st.session_state.code = newCode
code = st.session_state.code
if not code:
# μ’…λͺ© λͺ» 찾음
reply = "μ’…λͺ©μ„ μ°Ύμ§€ λͺ»ν–ˆμŠ΅λ‹ˆλ‹€. νšŒμ‚¬λͺ…μ΄λ‚˜ μ’…λͺ©μ½”λ“œλ₯Ό ν¬ν•¨ν•΄μ„œ λ‹€μ‹œ μ§ˆλ¬Έν•΄μ£Όμ„Έμš”.\n\n예: μ‚Όμ„±μ „μžμ— λŒ€ν•΄ μ•Œλ €μ€˜, 005930 뢄석, AAPL 재무"
st.session_state.messages.append({"role": "assistant", "content": reply})
with st.chat_message("assistant"):
st.markdown(reply)
else:
# 응닡 생성
with st.chat_message("assistant"):
# νŠΉμ • topic 감지
topic = _detectTopic(prompt)
if topic:
# νŠΉμ • topic만 보여주기
try:
c = _getCompany(code)
_renderTopicData(c, code, topic)
except Exception:
pass
# AI μš”μ•½
with st.spinner("뢄석 쀑..."):
aiAnswer = _askAi(code, prompt)
st.markdown(aiAnswer)
st.session_state.messages.append({"role": "assistant", "content": aiAnswer})
# λŒ€μ‹œλ³΄λ“œ 갱신을 μœ„ν•΄ rerun
if newCode and newCode != "":
st.rerun()
# ── 초기 μ•ˆλ‚΄ (λŒ€ν™” 없을 λ•Œ) ─────────────────────────
if not st.session_state.messages and not st.session_state.code:
st.markdown("""
<div style="text-align: center; color: #64748b; padding: 2rem 1rem;">
<p style="font-size: 1.1rem; color: #94a3b8;">
μ•„λž˜ μž…λ ₯창에 μžμ—°μ–΄λ‘œ μ§ˆλ¬Έν•˜μ„Έμš”
</p>
<p style="margin-top: 0.5rem;">
<code>μ‚Όμ„±μ „μžμ— λŒ€ν•΄ μ•Œλ €μ€˜</code> &middot;
<code>005930 뢄석</code> &middot;
<code>AAPL 재무 λ³΄μ—¬μ€˜</code>
</p>
<p style="margin-top: 0.3rem; font-size: 0.85rem;">
μ’…λͺ©μ„ λ§ν•˜λ©΄ μž¬λ¬΄μ œν‘œ/κ³΅μ‹œ 데이터가 λ°”λ‘œ ν‘œμ‹œλ˜κ³ , AIκ°€ 뢄석을 λ§λΆ™μž…λ‹ˆλ‹€
</p>
</div>
""", unsafe_allow_html=True)
# ── ν‘Έν„° ──────────────────────────────────────────────
st.markdown(f"""
<div class="dl-footer">
<a href="{_BLOG_URL}">초보자 κ°€μ΄λ“œ</a> /
<a href="{_DOCS_URL}">곡식 λ¬Έμ„œ</a> /
<a href="{_COLAB_URL}">Colab</a> /
<a href="{_REPO_URL}">GitHub</a>
<br><span style="color:#334155; font-size:0.78rem; margin-top:0.4rem; display:inline-block;">
pip install dartlab
</span>
</div>
""", unsafe_allow_html=True)