Spaces:
Sleeping
Sleeping
Update streamlit_app.py
Browse files- streamlit_app.py +142 -72
streamlit_app.py
CHANGED
|
@@ -30,7 +30,7 @@ UI_TEXT = {
|
|
| 30 |
"menu_search": "๐ AI ์ค๋งํธ ๊ฒ์",
|
| 31 |
"menu_scanner": "๐ก ๊ธ๋ก๋ฒ ์์ฅ ์ค์บ๋",
|
| 32 |
"search_title": "๐ง ๋ค๊ตญ์ด AI ์ฃผ์ ๋น์",
|
| 33 |
-
"search_placeholder": "์: ๋ฒ ํธ๋จ
|
| 34 |
"btn_analyze": "๋ถ์ ์์",
|
| 35 |
"status_thinking": "๐ง AI๊ฐ ๋ถ์ ์ค์
๋๋ค...",
|
| 36 |
"info_target": "ํ๊ฒ",
|
|
@@ -41,11 +41,11 @@ UI_TEXT = {
|
|
| 41 |
"metric_source": "์ถ์ฒ",
|
| 42 |
"expander_news": "๐ฐ ๋ด์ค ์๋ฌธ ๋ณด๊ธฐ",
|
| 43 |
"insight_title": "๐ง AI ํฌ์ ๋ฆฌํฌํธ",
|
| 44 |
-
"scanner_title": "๐ก ๊ธ๋ก๋ฒ ์์ฅ ์ ๊ดํ",
|
| 45 |
-
"scanner_caption": "โป
|
| 46 |
-
"btn_scan": "์ค์บ ์์",
|
| 47 |
-
"tab_gainers": "๐ฅ ๊ธ๋ฑ (
|
| 48 |
-
"tab_losers": "๐ง ๊ธ๋ฝ (
|
| 49 |
"col_name": "๊ธฐ์
๋ช
",
|
| 50 |
"col_price": "๊ฐ๊ฒฉ",
|
| 51 |
"col_change": "๋ฑ๋ฝ๋ฅ ",
|
|
@@ -58,7 +58,7 @@ UI_TEXT = {
|
|
| 58 |
"menu_search": "๐ AI Smart Search",
|
| 59 |
"menu_scanner": "๐ก Global Market Scanner",
|
| 60 |
"search_title": "๐ง AI Stock Assistant",
|
| 61 |
-
"search_placeholder": "e.g. Vinamilk,
|
| 62 |
"btn_analyze": "Analyze",
|
| 63 |
"status_thinking": "๐ง AI is analyzing...",
|
| 64 |
"info_target": "Target",
|
|
@@ -69,11 +69,11 @@ UI_TEXT = {
|
|
| 69 |
"metric_source": "Source",
|
| 70 |
"expander_news": "๐ฐ View Source News",
|
| 71 |
"insight_title": "๐ง AI Investment Report",
|
| 72 |
-
"scanner_title": "๐ก Global Market Scanner",
|
| 73 |
-
"scanner_caption": "โป
|
| 74 |
-
"btn_scan": "Start Scan",
|
| 75 |
-
"tab_gainers": "๐ฅ Top Gainers",
|
| 76 |
-
"tab_losers": "๐ง Top Losers",
|
| 77 |
"col_name": "Company",
|
| 78 |
"col_price": "Price",
|
| 79 |
"col_change": "Change(%)",
|
|
@@ -84,38 +84,85 @@ UI_TEXT = {
|
|
| 84 |
}
|
| 85 |
|
| 86 |
# ==============================================================================
|
| 87 |
-
# ๐พ [๋ฐ์ดํฐ] ํฐ์ปค
|
| 88 |
# ==============================================================================
|
| 89 |
TICKER_NAMES = {
|
|
|
|
| 90 |
"VIC.VN": "Vingroup", "VHM.VN": "Vinhomes", "VCB.VN": "Vietcombank", "VNM.VN": "Vinamilk",
|
| 91 |
-
"HPG.VN": "Hoa Phat", "MSN.VN": "Masan Group", "GAS.VN": "PV Gas",
|
| 92 |
-
"
|
| 93 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
"005930.KS": "Samsung Elec", "000660.KS": "SK Hynix", "035420.KS": "NAVER", "035720.KS": "Kakao",
|
| 95 |
-
"005380.KS": "Hyundai Motor", "207940.KS": "Samsung Bio",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
"AAPL": "Apple", "NVDA": "NVIDIA", "TSLA": "Tesla", "AMZN": "Amazon", "MSFT": "Microsoft",
|
| 97 |
-
"GOOGL": "Google", "META": "Meta", "AMD": "AMD",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
"7203.T": "Toyota", "6758.T": "Sony", "9984.T": "SoftBank", "8035.T": "Tokyo Elec",
|
| 99 |
-
"6861.T": "Keyence", "7974.T": "Nintendo",
|
| 100 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
"2330.TW": "TSMC", "2454.TW": "MediaTek", "2317.TW": "Foxconn", "2308.TW": "Delta Elec",
|
| 102 |
-
"RELIANCE.NS": "Reliance
|
| 103 |
}
|
| 104 |
|
| 105 |
MARKET_SAMPLES = {
|
| 106 |
-
"๐ป๐ณ Vietnam
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
"
|
| 113 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
}
|
| 115 |
|
| 116 |
# ==============================================================================
|
| 117 |
-
#
|
| 118 |
# ==============================================================================
|
|
|
|
|
|
|
| 119 |
def fetch_news_robust(keyword):
|
| 120 |
summary = ""
|
| 121 |
seen_urls = set()
|
|
@@ -128,38 +175,36 @@ def fetch_news_robust(keyword):
|
|
| 128 |
if not results:
|
| 129 |
time.sleep(random.uniform(0.5, 1.0))
|
| 130 |
results = list(ddgs.text(f"{keyword} latest news", max_results=3))
|
| 131 |
-
|
| 132 |
for r in results:
|
| 133 |
url = r.get('url') or r.get('href')
|
| 134 |
if url not in seen_urls:
|
| 135 |
-
date = r.get('date', '
|
| 136 |
title = r.get('title', '')
|
| 137 |
summary += f"[{date}] {title}\n"
|
| 138 |
seen_urls.add(url)
|
| 139 |
-
except
|
| 140 |
-
return summary if summary else "No news
|
| 141 |
|
| 142 |
def scrape_price_from_web(name, ticker):
|
| 143 |
try:
|
| 144 |
-
time.sleep(random.uniform(
|
| 145 |
with DDGS() as ddgs:
|
| 146 |
query = f"{name} {ticker} stock price quote today"
|
| 147 |
results = ddgs.text(query, max_results=2)
|
| 148 |
blob = " ".join([r['body'] for r in results])
|
| 149 |
match = re.search(r'(\d{1,3}(,\d{3})*(\.\d+)?)', blob)
|
| 150 |
-
if match:
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
return None, None
|
| 154 |
-
except: return None, None
|
| 155 |
|
| 156 |
def get_price_data_robust(ticker, name):
|
|
|
|
| 157 |
try:
|
| 158 |
fdr_symbol = ticker
|
| 159 |
if ".VN" in ticker: fdr_symbol = ticker.split('.')[0]
|
| 160 |
elif ".JK" in ticker: fdr_symbol = f"IDX:{ticker.split('.')[0]}"
|
| 161 |
elif ".KS" in ticker or ".KQ" in ticker: fdr_symbol = ticker.split('.')[0]
|
| 162 |
-
hist = fdr.DataReader(fdr_symbol, start=(datetime.datetime.now() - datetime.timedelta(days=
|
| 163 |
if not hist.empty:
|
| 164 |
curr = hist['Close'].iloc[-1]
|
| 165 |
prev = hist['Close'].iloc[-2] if len(hist) > 1 else curr
|
|
@@ -167,18 +212,16 @@ def get_price_data_robust(ticker, name):
|
|
| 167 |
if prev != 0: pct = ((curr - prev)/prev)*100
|
| 168 |
return curr, pct, "FDR"
|
| 169 |
except: pass
|
| 170 |
-
|
| 171 |
try:
|
| 172 |
stock = yf.Ticker(ticker)
|
| 173 |
price = stock.fast_info.last_price
|
| 174 |
prev = stock.fast_info.previous_close
|
| 175 |
-
if price and prev:
|
| 176 |
-
pct = ((price - prev)/prev)*100
|
| 177 |
-
return price, pct, "Yahoo"
|
| 178 |
except: pass
|
| 179 |
-
|
| 180 |
price, pct = scrape_price_from_web(name, ticker)
|
| 181 |
-
if price is not None: return price, pct, "Web
|
| 182 |
return None, None, "Fail"
|
| 183 |
|
| 184 |
def get_chart_data(ticker):
|
|
@@ -201,7 +244,6 @@ def identify_target_with_ai(user_query):
|
|
| 201 |
try:
|
| 202 |
prompt = f"""
|
| 203 |
[ROLE] Entity Resolver. [QUERY] "{user_query}"
|
| 204 |
-
[TASK] Identify company, ticker, dual keywords.
|
| 205 |
[OUTPUT JSON] {{ "name": "Eng Name", "ticker": "TICKER", "eng_key": "Name stock news", "native_key": "Local Name + news keyword" }}
|
| 206 |
"""
|
| 207 |
messages = [{"role": "user", "content": prompt}]
|
|
@@ -211,39 +253,26 @@ def identify_target_with_ai(user_query):
|
|
| 211 |
except: return None
|
| 212 |
|
| 213 |
def get_polyglot_news(eng_key, native_key):
|
| 214 |
-
|
| 215 |
-
if eng_key != native_key:
|
| 216 |
-
summary += "\n\n=== Local ===\n" + fetch_news_robust(native_key)
|
| 217 |
-
return summary
|
| 218 |
|
| 219 |
def parse_stream(stream):
|
| 220 |
for chunk in stream:
|
| 221 |
if chunk.choices: yield chunk.choices[0].delta.content or ""
|
| 222 |
|
| 223 |
-
# ==============================================================================
|
| 224 |
-
# ๐ [์๊ฐํ] ์ฐจํธ ํจ์ (๋ฒ๊ทธ ์์ ๋จ)
|
| 225 |
-
# ==============================================================================
|
| 226 |
def plot_candle_chart(hist, title, source):
|
| 227 |
fig = go.Figure(data=[go.Candlestick(x=hist.index, open=hist['Open'], high=hist['High'], low=hist['Low'], close=hist['Close'], name="Price")])
|
| 228 |
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)')
|
| 229 |
return fig
|
| 230 |
|
| 231 |
def plot_bar_chart(df, lang_data):
|
| 232 |
-
# [์์ ] df['Name'] ๋์ ํ์ฌ ์ธ์ด ์ค์ ์ ๋ง๋ ์ปฌ๋ผ(lang_data['col_name']) ์ฌ์ฉ
|
| 233 |
target_col = lang_data['col_name']
|
| 234 |
-
|
| 235 |
colors = ['#00FF00' if x > 0 else '#FF0000' for x in df['Change(%)']]
|
| 236 |
-
fig = go.Figure(go.Bar(
|
| 237 |
-
x=df[target_col], # <--- ์ฌ๊ธฐ์ ์๋ฌ๊ฐ ๋ฌ์์ต๋๋ค. ๋์ ์ปฌ๋ผ๋ช
์ฌ์ฉ!
|
| 238 |
-
y=df['Change(%)'],
|
| 239 |
-
marker_color=colors,
|
| 240 |
-
text=df['Change(%)'].apply(lambda x: f"{x:.2f}%")
|
| 241 |
-
))
|
| 242 |
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)')
|
| 243 |
return fig
|
| 244 |
|
| 245 |
# ==============================================================================
|
| 246 |
-
#
|
| 247 |
# ==============================================================================
|
| 248 |
with st.sidebar:
|
| 249 |
lang_code = st.selectbox("Language / ์ธ์ด", ["KR", "EN"])
|
|
@@ -264,7 +293,6 @@ if menu == T['menu_search']:
|
|
| 264 |
if info:
|
| 265 |
name, ticker = info.get('name'), info.get('ticker')
|
| 266 |
eng_key, native_key = info.get('eng_key'), info.get('native_key')
|
| 267 |
-
|
| 268 |
st.info(f"๐ก {T['info_target']}: **{name} ({ticker})**")
|
| 269 |
|
| 270 |
h, src = get_chart_data(ticker)
|
|
@@ -301,8 +329,7 @@ if menu == T['menu_search']:
|
|
| 301 |
prompt = f"""
|
| 302 |
[ROLE] Analyst. [TARGET] {name} ({ticker}). [QUERY] "{query}"
|
| 303 |
[DATA] Price:{curr}, Trend:{pct:.2f}%. [NEWS] {news_data}
|
| 304 |
-
[TASK] Summarize, Sentiment, Verdict.
|
| 305 |
-
[OUTPUT LANGUAGE] **{target_lang}** (Strictly output in {target_lang})
|
| 306 |
"""
|
| 307 |
msg = [{"role": "user", "content": prompt}]
|
| 308 |
stream = client.chat.completions.create(model="Qwen/Qwen2.5-72B-Instruct", messages=msg, stream=True)
|
|
@@ -322,9 +349,38 @@ elif menu == T['menu_scanner']:
|
|
| 322 |
results = []
|
| 323 |
bar = st.progress(0)
|
| 324 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 325 |
for i, t in enumerate(tickers):
|
| 326 |
name_display = TICKER_NAMES.get(t, t)
|
| 327 |
-
p, c, s =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 328 |
if p is not None:
|
| 329 |
results.append({
|
| 330 |
T['col_name']: name_display,
|
|
@@ -335,19 +391,33 @@ elif menu == T['menu_scanner']:
|
|
| 335 |
})
|
| 336 |
bar.progress((i+1)/len(tickers))
|
| 337 |
|
|
|
|
|
|
|
| 338 |
if results:
|
| 339 |
df = pd.DataFrame(results).sort_values('Change(%)', ascending=False)
|
| 340 |
-
|
| 341 |
-
# [์์ ] ์ฐจํธ ํจ์์ ํ์ฌ ์ธ์ด์ค์ (T)์ ์ ๋ฌ
|
| 342 |
st.plotly_chart(plot_bar_chart(df, T), use_container_width=True)
|
| 343 |
|
| 344 |
disp_cols = [T['col_name'], 'Ticker', T['col_price'], 'Change(%)', T['col_source']]
|
| 345 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 346 |
c_up, c_down = st.columns(2)
|
| 347 |
with c_up:
|
| 348 |
st.success(T['tab_gainers'])
|
| 349 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 350 |
with c_down:
|
| 351 |
st.error(T['tab_losers'])
|
| 352 |
-
|
|
|
|
|
|
|
|
|
|
| 353 |
else: st.warning(T['msg_fail'])
|
|
|
|
| 30 |
"menu_search": "๐ AI ์ค๋งํธ ๊ฒ์",
|
| 31 |
"menu_scanner": "๐ก ๊ธ๋ก๋ฒ ์์ฅ ์ค์บ๋",
|
| 32 |
"search_title": "๐ง ๋ค๊ตญ์ด AI ์ฃผ์ ๋น์",
|
| 33 |
+
"search_placeholder": "์: ๋ฒ ํธ๋จ ์บ๋์, ๋ฏธ๊ตญ ํ๋ํฐ์ด...",
|
| 34 |
"btn_analyze": "๋ถ์ ์์",
|
| 35 |
"status_thinking": "๐ง AI๊ฐ ๋ถ์ ์ค์
๋๋ค...",
|
| 36 |
"info_target": "ํ๊ฒ",
|
|
|
|
| 41 |
"metric_source": "์ถ์ฒ",
|
| 42 |
"expander_news": "๐ฐ ๋ด์ค ์๋ฌธ ๋ณด๊ธฐ",
|
| 43 |
"insight_title": "๐ง AI ํฌ์ ๋ฆฌํฌํธ",
|
| 44 |
+
"scanner_title": "๐ก ๊ธ๋ก๋ฒ ์์ฅ ์ ๊ดํ (Max 100)",
|
| 45 |
+
"scanner_caption": "โป ์ฝ 100๊ฐ ์ข
๋ชฉ์ 1์ฐจ ๊ณ ์ ์ค์บ ํ, ์คํจ ์ข
๋ชฉ๋ง ์ ๋ฐ ์ค์บํฉ๋๋ค.",
|
| 46 |
+
"btn_scan": "๊ด๋์ญ ์ค์บ ์์",
|
| 47 |
+
"tab_gainers": "๐ฅ ๊ธ๋ฑ (Pure Gainers)",
|
| 48 |
+
"tab_losers": "๐ง ๊ธ๋ฝ (Pure Losers)",
|
| 49 |
"col_name": "๊ธฐ์
๋ช
",
|
| 50 |
"col_price": "๊ฐ๊ฒฉ",
|
| 51 |
"col_change": "๋ฑ๋ฝ๋ฅ ",
|
|
|
|
| 58 |
"menu_search": "๐ AI Smart Search",
|
| 59 |
"menu_scanner": "๐ก Global Market Scanner",
|
| 60 |
"search_title": "๐ง AI Stock Assistant",
|
| 61 |
+
"search_placeholder": "e.g. Vinamilk, Palantir...",
|
| 62 |
"btn_analyze": "Analyze",
|
| 63 |
"status_thinking": "๐ง AI is analyzing...",
|
| 64 |
"info_target": "Target",
|
|
|
|
| 69 |
"metric_source": "Source",
|
| 70 |
"expander_news": "๐ฐ View Source News",
|
| 71 |
"insight_title": "๐ง AI Investment Report",
|
| 72 |
+
"scanner_title": "๐ก Global Market Scanner (Max 100)",
|
| 73 |
+
"scanner_caption": "โป Hybrid Scanning (Batch High-Speed + Deep Robust).",
|
| 74 |
+
"btn_scan": "Start Full Scan",
|
| 75 |
+
"tab_gainers": "๐ฅ Top Gainers (>0%)",
|
| 76 |
+
"tab_losers": "๐ง Top Losers (<0%)",
|
| 77 |
"col_name": "Company",
|
| 78 |
"col_price": "Price",
|
| 79 |
"col_change": "Change(%)",
|
|
|
|
| 84 |
}
|
| 85 |
|
| 86 |
# ==============================================================================
|
| 87 |
+
# ๐พ [๋ฐ์ดํฐ] ๋๊ท๋ชจ ํ์ฅ ํฐ์ปค (์ฝ 100๊ฐ ๊ท๋ชจ)
|
| 88 |
# ==============================================================================
|
| 89 |
TICKER_NAMES = {
|
| 90 |
+
# ๐ป๐ณ ๋ฒ ํธ๋จ
|
| 91 |
"VIC.VN": "Vingroup", "VHM.VN": "Vinhomes", "VCB.VN": "Vietcombank", "VNM.VN": "Vinamilk",
|
| 92 |
+
"HPG.VN": "Hoa Phat", "MSN.VN": "Masan Group", "GAS.VN": "PV Gas", "NVL.VN": "Novaland",
|
| 93 |
+
"PDR.VN": "Phat Dat", "DIG.VN": "DIC Corp", "CEO.VN": "CEO Group", "SSI.VN": "SSI Sec",
|
| 94 |
+
"VND.VN": "VNDirect", "MWG.VN": "Mobile World", "FRT.VN": "FPT Retail", "FPT.VN": "FPT Corp",
|
| 95 |
+
"STB.VN": "Sacombank", "MBB.VN": "MB Bank", "TCB.VN": "Techcombank", "VPB.VN": "VPBank",
|
| 96 |
+
"VRE.VN": "Vincom Retail", "DGC.VN": "Duc Giang Chem", "VHC.VN": "Vinh Hoan", "KBC.VN": "Kinh Bac",
|
| 97 |
+
"GVR.VN": "Vietnam Rubber", "SAB.VN": "Sabeco", "BID.VN": "BIDV", "CTG.VN": "VietinBank",
|
| 98 |
+
|
| 99 |
+
# ๐ฎ๐ฉ ์ธ๋๋ค์์
|
| 100 |
+
"BBCA.JK": "BCA Bank", "BBRI.JK": "BRI Bank", "TLKM.JK": "Telkom", "BMRI.JK": "Mandiri",
|
| 101 |
+
"ASII.JK": "Astra Intl", "GOTO.JK": "GoTo", "UNVR.JK": "Unilever", "ADRO.JK": "Adaro Energy",
|
| 102 |
+
"MDKA.JK": "Merdeka Copper", "ANTM.JK": "Aneka Tambang", "ICBP.JK": "Indofood CBP",
|
| 103 |
+
"KLBF.JK": "Kalbe Farma", "BUKA.JK": "Bukalapak", "PGAS.JK": "Perusahaan Gas",
|
| 104 |
+
|
| 105 |
+
# ๐ฐ๐ท ํ๊ตญ
|
| 106 |
"005930.KS": "Samsung Elec", "000660.KS": "SK Hynix", "035420.KS": "NAVER", "035720.KS": "Kakao",
|
| 107 |
+
"005380.KS": "Hyundai Motor", "207940.KS": "Samsung Bio", "068270.KS": "Celltrion",
|
| 108 |
+
"086520.KQ": "Ecopro", "247540.KQ": "Ecopro BM", "005490.KS": "POSCO Holdings", "010120.KS": "LS ELECTRIC",
|
| 109 |
+
"012450.KS": "Hanwha Aero", "042700.KS": "Hanmi Semi", "196170.KQ": "Alteogen",
|
| 110 |
+
"352820.KS": "HYBE", "035900.KQ": "JYP Ent", "000270.KS": "Kia", "010950.KS": "S-Oil",
|
| 111 |
+
"032830.KQ": "Samsung Life", "329180.KS": "Hyundai Heavy", "015760.KS": "KEPCO",
|
| 112 |
+
|
| 113 |
+
# ๐บ๐ธ ๋ฏธ๊ตญ (M7 + ์ฑ์ฅ + ๋ฐ์ด์ค + ์๋์ง)
|
| 114 |
"AAPL": "Apple", "NVDA": "NVIDIA", "TSLA": "Tesla", "AMZN": "Amazon", "MSFT": "Microsoft",
|
| 115 |
+
"GOOGL": "Google", "META": "Meta", "AMD": "AMD", "NFLX": "Netflix", "INTC": "Intel",
|
| 116 |
+
"PLTR": "Palantir", "COIN": "Coinbase", "U": "Unity", "RBLX": "Roblox", "SOFI": "SoFi",
|
| 117 |
+
"PYPL": "PayPal", "SQ": "Block", "UBER": "Uber", "ABNB": "Airbnb", "HOOD": "Robinhood",
|
| 118 |
+
"LLY": "Eli Lilly", "NVO": "Novo Nordisk", "PFE": "Pfizer", "MRK": "Merck",
|
| 119 |
+
"XOM": "Exxon", "CVX": "Chevron", "KO": "Coca-Cola", "PEP": "Pepsi", "COST": "Costco",
|
| 120 |
+
"DIS": "Disney", "NKE": "Nike", "SBUX": "Starbucks", "MCD": "McDonalds",
|
| 121 |
+
|
| 122 |
+
# ๐ฏ๐ต ์ผ๋ณธ
|
| 123 |
"7203.T": "Toyota", "6758.T": "Sony", "9984.T": "SoftBank", "8035.T": "Tokyo Elec",
|
| 124 |
+
"6861.T": "Keyence", "7974.T": "Nintendo", "8306.T": "MUFG", "6501.T": "Hitachi",
|
| 125 |
+
"8058.T": "Mitsubishi", "8001.T": "Itochu", "8031.T": "Mitsui", "6920.T": "Lasertec",
|
| 126 |
+
"4063.T": "Shin-Etsu", "7741.T": "HOYA", "6146.T": "Disco", "7267.T": "Honda",
|
| 127 |
+
|
| 128 |
+
# ๐จ๐ณ ์ค๊ตญ/ํ์ฝฉ/๋๋ง/์ธ๋
|
| 129 |
+
"9988.HK": "Alibaba", "0700.HK": "Tencent", "3690.HK": "Meituan", "1211.HK": "BYD",
|
| 130 |
+
"1810.HK": "Xiaomi", "0941.HK": "China Mobile", "9888.HK": "Baidu", "9618.HK": "JD.com",
|
| 131 |
+
"2015.HK": "Li Auto", "9868.HK": "Xpeng", "0981.HK": "SMIC",
|
| 132 |
"2330.TW": "TSMC", "2454.TW": "MediaTek", "2317.TW": "Foxconn", "2308.TW": "Delta Elec",
|
| 133 |
+
"RELIANCE.NS": "Reliance", "TCS.NS": "TCS", "HDFCBANK.NS": "HDFC Bank", "INFY.NS": "Infosys"
|
| 134 |
}
|
| 135 |
|
| 136 |
MARKET_SAMPLES = {
|
| 137 |
+
"๐ป๐ณ Vietnam (Max 50)": [
|
| 138 |
+
"VIC.VN", "VHM.VN", "VRE.VN", "VNM.VN", "MSN.VN", "GAS.VN", "HPG.VN", "HSG.VN",
|
| 139 |
+
"NVL.VN", "PDR.VN", "DIG.VN", "CEO.VN", "DXG.VN", "KBC.VN", "GVR.VN",
|
| 140 |
+
"VCB.VN", "TCB.VN", "VPB.VN", "MBB.VN", "STB.VN", "ACB.VN", "BID.VN", "CTG.VN",
|
| 141 |
+
"SSI.VN", "VND.VN", "VCI.VN", "MWG.VN", "FRT.VN", "DGW.VN", "FPT.VN", "DGC.VN", "VHC.VN", "SAB.VN"
|
| 142 |
+
],
|
| 143 |
+
"๐บ๐ธ USA (Max 50)": [
|
| 144 |
+
"AAPL", "MSFT", "GOOGL", "AMZN", "NVDA", "META", "TSLA", "AMD", "INTC", "QCOM",
|
| 145 |
+
"AVGO", "MU", "PLTR", "COIN", "U", "RBLX", "SOFI", "UBER", "ABNB", "HOOD", "PYPL", "SQ",
|
| 146 |
+
"LLY", "NVO", "JNJ", "PFE", "MRK", "UNH", "XOM", "CVX", "JPM", "V", "MA",
|
| 147 |
+
"KO", "PEP", "COST", "WMT", "DIS", "NKE", "SBUX", "MCD"
|
| 148 |
+
],
|
| 149 |
+
"๐ฐ๐ท Korea (Max 50)": [
|
| 150 |
+
"005930.KS", "000660.KS", "005380.KS", "000270.KS", "035420.KS", "035720.KS",
|
| 151 |
+
"005490.KS", "086520.KQ", "247540.KQ", "010120.KS", "207940.KS", "068270.KS",
|
| 152 |
+
"196170.KQ", "012450.KS", "042700.KS", "352820.KS", "010950.KS", "032830.KQ", "329180.KS", "015760.KS"
|
| 153 |
+
],
|
| 154 |
+
"๐ฎ๐ฉ Indonesia": ["BBCA.JK", "BBRI.JK", "BMRI.JK", "BBNI.JK", "TLKM.JK", "ASII.JK", "GOTO.JK", "ADRO.JK", "MDKA.JK", "ANTM.JK", "UNVR.JK", "ICBP.JK", "KLBF.JK", "BUKA.JK", "PGAS.JK"],
|
| 155 |
+
"๐ฏ๐ต Japan": ["7203.T", "6758.T", "9984.T", "8035.T", "6861.T", "7974.T", "8306.T", "6501.T", "8058.T", "8001.T", "8031.T", "6920.T", "4063.T", "7741.T", "6146.T", "7267.T"],
|
| 156 |
+
"๐จ๐ณ China/HK": ["9988.HK", "0700.HK", "3690.HK", "1211.HK", "1810.HK", "0941.HK", "9888.HK", "9618.HK", "2015.HK", "9868.HK", "0981.HK"],
|
| 157 |
+
"๐น๐ผ Taiwan": ["2330.TW", "2454.TW", "2317.TW", "2308.TW", "2603.TW", "2881.TW", "1301.TW"],
|
| 158 |
+
"๐ฎ๐ณ India": ["RELIANCE.NS", "TCS.NS", "HDFCBANK.NS", "INFY.NS", "ICICIBANK.NS", "TATAMOTORS.NS", "BHARTIARTL.NS"]
|
| 159 |
}
|
| 160 |
|
| 161 |
# ==============================================================================
|
| 162 |
+
# ๐ ๏ธ [์์ง] ๋ฐ์ดํฐ ์์ง ํจ์๋ค
|
| 163 |
# ==============================================================================
|
| 164 |
+
FAKE_HEADERS = {'User-Agent': 'Mozilla/5.0 ...'}
|
| 165 |
+
|
| 166 |
def fetch_news_robust(keyword):
|
| 167 |
summary = ""
|
| 168 |
seen_urls = set()
|
|
|
|
| 175 |
if not results:
|
| 176 |
time.sleep(random.uniform(0.5, 1.0))
|
| 177 |
results = list(ddgs.text(f"{keyword} latest news", max_results=3))
|
|
|
|
| 178 |
for r in results:
|
| 179 |
url = r.get('url') or r.get('href')
|
| 180 |
if url not in seen_urls:
|
| 181 |
+
date = r.get('date', '?')
|
| 182 |
title = r.get('title', '')
|
| 183 |
summary += f"[{date}] {title}\n"
|
| 184 |
seen_urls.add(url)
|
| 185 |
+
except: return "No news."
|
| 186 |
+
return summary if summary else "No news."
|
| 187 |
|
| 188 |
def scrape_price_from_web(name, ticker):
|
| 189 |
try:
|
| 190 |
+
time.sleep(random.uniform(0.5, 1.0))
|
| 191 |
with DDGS() as ddgs:
|
| 192 |
query = f"{name} {ticker} stock price quote today"
|
| 193 |
results = ddgs.text(query, max_results=2)
|
| 194 |
blob = " ".join([r['body'] for r in results])
|
| 195 |
match = re.search(r'(\d{1,3}(,\d{3})*(\.\d+)?)', blob)
|
| 196 |
+
if match: return float(match.group(0).replace(",", "")), 0.0
|
| 197 |
+
except: pass
|
| 198 |
+
return None, None
|
|
|
|
|
|
|
| 199 |
|
| 200 |
def get_price_data_robust(ticker, name):
|
| 201 |
+
# FDR
|
| 202 |
try:
|
| 203 |
fdr_symbol = ticker
|
| 204 |
if ".VN" in ticker: fdr_symbol = ticker.split('.')[0]
|
| 205 |
elif ".JK" in ticker: fdr_symbol = f"IDX:{ticker.split('.')[0]}"
|
| 206 |
elif ".KS" in ticker or ".KQ" in ticker: fdr_symbol = ticker.split('.')[0]
|
| 207 |
+
hist = fdr.DataReader(fdr_symbol, start=(datetime.datetime.now() - datetime.timedelta(days=7)))
|
| 208 |
if not hist.empty:
|
| 209 |
curr = hist['Close'].iloc[-1]
|
| 210 |
prev = hist['Close'].iloc[-2] if len(hist) > 1 else curr
|
|
|
|
| 212 |
if prev != 0: pct = ((curr - prev)/prev)*100
|
| 213 |
return curr, pct, "FDR"
|
| 214 |
except: pass
|
| 215 |
+
# Yahoo
|
| 216 |
try:
|
| 217 |
stock = yf.Ticker(ticker)
|
| 218 |
price = stock.fast_info.last_price
|
| 219 |
prev = stock.fast_info.previous_close
|
| 220 |
+
if price and prev: return price, ((price - prev)/prev)*100, "Yahoo"
|
|
|
|
|
|
|
| 221 |
except: pass
|
| 222 |
+
# Web
|
| 223 |
price, pct = scrape_price_from_web(name, ticker)
|
| 224 |
+
if price is not None: return price, pct, "Web"
|
| 225 |
return None, None, "Fail"
|
| 226 |
|
| 227 |
def get_chart_data(ticker):
|
|
|
|
| 244 |
try:
|
| 245 |
prompt = f"""
|
| 246 |
[ROLE] Entity Resolver. [QUERY] "{user_query}"
|
|
|
|
| 247 |
[OUTPUT JSON] {{ "name": "Eng Name", "ticker": "TICKER", "eng_key": "Name stock news", "native_key": "Local Name + news keyword" }}
|
| 248 |
"""
|
| 249 |
messages = [{"role": "user", "content": prompt}]
|
|
|
|
| 253 |
except: return None
|
| 254 |
|
| 255 |
def get_polyglot_news(eng_key, native_key):
|
| 256 |
+
return f"Global:\n{fetch_news_robust(eng_key)}\nLocal:\n{fetch_news_robust(native_key)}"
|
|
|
|
|
|
|
|
|
|
| 257 |
|
| 258 |
def parse_stream(stream):
|
| 259 |
for chunk in stream:
|
| 260 |
if chunk.choices: yield chunk.choices[0].delta.content or ""
|
| 261 |
|
|
|
|
|
|
|
|
|
|
| 262 |
def plot_candle_chart(hist, title, source):
|
| 263 |
fig = go.Figure(data=[go.Candlestick(x=hist.index, open=hist['Open'], high=hist['High'], low=hist['Low'], close=hist['Close'], name="Price")])
|
| 264 |
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)')
|
| 265 |
return fig
|
| 266 |
|
| 267 |
def plot_bar_chart(df, lang_data):
|
|
|
|
| 268 |
target_col = lang_data['col_name']
|
|
|
|
| 269 |
colors = ['#00FF00' if x > 0 else '#FF0000' for x in df['Change(%)']]
|
| 270 |
+
fig = go.Figure(go.Bar(x=df[target_col], y=df['Change(%)'], marker_color=colors, text=df['Change(%)'].apply(lambda x: f"{x:.2f}%")))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
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)')
|
| 272 |
return fig
|
| 273 |
|
| 274 |
# ==============================================================================
|
| 275 |
+
# UI
|
| 276 |
# ==============================================================================
|
| 277 |
with st.sidebar:
|
| 278 |
lang_code = st.selectbox("Language / ์ธ์ด", ["KR", "EN"])
|
|
|
|
| 293 |
if info:
|
| 294 |
name, ticker = info.get('name'), info.get('ticker')
|
| 295 |
eng_key, native_key = info.get('eng_key'), info.get('native_key')
|
|
|
|
| 296 |
st.info(f"๐ก {T['info_target']}: **{name} ({ticker})**")
|
| 297 |
|
| 298 |
h, src = get_chart_data(ticker)
|
|
|
|
| 329 |
prompt = f"""
|
| 330 |
[ROLE] Analyst. [TARGET] {name} ({ticker}). [QUERY] "{query}"
|
| 331 |
[DATA] Price:{curr}, Trend:{pct:.2f}%. [NEWS] {news_data}
|
| 332 |
+
[TASK] Summarize, Sentiment, Verdict. [LANG] {target_lang}.
|
|
|
|
| 333 |
"""
|
| 334 |
msg = [{"role": "user", "content": prompt}]
|
| 335 |
stream = client.chat.completions.create(model="Qwen/Qwen2.5-72B-Instruct", messages=msg, stream=True)
|
|
|
|
| 349 |
results = []
|
| 350 |
bar = st.progress(0)
|
| 351 |
|
| 352 |
+
# [์ต์ ํ] 1์ฐจ: Batch Download (๋น ๋ฆ)
|
| 353 |
+
# 100๊ฐ์ฉ ๋๋ ์ ๋ค์ด๋ก๋ (Yfinance ์ ํ ๊ณ ๋ ค)
|
| 354 |
+
status_text = st.empty()
|
| 355 |
+
status_text.text("๐ Phase 1: Batch Speed Scan...")
|
| 356 |
+
|
| 357 |
+
try:
|
| 358 |
+
# Batch๋ก ๊ธ์ด์ค๊ธฐ
|
| 359 |
+
batch_data = yf.download(tickers, period="5d", progress=False)['Close']
|
| 360 |
+
except:
|
| 361 |
+
batch_data = pd.DataFrame()
|
| 362 |
+
|
| 363 |
+
# 2์ฐจ: ์คํจํ ์ข
๋ชฉ๋ง Robust Scan (FDR/Web)
|
| 364 |
+
status_text.text("๐ต๏ธ Phase 2: Deep Robust Scan...")
|
| 365 |
+
|
| 366 |
for i, t in enumerate(tickers):
|
| 367 |
name_display = TICKER_NAMES.get(t, t)
|
| 368 |
+
p, c, s = None, None, None
|
| 369 |
+
|
| 370 |
+
# 1. Batch ๊ฒฐ๊ณผ ํ์ธ
|
| 371 |
+
if not batch_data.empty and t in batch_data.columns:
|
| 372 |
+
series = batch_data[t].dropna()
|
| 373 |
+
if len(series) >= 2:
|
| 374 |
+
p = series.iloc[-1]
|
| 375 |
+
prev = series.iloc[-2]
|
| 376 |
+
if prev != 0:
|
| 377 |
+
c = ((p - prev)/prev)*100
|
| 378 |
+
s = "Yahoo (Batch)"
|
| 379 |
+
|
| 380 |
+
# 2. Batch ์คํจ ์ Robust ์คํ
|
| 381 |
+
if p is None:
|
| 382 |
+
p, c, s = get_price_data_robust(t, name_display)
|
| 383 |
+
|
| 384 |
if p is not None:
|
| 385 |
results.append({
|
| 386 |
T['col_name']: name_display,
|
|
|
|
| 391 |
})
|
| 392 |
bar.progress((i+1)/len(tickers))
|
| 393 |
|
| 394 |
+
status_text.empty()
|
| 395 |
+
|
| 396 |
if results:
|
| 397 |
df = pd.DataFrame(results).sort_values('Change(%)', ascending=False)
|
|
|
|
|
|
|
| 398 |
st.plotly_chart(plot_bar_chart(df, T), use_container_width=True)
|
| 399 |
|
| 400 |
disp_cols = [T['col_name'], 'Ticker', T['col_price'], 'Change(%)', T['col_source']]
|
| 401 |
|
| 402 |
+
# [ํํฐ๋ง ๋ก์ง ๊ฐ์ ]
|
| 403 |
+
# Gainers: 0๋ณด๋ค ํฐ ๊ฒ๋ง, ์์์ 10๊ฐ
|
| 404 |
+
df_gainers = df[df['Change(%)'] > 0].head(10)
|
| 405 |
+
|
| 406 |
+
# Losers: 0๋ณด๋ค ์์ ๊ฒ๋ง, ๋ค์์๋ถํฐ ์ ๋ ฌํด์ 10๊ฐ (๊ฐ์ฅ ๋ง์ด ๋จ์ด์ง ์์)
|
| 407 |
+
df_losers = df[df['Change(%)'] < 0].sort_values('Change(%)', ascending=True).head(10)
|
| 408 |
+
|
| 409 |
c_up, c_down = st.columns(2)
|
| 410 |
with c_up:
|
| 411 |
st.success(T['tab_gainers'])
|
| 412 |
+
if not df_gainers.empty:
|
| 413 |
+
st.dataframe(df_gainers[disp_cols].style.format({T['col_price']: "{:,.2f}", "Change(%)": "{:,.2f}%"}), use_container_width=True)
|
| 414 |
+
else:
|
| 415 |
+
st.info("No gainers found.")
|
| 416 |
+
|
| 417 |
with c_down:
|
| 418 |
st.error(T['tab_losers'])
|
| 419 |
+
if not df_losers.empty:
|
| 420 |
+
st.dataframe(df_losers[disp_cols].style.format({T['col_price']: "{:,.2f}", "Change(%)": "{:,.2f}%"}), use_container_width=True)
|
| 421 |
+
else:
|
| 422 |
+
st.info("No losers found.")
|
| 423 |
else: st.warning(T['msg_fail'])
|