import os import datetime import requests from flask import Flask, render_template, request import openai import yfinance as yf import plotly.graph_objs as go app = Flask(__name__) FINNHUB_API_KEY = os.getenv("FINNHUB_API_KEY") OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") openai.api_key = OPENAI_API_KEY industry_mapping = { "Technology": "科技業", "Financial Services": "金融服務業", "Healthcare": "醫療保健業", "Consumer Cyclical": "非必需消費品業", "Communication Services": "通訊服務業", "Energy": "能源業", "Industrials": "工業類股", "Utilities": "公用事業", "Real Estate": "房地產業", "Materials": "原物料業", "Consumer Defensive": "必需消費品業", "Unknown": "未知" } IMPORTANT_METRICS = [ "peTTM", "pb", "roeTTM", "roaTTM", "grossMarginTTM", "revenueGrowthTTMYoy", "epsGrowthTTMYoy", "debtToEquityAnnual" ] METRIC_NAMES_ZH_EN = { "peTTM": "本益比 (PE TTM)", "pb": "股價淨值比 (PB)", "roeTTM": "股東權益報酬率 (ROE TTM)", "roaTTM": "資產報酬率 (ROA TTM)", "grossMarginTTM": "毛利率 (Gross Margin TTM)", "revenueGrowthTTMYoy": "營收成長率 (YoY)", "epsGrowthTTMYoy": "每股盈餘成長率 (EPS Growth YoY)", "debtToEquityAnnual": "負債權益比 (Debt to Equity Annual)" } QUOTE_FIELDS = { "c": ("即時股價", "Current Price"), "o": ("開盤價", "Open"), "h": ("最高價", "High"), "l": ("最低價", "Low"), "pc": ("前收盤價", "Previous Close"), "dp": ("漲跌幅(%)", "Change Percent") } def get_finnhub_json(endpoint, params): url = f"https://finnhub.io/api/v1/{endpoint}" params["token"] = FINNHUB_API_KEY try: r = requests.get(url, params=params, timeout=5) r.raise_for_status() return r.json() except Exception as e: print(f"Request error {endpoint} with params {params}: {e}") return {} def get_quote(symbol): data = get_finnhub_json("quote", {"symbol": symbol}) return {k: (round(data.get(k), 4) if isinstance(data.get(k), (int, float)) else data.get(k)) for k in QUOTE_FIELDS.keys()} def get_metrics(symbol): data = get_finnhub_json("stock/metric", {"symbol": symbol, "metric": "all"}) return data.get("metric", {}) def filter_metrics(metrics): filtered = {} for key in IMPORTANT_METRICS: v = metrics.get(key) if v is not None: if any(x in key.lower() for x in ["growth", "margin", "roe", "roa"]): try: filtered[key] = f"{float(v):.2f}%" except: filtered[key] = str(v) else: try: filtered[key] = round(float(v), 4) except: filtered[key] = str(v) return filtered def get_recent_news(symbol): today = datetime.datetime.now().strftime("%Y-%m-%d") past = (datetime.datetime.now() - datetime.timedelta(days=10)).strftime("%Y-%m-%d") news = get_finnhub_json("company-news", {"symbol": symbol, "from": past, "to": today}) if not isinstance(news, list): return [] news = sorted(news, key=lambda x: x.get("datetime", 0), reverse=True)[:10] for n in news: try: n["datetime"] = datetime.datetime.utcfromtimestamp(n["datetime"]).strftime("%Y-%m-%d %H:%M") except: n["datetime"] = "未知時間" return news def get_company_profile(symbol): return get_finnhub_json("stock/profile2", {"symbol": symbol}) def adjust_symbol_for_yfinance(symbol): """ 如果是台灣股票,若沒帶 .TW 則補上 """ if symbol.isalpha() and len(symbol) <= 5: # 簡單判斷台股代碼長度 if not symbol.endswith(".TW"): symbol += ".TW" return symbol def get_7day_stock_plot(symbol): try: yf_symbol = adjust_symbol_for_yfinance(symbol) end_date = datetime.datetime.today() start_date = end_date - datetime.timedelta(days=14) # account for weekends df = yf.download(yf_symbol, start=start_date.strftime('%Y-%m-%d'), end=end_date.strftime('%Y-%m-%d'), progress=False) if df.empty or 'Close' not in df.columns: return "
📊 無法取得股價趨勢圖。
" df = df.tail(7) # recent 7 trading days dates = df.index.strftime('%Y-%m-%d').tolist() closes = df['Close'].round(2).tolist() fig = go.Figure() fig.add_trace(go.Scatter(x=dates, y=closes, mode='lines+markers', name='Close Price')) fig.update_layout( title=f"📉 {symbol} 最近7日收盤價走勢 / 7-Day Closing Price Trend", xaxis_title="日期 / Date", yaxis_title="收盤價 (USD)", template="plotly_white", height=400 ) return fig.to_html(full_html=False, include_plotlyjs='cdn', default_height="400px", default_width="100%") except Exception as e: print(f"[Plot Error] Failed to generate 7-day stock plot for {symbol}: {e}") return "📊 無法取得股價趨勢圖。
" @app.route("/", methods=["GET", "POST"]) def index(): result = {} symbol = "" if request.method == "POST": symbol = request.form.get("symbol", "").strip().upper() if not symbol: result["error"] = "請輸入股票代號 / Please enter a stock symbol" return render_template("index.html", result=result, symbol_input=symbol) try: quote = get_quote(symbol) metrics = get_metrics(symbol) filtered_metrics = filter_metrics(metrics) news = get_recent_news(symbol) profile = get_company_profile(symbol) industry_en = profile.get("finnhubIndustry", "Unknown") industry_zh = industry_mapping.get(industry_en, "未知") plot_html = get_7day_stock_plot(symbol) if filtered_metrics: prompt = f""" 請根據以下資訊,產出一份專業、詳細且內容完全對應的中英文雙語股票技術分析與投資建議報告,請將中文與英文分段清楚列出,內容一致且詳盡,並使用分點說明: 股票代號: {symbol} 目前價格: {quote.get('c', 'N/A')} 美元 (USD) 產業分類: {industry_zh} ({industry_en}) 重要財務指標: {filtered_metrics} 請內容涵蓋: 1. 價格趨勢與技術指標分析(如RSI, 移動平均線等) 2. 重要財務數據對股價的支持或風險提示 3. 明確且具體的買入、目標價、止損和加碼或減碼策略 4. 結論與投資建議 --- Please generate a professional and detailed bilingual (Chinese and English) technical analysis and investment recommendation report based on the above data. Make sure the Chinese and English parts are clearly separated and correspond exactly in content and detail. Use numbered points matching the Chinese section: 1. Price trend and technical indicators analysis (e.g., RSI, moving averages) 2. Key financial metrics supporting or warning risks for the stock price 3. Clear and specific buy, target price, stop loss, and position adjustment strategies 4. Conclusion and investment advice --- 請務必完整且清楚地列出中英文報告,避免內容不對稱或省略。 """ else: prompt = f""" 由於缺乏完整的歷史價格與財務資料,請針對股票代號 {symbol},以專業且詳細的語氣產出一份中英文雙語且內容一致的股票技術分析與投資建議報告,請將中英文分段清楚列出,內容完整且分點說明,包含趨勢分析、支撐阻力、成交量、技術指標、風險提示與具體操作建議。 --- Because of incomplete historical price and financial data, please generate a professional, detailed, and content-matched bilingual (Chinese and English) technical analysis and investment recommendation report for the stock symbol {symbol}. Make sure the Chinese and English parts are clearly separated and have exactly the same content, including numbered points covering trend analysis, support and resistance, volume, technical indicators, risk warnings, and specific investment strategies. --- 請務必用清晰的格式和專業語氣回覆,確保中英文內容對等。 """ # OpenAI ChatCompletion 呼叫(根據你使用的 openai 版本調整) try: chat_response = openai.ChatCompletion.create( model="gpt-4o-mini", messages=[ { "role": "system", "content": ( "你是一位中英雙語的金融分析助理," "請同時用中文和英文回覆,且中英文內容需完全對等且分段清楚。" ) }, {"role": "user", "content": prompt} ], max_tokens=900, temperature=0.6, ) gpt_analysis = chat_response['choices'][0]['message']['content'].strip() # 判斷是否為預設問候語,若是則回報錯誤 if "您好" in gpt_analysis and ("帮助您" in gpt_analysis or "帮助" in gpt_analysis): gpt_analysis = "❌ GPT 回應似乎是預設問候語,可能 prompt 未正確生效或 API 請求有誤。" else: if "技術分析" in gpt_analysis: gpt_analysis += "\n\n---\n\n*以上分析僅供參考,投資有風險,入市需謹慎。*" except Exception as e: gpt_analysis = f"❌ GPT 回應錯誤: {e}" result = { "symbol": symbol, "quote": quote, "industry_en": industry_en, "industry_zh": industry_zh, "metrics": filtered_metrics, "news": news, "gpt_analysis": gpt_analysis, "plot_html": plot_html } except Exception as e: result = {"error": f"資料讀取錯誤: {e}"} return render_template( "index.html", result=result, symbol_input=symbol, QUOTE_FIELDS=QUOTE_FIELDS, METRIC_NAMES_ZH_EN=METRIC_NAMES_ZH_EN ) if __name__ == "__main__": app.run(host="0.0.0.0", port=7860, debug=True)