Spaces:
Running
Running
| import streamlit as st | |
| import yfinance as yf | |
| import FinanceDataReader as fdr | |
| import pandas as pd | |
| import plotly.graph_objects as go | |
| from huggingface_hub import InferenceClient | |
| from duckduckgo_search import DDGS | |
| import json | |
| import datetime | |
| import re | |
| import time | |
| import random | |
| # 1. ํ์ด์ง ์ค์ | |
| st.set_page_config(page_title="Pocket Quant AI", page_icon="๐ง ", layout="wide") | |
| # 2. ํ ํฐ ํ์ธ | |
| if "HF_TOKEN" in st.secrets: | |
| client = InferenceClient(api_key=st.secrets["HF_TOKEN"]) | |
| else: | |
| st.error("๐จ HF_TOKEN required in secrets.") | |
| st.stop() | |
| # ============================================================================== | |
| # ๐ [UI] ๋ค๊ตญ์ด ๋์ ๋๋ฆฌ | |
| # ============================================================================== | |
| UI_TEXT = { | |
| "KR": { | |
| "sidebar_title": "๐ง Pocket Quant AI", | |
| "sidebar_weather": "๐ ์ค๋์ ์์ฅ ๋ ์จ (Market Weather)", | |
| "menu_search": "๐ AI ์ค๋งํธ ๊ฒ์ (์ค์ +๋ด์ค)", | |
| "menu_scanner": "๐ก ๊ธ๋ก๋ฒ ์์ฅ ์ค์บ๋", | |
| "search_title": "๐ง AI ์ฃผ์ ๋น์ (๋ถ๊ธฐ ์ค์ ๋ถ์)", | |
| "search_placeholder": "์: ์ผ์ฑ์ ์ ์ค์ ๋ถ์, ๋ชฝ๊ณจ ๊ณ ๋น ์บ์๋ฏธ์ด...", | |
| "btn_analyze": "๋ถ์ ์์", | |
| "status_thinking": "๐ง AI๊ฐ ๋ถ๊ธฐ ์ค์ ๊ณผ ์์ฅ์ ๋ถ์ ์ค์ ๋๋ค...", | |
| "status_news": "๐ฐ ๋ด์ค ๋ฐ ์ฌ๋ฌด์ ํ ์์ง ์ค...", | |
| "metric_price": "ํ์ฌ๊ฐ", | |
| "metric_source": "์ถ์ฒ", | |
| "expander_news": "๐ฐ ๋ด์ค ์๋ฌธ ๋ณด๊ธฐ", | |
| "insight_title": "๐ง AI ์ข ํฉ ๋ถ์ ๋ฆฌํฌํธ", | |
| "scanner_title": "๐ก ๊ธ๋ก๋ฒ ์์ฅ ์ ๊ดํ (Frontier Included)", | |
| "scanner_caption": "โป 100๊ฐ ์ค์บ ํ, ์์ ์ข ๋ชฉ์ ๋ถ๊ธฐ ์ค์ ํ๋ฆ์ ๋ถ์ํฉ๋๋ค.", | |
| "btn_scan": "์ ์ฒด ์ข ๋ชฉ ์ค์บ & AI ๋ฆฌํฌํธ", | |
| "tab_gainers": "๐ฅ ๊ธ๋ฑ Top 10", | |
| "tab_losers": "๐ง ๊ธ๋ฝ Top 10", | |
| "col_name": "๊ธฐ์ ๋ช ", | |
| "col_price": "๊ฐ๊ฒฉ", | |
| "col_change": "๋ฑ๋ฝ๋ฅ ", | |
| "col_per": "PER", | |
| "col_pbr": "PBR", | |
| "col_source": "์ถ์ฒ", | |
| "briefing_title": "๐ง AI Market Briefing (์ค๋์ ์์ฅ ๋ถ์)", | |
| "msg_fail": "๋ฐ์ดํฐ ์์ง ์คํจ", | |
| "llm_lang_instruction": "Korean" | |
| }, | |
| "EN": { | |
| "sidebar_title": "๐ง Pocket Quant AI", | |
| "sidebar_weather": "๐ Market Weather", | |
| "menu_search": "๐ AI Smart Search (Financials)", | |
| "menu_scanner": "๐ก Global Market Scanner", | |
| "search_title": "๐ง AI Stock Assistant (Quarterly Analysis)", | |
| "search_placeholder": "e.g. Analyze Samsung Electronics financials...", | |
| "btn_analyze": "Analyze", | |
| "status_thinking": "๐ง AI is analyzing quarterly financials...", | |
| "status_news": "๐ฐ Fetching news & financials...", | |
| "metric_price": "Price", | |
| "metric_source": "Source", | |
| "expander_news": "๐ฐ View Source News", | |
| "insight_title": "๐ง AI Comprehensive Report", | |
| "scanner_title": "๐ก Global Market Scanner (Frontier Included)", | |
| "scanner_caption": "โป Scans stocks, then analyzes quarterly trends for top movers.", | |
| "btn_scan": "Scan & Generate Report", | |
| "tab_gainers": "๐ฅ Top 10 Gainers", | |
| "tab_losers": "๐ง Top 10 Losers", | |
| "col_name": "Company", | |
| "col_price": "Price", | |
| "col_change": "Change(%)", | |
| "col_per": "PER", | |
| "col_pbr": "PBR", | |
| "col_source": "Source", | |
| "briefing_title": "๐ง AI Market Briefing", | |
| "msg_fail": "Data collection failed", | |
| "llm_lang_instruction": "English" | |
| } | |
| } | |
| # ============================================================================== | |
| # ๐พ [๋ฐ์ดํฐ] ํฐ์ปค ๋งคํ (Full List) | |
| # ============================================================================== | |
| TICKER_NAMES = { | |
| # (๊ธฐ์กด ๋ฆฌ์คํธ ์ ์ง - ๋๋ฌด ๊ธธ์ด์ ์๋ตํ์ง๋ง ์ค์ ์ฝ๋์ ๊ผญ ๋ฃ์ผ์ธ์!) | |
| "VIC.VN": "Vingroup", "VHM.VN": "Vinhomes", "VCB.VN": "Vietcombank", "VNM.VN": "Vinamilk", | |
| "005930.KS": "Samsung Elec", "000660.KS": "SK Hynix", "AAPL": "Apple", "NVDA": "NVIDIA", | |
| "APU.MN": "APU JSC", "GP.BD": "Grameenphone", # Frontier | |
| # ... (๋๋จธ์ง ๊ตญ๊ฐ๋ค ํฌํจ) ... | |
| } | |
| # (์ค์บ๋์ฉ MARKET_SAMPLES๋ ๊ธฐ์กด ์ ์ง) | |
| MARKET_SAMPLES = { | |
| "๐ป๐ณ Vietnam": ["VIC.VN", "VHM.VN", "VRE.VN", "VNM.VN", "MSN.VN", "GAS.VN", "HPG.VN", "NVL.VN", "PDR.VN", "DIG.VN", "SSI.VN", "VND.VN", "MWG.VN", "FRT.VN", "FPT.VN", "STB.VN", "TCB.VN", "VCB.VN"], | |
| "๐บ๐ธ USA": ["AAPL", "MSFT", "GOOGL", "AMZN", "NVDA", "META", "TSLA", "AMD", "INTC", "PLTR", "COIN", "LLY", "XOM", "DIS"], | |
| "๐ฐ๐ท Korea": ["005930.KS", "000660.KS", "005380.KS", "000270.KS", "035420.KS", "035720.KS", "005490.KS", "086520.KQ", "247540.KQ", "207940.KS"], | |
| "๐ฒ๐ณ Mongolia (Frontier)": ["APU.MN", "TTL.MN", "GOV.MN", "MNDL.MN", "SUU.MN"], | |
| "๐ง๐ฉ Bangladesh (Frontier)": ["GP.BD", "SQURPHARMA.BD", "BATBC.BD", "BEXIMCO.BD"], | |
| "๐ญ๐ฐ Hong Kong": ["1299.HK", "0388.HK", "0005.HK", "0700.HK", "9988.HK", "3690.HK"], | |
| "๐น๐ญ Thailand": ["PTT.BK", "AOT.BK", "CPALL.BK", "ADVANC.BK", "KBANK.BK"], | |
| "๐ฎ๐ฉ Indonesia": ["BBCA.JK", "BBRI.JK", "BMRI.JK", "TLKM.JK", "ASII.JK", "GOTO.JK"], | |
| "๐น๐ผ Taiwan": ["2330.TW", "2317.TW", "2454.TW", "2881.TW", "2308.TW"], | |
| "๐ฏ๐ต Japan": ["7203.T", "6758.T", "9984.T", "8035.T", "6861.T"], | |
| "๐ฎ๐ณ India": ["RELIANCE.NS", "TCS.NS", "HDFCBANK.NS", "INFY.NS"] | |
| } | |
| # ============================================================================== | |
| # ๐ ๏ธ [์์ง] ์ง์ & ๋ฐ์ดํฐ ์์ง (Fundamentals ๊ฐํ) | |
| # ============================================================================== | |
| def get_market_indices(): | |
| indices = {"S&P 500": "^GSPC", "NASDAQ": "^IXIC", "KOSPI": "^KS11", "VN-INDEX": "^VNINDEX"} | |
| data = {} | |
| try: | |
| df = yf.download(list(indices.values()), period="5d", progress=False)['Close'] | |
| for name, ticker in indices.items(): | |
| if ticker in df.columns: | |
| series = df[ticker].dropna() | |
| if len(series) >= 2: | |
| curr, prev = series.iloc[-1], series.iloc[-2] | |
| data[name] = (curr, ((curr - prev)/prev)*100) | |
| except: pass | |
| return data | |
| # [NEW] ์ฌ๋ฌด ๋ฐ์ดํฐ + ๋ถ๊ธฐ ์ค์ ๊ฐ์ ธ์ค๊ธฐ | |
| def get_fundamentals(ticker): | |
| """ | |
| ๊ธฐ๋ณธ ์งํ(PER/PBR) + ์ต๊ทผ 4๋ถ๊ธฐ ์ค์ (๋งค์ถ/์ด์ต) ์ถ์ถ | |
| """ | |
| data = {"PER": "N/A", "PBR": "N/A", "ROE": "N/A", "Quarterly": pd.DataFrame()} | |
| try: | |
| stock = yf.Ticker(ticker) | |
| info = stock.info | |
| # 1. ๊ธฐ๋ณธ ์งํ | |
| per = info.get('trailingPE') | |
| pbr = info.get('priceToBook') | |
| roe = info.get('returnOnEquity') | |
| data["PER"] = f"{per:.2f}" if per else "N/A" | |
| data["PBR"] = f"{pbr:.2f}" if pbr else "N/A" | |
| data["ROE"] = f"{roe*100:.2f}%" if roe else "N/A" | |
| # 2. ๋ถ๊ธฐ๋ณ ์ค์ (์ต๊ทผ 4๋ถ๊ธฐ) | |
| q_fin = stock.quarterly_financials | |
| if not q_fin.empty: | |
| # ์ฃผ์ ํญ๋ชฉ๋ง ์ถ์ถ (Total Revenue, Net Income) - ํค ์ด๋ฆ์ ์ผํ ๋ฒ์ ์ ๋ฐ๋ผ ๋ค๋ฅผ ์ ์์ด ์์ธ์ฒ๋ฆฌ | |
| target_rows = [r for r in ['Total Revenue', 'Operating Revenue', 'Net Income', 'Net Income Common Stockholders'] if r in q_fin.index] | |
| if target_rows: | |
| # ์ต๊ทผ 4๊ฐ ๋ถ๊ธฐ๋ง, ๋ณด๊ธฐ ์ข๊ฒ Transpose | |
| recent_q = q_fin.loc[target_rows].iloc[:, :4].T | |
| # ๋ ์ง ํฌ๋งท ์ ๋ฆฌ (YYYY-MM-DD) | |
| recent_q.index = [d.strftime('%Y-%m') for d in recent_q.index] | |
| data["Quarterly"] = recent_q | |
| except: pass | |
| return data | |
| FAKE_HEADERS = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'} | |
| def fetch_news_robust(keyword): | |
| summary = "" | |
| seen_urls = set() | |
| try: | |
| with DDGS() as ddgs: | |
| results = list(ddgs.news(keyword, timelimit="m", max_results=3)) | |
| if not results: results = list(ddgs.news(keyword, max_results=3)) | |
| if not results: results = list(ddgs.text(f"{keyword} latest news", max_results=3)) | |
| for r in results: | |
| url = r.get('url') or r.get('href') | |
| if url not in seen_urls: | |
| summary += f"[{r.get('date', '?')}] {r.get('title', '')}\n" | |
| seen_urls.add(url) | |
| except: return "No news." | |
| return summary if summary else "No news." | |
| def scrape_price_from_web(name, ticker): | |
| try: | |
| time.sleep(random.uniform(1.0, 1.5)) | |
| with DDGS() as ddgs: | |
| query = f"{name} {ticker} stock price quote today" | |
| results = ddgs.text(query, max_results=2) | |
| blob = " ".join([r['body'] for r in results]) | |
| match = re.search(r'(\d{1,3}(,\d{3})*(\.\d+)?)', blob) | |
| if match: return float(match.group(0).replace(",", "")), 0.0 | |
| except: pass | |
| return None, None | |
| def get_price_data_robust(ticker, name): | |
| try: | |
| fdr_symbol = ticker | |
| if ".VN" in ticker: fdr_symbol = ticker.split('.')[0] | |
| elif ".JK" in ticker: fdr_symbol = f"IDX:{ticker.split('.')[0]}" | |
| elif ".KS" in ticker or ".KQ" in ticker: fdr_symbol = ticker.split('.')[0] | |
| hist = fdr.DataReader(fdr_symbol, start=(datetime.datetime.now() - datetime.timedelta(days=7))) | |
| if not hist.empty: | |
| curr, prev = hist['Close'].iloc[-1], hist['Close'].iloc[-2] | |
| return curr, ((curr - prev)/prev)*100, "FDR" | |
| except: pass | |
| try: | |
| stock = yf.Ticker(ticker) | |
| price = stock.fast_info.last_price | |
| prev = stock.fast_info.previous_close | |
| if price and prev: return price, ((price - prev)/prev)*100, "Yahoo" | |
| except: pass | |
| price, pct = scrape_price_from_web(name, ticker) | |
| if price is not None: return price, pct, "Web Search" | |
| return None, None, "Fail" | |
| def get_chart_data(ticker): | |
| try: | |
| fdr_symbol = ticker | |
| if ".VN" in ticker: fdr_symbol = ticker.split('.')[0] | |
| elif ".JK" in ticker: fdr_symbol = f"IDX:{ticker.split('.')[0]}" | |
| elif ".T" in ticker: fdr_symbol = f"TSE:{ticker.split('.')[0]}" | |
| hist = fdr.DataReader(fdr_symbol, start=(datetime.datetime.now() - datetime.timedelta(days=90)).strftime('%Y-%m-%d')) | |
| if not hist.empty: return hist.rename(columns={'Close':'Close', 'Open':'Open', 'High':'High', 'Low':'Low'}), "FDR" | |
| except: pass | |
| try: | |
| hist = yf.Ticker(ticker).history(period="3mo") | |
| if not hist.empty: return hist, "Yahoo" | |
| except: pass | |
| return pd.DataFrame(), "None" | |
| def identify_targets_with_ai(user_query): | |
| try: | |
| prompt = f""" | |
| [ROLE] Entity Resolver. [QUERY] "{user_query}" | |
| [TASK] Identify ALL companies. [OUTPUT JSON LIST] | |
| [ {{ "name": "EngName", "ticker": "TICKER", "eng_key": "Name news", "native_key": "LocalName news" }} ] | |
| """ | |
| messages = [{"role": "user", "content": prompt}] | |
| response = client.chat.completions.create(model="Qwen/Qwen2.5-72B-Instruct", messages=messages, max_tokens=300) | |
| content = re.sub(r"```json|```", "", response.choices[0].message.content.strip()).strip() | |
| return json.loads(content) | |
| except: return [] | |
| def get_polyglot_news(eng_key, native_key): | |
| return f"Global:\n{fetch_news_robust(eng_key)}\nLocal:\n{fetch_news_robust(native_key)}" | |
| def parse_stream(stream): | |
| for chunk in stream: | |
| if chunk.choices: yield chunk.choices[0].delta.content or "" | |
| def plot_candle_chart(hist, title, source): | |
| fig = go.Figure(data=[go.Candlestick(x=hist.index, open=hist['Open'], high=hist['High'], low=hist['Low'], close=hist['Close'], name="Price")]) | |
| fig.update_layout(title=f"{title} ({source})", height=350, margin=dict(l=10, r=10, t=30, b=10), template="plotly_dark", paper_bgcolor='rgba(0,0,0,0)') | |
| return fig | |
| def plot_bar_chart(df, lang_data): | |
| target_col = lang_data['col_name'] | |
| colors = ['#00FF00' if x > 0 else '#FF0000' for x in df['Change(%)']] | |
| fig = go.Figure(go.Bar(x=df[target_col], y=df['Change(%)'], marker_color=colors, text=df['Change(%)'].apply(lambda x: f"{x:.2f}%"))) | |
| fig.update_layout(title="Market Heatmap", height=350, margin=dict(l=10, r=10, t=30, b=10), template="plotly_dark", paper_bgcolor='rgba(0,0,0,0)') | |
| return fig | |
| # ============================================================================== | |
| # UI | |
| # ============================================================================== | |
| with st.sidebar: | |
| lang_code = st.selectbox("Language / ์ธ์ด", ["KR", "EN"]) | |
| T = UI_TEXT[lang_code] | |
| st.title(T['sidebar_title']) | |
| st.divider() | |
| st.caption(T['sidebar_weather']) | |
| market_data = get_market_indices() | |
| market_context_str = "" | |
| if market_data: | |
| c1, c2 = st.columns(2) | |
| if "S&P 500" in market_data: | |
| p, c = market_data["S&P 500"] | |
| c1.metric("๐บ๐ธ S&P 500", f"{p:,.0f}", f"{c:+.2f}%") | |
| market_context_str += f"S&P 500 {c:+.2f}%, " | |
| if "NASDAQ" in market_data: | |
| p, c = market_data["NASDAQ"] | |
| c2.metric("๐บ๐ธ NASDAQ", f"{p:,.0f}", f"{c:+.2f}%") | |
| market_context_str += f"NASDAQ {c:+.2f}%, " | |
| c3, c4 = st.columns(2) | |
| if "KOSPI" in market_data: | |
| p, c = market_data["KOSPI"] | |
| c3.metric("๐ฐ๐ท KOSPI", f"{p:,.0f}", f"{c:+.2f}%") | |
| if "VN-INDEX" in market_data: | |
| p, c = market_data["VN-INDEX"] | |
| c4.metric("๐ป๐ณ VN-IDX", f"{p:,.0f}", f"{c:+.2f}%") | |
| market_context_str += f"Vietnam Index {c:+.2f}%." | |
| else: st.caption("Loading failed.") | |
| st.divider() | |
| menu = st.radio("MENU", [T['menu_search'], T['menu_scanner']], index=0) | |
| # --- AI ๊ฒ์ (์ฌ๋ฌด์ ํ + ๋ถ๊ธฐ ์ค์ ํฌํจ) --- | |
| if menu == T['menu_search']: | |
| st.subheader(T['search_title']) | |
| c1, c2 = st.columns([3, 1]) | |
| with c1: query = st.text_input("Query", placeholder=T['search_placeholder'], label_visibility="collapsed") | |
| with c2: btn = st.button(T['btn_analyze'], use_container_width=True) | |
| if btn: | |
| with st.status(T['status_thinking'], expanded=True) as status: | |
| targets = identify_targets_with_ai(query) | |
| if targets: | |
| collected_data = [] | |
| tabs = st.tabs([t.get('name', 'Unknown') for t in targets]) | |
| for i, target in enumerate(targets): | |
| name, ticker = target.get('name'), target.get('ticker') | |
| eng_key, native_key = target.get('eng_key'), target.get('native_key') | |
| with tabs[i]: | |
| st.info(f"๐ **{name} ({ticker})**") | |
| # [NEW] ์ฌ๋ฌด ๋ฐ์ดํฐ (๋ถ๊ธฐ ํฌํจ) | |
| fund_data = get_fundamentals(ticker) | |
| q_df = fund_data['Quarterly'] | |
| # ๊ธฐ๋ณธ ์งํ ์นด๋ | |
| f1, f2, f3 = st.columns(3) | |
| f1.metric("PER", fund_data["PER"]) | |
| f2.metric("PBR", fund_data["PBR"]) | |
| f3.metric("ROE", fund_data["ROE"]) | |
| # [NEW] ๋ถ๊ธฐ ์ค์ ํ (์์ ๊ฒฝ์ฐ๋ง) | |
| if not q_df.empty: | |
| st.caption("๐ Recent Quarterly Financials (4 Quarters)") | |
| st.dataframe(q_df.style.format("{:,.0f}"), use_container_width=True) | |
| else: | |
| st.caption("โ ๏ธ No quarterly data available via API.") | |
| st.divider() | |
| h, src = get_chart_data(ticker) | |
| news_data = get_polyglot_news(eng_key, native_key) | |
| curr, pct = "N/A", 0 | |
| if not h.empty: | |
| curr, prev = h['Close'].iloc[-1], h['Close'].iloc[-2] | |
| pct = ((curr - prev)/prev)*100 | |
| else: | |
| c_web, p_web, s_web = get_price_data_robust(ticker, name) | |
| if c_web: curr, pct, src = c_web, p_web, s_web | |
| m1, m2 = st.columns(2) | |
| m1.metric(T['metric_price'], f"{curr:,.0f}" if isinstance(curr, (int, float)) else curr, f"{pct:.2f}%") | |
| m2.metric(T['metric_source'], src) | |
| if not h.empty: st.plotly_chart(plot_candle_chart(h, name, src), use_container_width=True) | |
| else: st.warning(f"Chart N/A") | |
| with st.expander(T['expander_news']): st.text(news_data) | |
| # AI์๊ฒ ์ค ๋ฐ์ดํฐ์ ๋ถ๊ธฐ ์ค์ ์ถ๊ฐ | |
| q_str = q_df.to_string() if not q_df.empty else "N/A" | |
| collected_data.append(f""" | |
| [TARGET {i+1}] {name} ({ticker}) | |
| - Price: {curr}, Change: {pct:.2f}% | |
| - Fundamentals: PER {fund_data['PER']}, PBR {fund_data['PBR']}, ROE {fund_data['ROE']} | |
| - Quarterly Trend: \n{q_str} | |
| - News: {news_data[:500]}... | |
| """) | |
| status.update(label="โ OK", state="complete", expanded=False) | |
| st.divider() | |
| st.subheader(T['insight_title']) | |
| prompt = f""" | |
| [ROLE] Global Analyst. [MARKET CONTEXT] {market_context_str} | |
| [USER QUERY] "{query}" [DATA] {chr(10).join(collected_data)} | |
| [TASK] | |
| 1. Comparative Analysis based on price and FUNDAMENTALS (Valuation). | |
| 2. Analyze QUARTERLY TRENDS (Revenue/Income growing or shrinking?). | |
| 3. Verdict based on Market Context + Fundamentals + News. | |
| [LANG] {T['llm_lang_instruction']}. | |
| """ | |
| msg = [{"role": "user", "content": prompt}] | |
| stream = client.chat.completions.create(model="Qwen/Qwen2.5-72B-Instruct", messages=msg, stream=True) | |
| st.write_stream(parse_stream(stream)) | |
| else: st.error("AI Error") | |
| # --- ์์ฅ ์ค์บ๋ & ๋ฆฌํฌํธ (Top 10 ๋ถ๊ธฐ ์ค์ ์ถ๊ฐ) --- | |
| elif menu == T['menu_scanner']: | |
| st.subheader(T['scanner_title']) | |
| st.caption(T['scanner_caption']) | |
| c1, c2 = st.columns([3, 1]) | |
| with c1: target = st.selectbox("Market", list(MARKET_SAMPLES.keys()), label_visibility="collapsed") | |
| with c2: scan = st.button(T['btn_scan']) | |
| if scan: | |
| tickers = MARKET_SAMPLES[target] | |
| results = [] | |
| bar = st.progress(0) | |
| try: batch_data = yf.download(tickers, period="5d", progress=False)['Close'] | |
| except: batch_data = pd.DataFrame() | |
| for i, t in enumerate(tickers): | |
| name_display = TICKER_NAMES.get(t, t) | |
| p, c, s = None, None, None | |
| if not batch_data.empty and t in batch_data.columns: | |
| series = batch_data[t].dropna() | |
| if len(series) >= 2: | |
| p, prev = series.iloc[-1], series.iloc[-2] | |
| c, s = ((p - prev)/prev)*100, "Yahoo (Batch)" | |
| if p is None: p, c, s = get_price_data_robust(t, name_display) | |
| if p is not None: | |
| results.append({T['col_name']: name_display, 'Ticker': t, T['col_price']: p, 'Change(%)': c, T['col_source']: s}) | |
| bar.progress((i+1)/len(tickers)) | |
| if results: | |
| df = pd.DataFrame(results).sort_values('Change(%)', ascending=False) | |
| st.plotly_chart(plot_bar_chart(df, T), use_container_width=True) | |
| # [NEW] Top 10 Gainers/Losers์ ๋ํด์๋ง ์ฌ๋ฌด ๋ฐ์ดํฐ ๋ฐ ์ค์ ์ถ์ด ์ถ๊ฐ ๋ก๋ฉ | |
| df_gainers = df[df['Change(%)'] > 0].head(10).copy() | |
| df_losers = df[df['Change(%)'] < 0].sort_values('Change(%)', ascending=True).head(10).copy() | |
| # AI์๊ฒ ์ค ์์ฝ ํ ์คํธ๋ฅผ ๋ด์ ๋ฆฌ์คํธ | |
| gainers_analysis_data = [] | |
| losers_analysis_data = [] | |
| def process_funds(sub_df, analysis_list): | |
| if sub_df.empty: return sub_df | |
| pers, pbrs = [], [] | |
| for idx, row in sub_df.iterrows(): | |
| t = row['Ticker'] | |
| f = get_fundamentals(t) # ๋ถ๊ธฐ ์ค์ ๋ ์ฌ๊ธฐ์ ๊ฐ์ ธ์ด | |
| pers.append(f['PER']) | |
| pbrs.append(f['PBR']) | |
| # AI์๊ฒ ๋๊ธธ ๋ฐ์ดํฐ ๋ฌธ์์ด ์์ฑ (์ข ๋ชฉ๋ช , ๋ฑ๋ฝ๋ฅ , PER, ๋ถ๊ธฐ ์ถ์ด) | |
| q_data = "N/A" | |
| if not f['Quarterly'].empty: | |
| # ๋ฐ์ดํฐํ๋ ์์ ๋ฌธ์์ด๋ก (์ต๊ทผ 2๋ถ๊ธฐ๋ง ๊ฐ๋ตํ๊ฒ) | |
| q_data = f['Quarterly'].iloc[:, :2].to_string() | |
| analysis_list.append(f"{row[T['col_name']]} ({row['Change(%)']:.1f}%, PER:{f['PER']}) -> Quarterly:\n{q_data}") | |
| sub_df[T['col_per']] = pers | |
| sub_df[T['col_pbr']] = pbrs | |
| return sub_df | |
| with st.spinner("Analyzing fundamentals & quarterly trends for top movers..."): | |
| df_gainers = process_funds(df_gainers, gainers_analysis_data) | |
| df_losers = process_funds(df_losers, losers_analysis_data) | |
| disp_cols = [T['col_name'], 'Ticker', T['col_price'], 'Change(%)', T['col_per'], T['col_pbr'], T['col_source']] | |
| # AI Market Briefing | |
| st.divider() | |
| st.subheader(T['briefing_title']) | |
| prompt = f""" | |
| [ROLE] Chief Market Strategist. | |
| [TARGET MARKET] {target} | |
| [GLOBAL CONTEXT] {market_context_str} | |
| [TOP GAINERS DATA (With Quarterly Trends)] | |
| {chr(10).join(gainers_analysis_data)} | |
| [TOP LOSERS DATA (With Quarterly Trends)] | |
| {chr(10).join(losers_analysis_data)} | |
| [TASK] | |
| 1. **Market Summary**: Sentiment analysis. | |
| 2. **Fundamental Check**: Are the gainers actually making money? (Check Quarterly Revenue/Income trends). | |
| 3. **Top Pick**: Recommend 1 solid stock (Good price action + Good financials). | |
| 4. **Caution**: Warn about a risky stock (Bad financials). | |
| [OUTPUT LANGUAGE] **{T['llm_lang_instruction']}** (Professional tone). | |
| """ | |
| msg = [{"role": "user", "content": prompt}] | |
| stream = client.chat.completions.create(model="Qwen/Qwen2.5-72B-Instruct", messages=msg, stream=True) | |
| st.write_stream(parse_stream(stream)) | |
| st.divider() | |
| c_up, c_down = st.columns(2) | |
| with c_up: | |
| st.success(T['tab_gainers']) | |
| if not df_gainers.empty: st.dataframe(df_gainers[disp_cols].style.format({T['col_price']: "{:,.2f}", "Change(%)": "{:,.2f}%"}), use_container_width=True) | |
| else: st.info("No gainers.") | |
| with c_down: | |
| st.error(T['tab_losers']) | |
| if not df_losers.empty: st.dataframe(df_losers[disp_cols].style.format({T['col_price']: "{:,.2f}", "Change(%)": "{:,.2f}%"}), use_container_width=True) | |
| else: st.info("No losers.") | |
| else: st.warning(T['msg_fail']) |