Spaces:
Sleeping
Sleeping
| """ | |
| 大宗商品即期價格 v3 | |
| 配色:米白暖灰(護眼) | |
| 字體:全面放大 | |
| """ | |
| import yfinance as yf | |
| import pandas as pd | |
| import numpy as np | |
| import matplotlib | |
| matplotlib.use("Agg") | |
| import matplotlib.pyplot as plt | |
| import gradio as gr | |
| from datetime import datetime, timedelta | |
| import pytz, io, warnings | |
| warnings.filterwarnings("ignore") | |
| # 中文字型 | |
| import subprocess | |
| try: | |
| subprocess.run(["apt-get","install","-y","-q","fonts-noto-cjk"], capture_output=True) | |
| import matplotlib.font_manager as fm | |
| fm._load_fontmanager(try_read_cache=False) | |
| plt.rcParams["font.family"] = ["Noto Sans CJK TC","DejaVu Sans"] | |
| except Exception: | |
| pass | |
| # ── 配色 ── | |
| BG_PAGE = "#F8F5F0" | |
| BG_HEADER = "#4A3728" | |
| BG_CARD = "#FFFFFF" | |
| BG_INNER = "#F8F5F0" | |
| C_BORDER = "#D4C5A9" | |
| C_TITLE = "#F5DEB3" | |
| C_SUB = "#C4A882" | |
| C_TEXT = "#2C1810" | |
| C_MUTED = "#8B7355" | |
| C_UP = "#1A7A1A" | |
| C_DOWN = "#CC3300" | |
| COMMODITIES = { | |
| "黃金": {"ticker":"GC=F", "unit":"USD / 盎司", "color":"#DAA520"}, | |
| "白銀": {"ticker":"SI=F", "unit":"USD / 盎司", "color":"#888888"}, | |
| "石油(WTI)": {"ticker":"CL=F", "unit":"USD / 桶", "color":"#8B4513"}, | |
| "比特幣": {"ticker":"BTC-USD", "unit":"USD", "color":"#F7931A"}, | |
| "以太幣": {"ticker":"ETH-USD", "unit":"USD", "color":"#627EEA"}, | |
| } | |
| def get_usdtwd(): | |
| try: | |
| h = yf.Ticker("USDTWD=X").history(period="5d") | |
| if not h.empty: | |
| return float(h["Close"].iloc[-1]) | |
| except Exception: | |
| pass | |
| return 31.5 | |
| def get_data(ticker, days=10): | |
| try: | |
| end = datetime.now() | |
| start = end - timedelta(days=days+7) | |
| df = yf.download(ticker, start=start, end=end, auto_adjust=True, progress=False) | |
| if df.empty: | |
| return None | |
| if isinstance(df.columns, pd.MultiIndex): | |
| df.columns = df.columns.get_level_values(0) | |
| return df[["Close"]].dropna().tail(days) | |
| except Exception: | |
| return None | |
| def make_spark(df, color, name): | |
| fig, ax = plt.subplots(figsize=(5, 2.2)) | |
| fig.patch.set_facecolor(BG_CARD) | |
| ax.set_facecolor(BG_INNER) | |
| if df is not None and len(df) >= 2: | |
| prices = df["Close"].squeeze().values | |
| xs = list(range(len(prices))) | |
| ax.plot(xs, prices, color=color, linewidth=2.8, zorder=3) | |
| ax.fill_between(xs, prices, prices.min()*0.998, | |
| alpha=0.15, color=color, zorder=2) | |
| ax.annotate(f"{prices[-1]:,.2f}", | |
| xy=(xs[-1], prices[-1]), | |
| xytext=(-55, 8), textcoords="offset points", | |
| color=color, fontsize=12, fontweight="bold") | |
| ax.set_xlim(xs[0], xs[-1]) | |
| ax.set_ylim(prices.min()*0.994, prices.max()*1.006) | |
| else: | |
| ax.text(0.5, 0.5, "資料載入中", ha="center", va="center", | |
| color=C_MUTED, transform=ax.transAxes, fontsize=13) | |
| ax.set_title(name, color=color, fontsize=14, fontweight="bold", pad=5, loc="left") | |
| ax.tick_params(colors=C_MUTED, labelsize=9) | |
| for sp in ax.spines.values(): | |
| sp.set_edgecolor(C_BORDER) | |
| ax.grid(axis="y", color=C_BORDER, linewidth=0.6, alpha=0.7) | |
| ax.set_xlabel("近 7 交易日", color=C_MUTED, fontsize=9) | |
| plt.tight_layout(pad=0.6) | |
| buf = io.BytesIO() | |
| fig.savefig(buf, format="png", dpi=140, facecolor=BG_CARD) | |
| buf.seek(0) | |
| plt.close(fig) | |
| from PIL import Image | |
| return Image.open(buf) | |
| def build_card(name, price, chg_abs, chg_pct, unit, color, usdtwd, now): | |
| if price is None: | |
| return f""" | |
| <div style="background:{BG_CARD};border:1px solid {C_BORDER};border-radius:10px; | |
| padding:20px;font-family:Arial"> | |
| <div style="color:{C_TEXT};font-size:20px;font-weight:700">{name}</div> | |
| <div style="color:{C_MUTED};font-size:15px;margin-top:8px">資料暫時無法取得</div> | |
| </div>""" | |
| price_twd = price * usdtwd | |
| up = chg_pct >= 0 | |
| arrow = "▲" if up else "▼" | |
| chg_color = C_UP if up else C_DOWN | |
| if price >= 10000: | |
| usd_str = f"${price:,.0f}" | |
| twd_str = f"NT${price_twd:,.0f}" | |
| cusd_str = f"{arrow} ${abs(chg_abs):,.0f} ({abs(chg_pct):.2f}%)" | |
| ctwd_str = f"{arrow} NT${abs(chg_abs*usdtwd):,.0f}" | |
| elif price >= 100: | |
| usd_str = f"${price:,.2f}" | |
| twd_str = f"NT${price_twd:,.0f}" | |
| cusd_str = f"{arrow} ${abs(chg_abs):.2f} ({abs(chg_pct):.2f}%)" | |
| ctwd_str = f"{arrow} NT${abs(chg_abs*usdtwd):,.0f}" | |
| else: | |
| usd_str = f"${price:.3f}" | |
| twd_str = f"NT${price_twd:.2f}" | |
| cusd_str = f"{arrow} ${abs(chg_abs):.3f} ({abs(chg_pct):.2f}%)" | |
| ctwd_str = f"{arrow} NT${abs(chg_abs*usdtwd):.2f}" | |
| convert_html = "" | |
| if name in ["黃金", "白銀"]: | |
| tael_usd = price * 1.20337 | |
| tael_twd = tael_usd * usdtwd | |
| convert_html = f""" | |
| <div style="background:{BG_INNER};border:1px solid {C_BORDER};border-radius:6px; | |
| padding:10px 14px;margin:10px 0 0;font-size:15px;color:{C_MUTED}"> | |
| 💱 每台兩換算: | |
| <b style="color:{C_TEXT}">USD ${tael_usd:,.2f}</b> | |
| = | |
| <b style="color:#B8860B">NT${tael_twd:,.0f}</b> | |
| </div>""" | |
| return f""" | |
| <div style="background:{BG_CARD};border:1px solid {C_BORDER};border-radius:10px; | |
| border-top:4px solid {color};padding:20px;font-family:Arial"> | |
| <div style="display:flex;align-items:baseline;gap:10px;margin-bottom:14px"> | |
| <span style="color:{C_TEXT};font-size:22px;font-weight:700">{name}</span> | |
| <span style="color:{C_MUTED};font-size:14px">{unit}</span> | |
| </div> | |
| <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px"> | |
| <div style="background:{BG_INNER};border:1px solid {C_BORDER};border-radius:8px;padding:12px 14px"> | |
| <div style="color:{C_MUTED};font-size:14px;margin-bottom:4px">💵 美元 USD</div> | |
| <div style="color:{C_TEXT};font-size:26px;font-weight:700;margin-bottom:4px">{usd_str}</div> | |
| <div style="color:{chg_color};font-size:15px;font-weight:600">{cusd_str}</div> | |
| </div> | |
| <div style="background:{BG_INNER};border:1px solid {C_BORDER};border-radius:8px;padding:12px 14px"> | |
| <div style="color:{C_MUTED};font-size:14px;margin-bottom:4px">🇹🇼 台幣 TWD</div> | |
| <div style="color:#7B4F00;font-size:22px;font-weight:700;margin-bottom:4px">{twd_str}</div> | |
| <div style="color:{chg_color};font-size:15px;font-weight:600">{ctwd_str}</div> | |
| </div> | |
| </div> | |
| {convert_html} | |
| <div style="color:{C_MUTED};font-size:13px;text-align:right;margin-top:10px"> | |
| 匯率:1 USD = {usdtwd:.2f} TWD | {now.strftime('%m/%d %H:%M')} 台灣時間 | |
| </div> | |
| </div>""" | |
| def fetch_all(): | |
| tw_tz = pytz.timezone("Asia/Taipei") | |
| now = datetime.now(tw_tz) | |
| usdtwd = get_usdtwd() | |
| grid_html = f""" | |
| <style> | |
| .cgrid{{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:14px;padding:4px}} | |
| </style> | |
| <div class="cgrid">""" | |
| images = {} | |
| for name, cfg in COMMODITIES.items(): | |
| df = get_data(cfg["ticker"], days=8) | |
| price = chg_abs = chg_pct = None | |
| if df is not None and len(df) >= 2: | |
| price = float(df["Close"].squeeze().iloc[-1]) | |
| prev = float(df["Close"].squeeze().iloc[-2]) | |
| chg_abs = price - prev | |
| chg_pct = chg_abs / prev * 100 if prev else 0 | |
| grid_html += build_card(name, price, chg_abs, chg_pct, | |
| cfg["unit"], cfg["color"], usdtwd, now) | |
| images[name] = make_spark(df, cfg["color"], name) | |
| grid_html += "</div>" | |
| update_str = (f"✅ 更新完成:{now.strftime('%Y-%m-%d %H:%M:%S')} 台灣時間" | |
| f" | 1 USD = {usdtwd:.2f} TWD") | |
| return (grid_html, | |
| images.get("黃金"), images.get("白銀"), images.get("石油(WTI)"), | |
| images.get("比特幣"), images.get("以太幣"), | |
| update_str) | |
| # ── CSS ── | |
| CSS = f""" | |
| body, .gradio-container {{ background:{BG_PAGE} !important; font-family:Arial,sans-serif }} | |
| button.primary {{ | |
| background:{BG_HEADER} !important; color:{C_TITLE} !important; | |
| font-size:18px !important; font-weight:700 !important; | |
| border:none !important; border-radius:8px !important; padding:14px !important; | |
| }} | |
| button.primary:hover {{ background:#6B5040 !important; }} | |
| label span, .label-wrap span {{ font-size:16px !important; color:{C_MUTED} !important; }} | |
| """ | |
| with gr.Blocks(css=CSS, title="大宗商品即期價格") as demo: | |
| gr.HTML(f""" | |
| <div style="background:{BG_HEADER};border-bottom:3px solid #DAA520;padding:18px 24px"> | |
| <div style="display:flex;align-items:center;gap:14px"> | |
| <span style="font-size:32px">📦</span> | |
| <div> | |
| <p style="color:{C_TITLE};font-size:28px;font-weight:700;margin:0">大宗商品即期價格</p> | |
| <p style="color:{C_SUB};font-size:15px;margin:5px 0 0"> | |
| 黃金 · 白銀 · 石油 · 比特幣 · 以太幣 | |
| | USD + TWD 雙幣顯示 | |
| | 7日走勢圖 | |
| </p> | |
| </div> | |
| </div> | |
| </div> | |
| """) | |
| refresh_btn = gr.Button("🔄 立即更新價格", variant="primary", size="lg") | |
| gr.HTML(f'<p style="color:{C_TEXT};font-size:18px;font-weight:600;margin:14px 4px 6px">📊 即期價格</p>') | |
| cards_out = gr.HTML() | |
| gr.HTML(f'<p style="color:{C_TEXT};font-size:18px;font-weight:600;margin:18px 4px 8px">📈 7日走勢圖</p>') | |
| with gr.Row(): | |
| gold_img = gr.Image(label="🥇 黃金", show_label=True, height=200) | |
| silver_img = gr.Image(label="🥈 白銀", show_label=True, height=200) | |
| oil_img = gr.Image(label="🛢️ 石油(WTI)", show_label=True, height=200) | |
| with gr.Row(): | |
| btc_img = gr.Image(label="₿ 比特幣", show_label=True, height=200) | |
| eth_img = gr.Image(label="◈ 以太幣", show_label=True, height=200) | |
| gr.HTML(f""" | |
| <div style="padding:20px 10px;color:{C_MUTED};font-size:14px;line-height:1.9"> | |
| · 走勢圖為近 7 個交易日收盤價<br> | |
| · 黃金/白銀附每台兩換算<br> | |
| (1 台兩 = 1.20337 盎司)<br> | |
| · 匯率即時取自 Yahoo Finance | |
| </div>""") | |
| update_out = gr.Textbox(label="", interactive=False, | |
| value="⬆️ 點擊上方按鈕載入最新價格", | |
| elem_id="upd") | |
| gr.HTML(f'<style>#upd textarea{{font-size:16px!important;color:{C_MUTED}!important;background:{BG_INNER}!important}}</style>') | |
| refresh_btn.click( | |
| fn=fetch_all, inputs=[], | |
| outputs=[cards_out, gold_img, silver_img, oil_img, | |
| btc_img, eth_img, update_out], | |
| ) | |
| demo.launch(server_name="0.0.0.0", server_port=7860) | |