File size: 15,443 Bytes
8e3b455
 
 
 
 
 
 
 
 
 
 
 
eda6a09
8e3b455
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b7459ba
8e3b455
b7459ba
8e3b455
b7459ba
8e3b455
 
 
 
 
 
 
 
 
 
 
 
fb54c7f
8e3b455
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b7459ba
8e3b455
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16c2273
8e3b455
 
 
b7ae03b
8e3b455
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5d3f8c9
 
8e3b455
 
e9f536d
8e3b455
 
 
 
 
 
 
 
 
a4f0f1c
8e3b455
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5d3f8c9
 
8e3b455
e9f536d
8e3b455
 
 
 
 
 
a4f0f1c
8e3b455
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
05ccbbe
8e3b455
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
import gradio as gr
import yfinance as yf
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from google import genai
import os
from datetime import datetime

GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY")
client = genai.Client(api_key=GOOGLE_API_KEY)
MODEL_NAME = "gemini-2.5-flash-lite-preview-06-17"
DEVELOPER_NAMES = "蔡勝軒、蔡柏鴻、巫丞喆、張凱崴"

# 支援的美國公司列表
US_STOCKS = {
    "Apple (AAPL)": "AAPL",
    "Microsoft (MSFT)": "MSFT",
    "Google (GOOG)": "GOOG",
    "Amazon (AMZN)": "AMZN",
    "NVIDIA (NVDA)": "NVDA",
    "Meta Platforms (META)": "META",
    "Tesla (TSLA)": "TSLA",
    "Berkshire Hathaway (BRK-B)": "BRK-B",
    "JPMorgan Chase & Co. (JPM)": "JPM",
    "Johnson & Johnson (JNJ)": "JNJ",
    "Samsung Electronics Co., Ltd. (005930.KS)": "005930.KS"
}

# 支援的台灣公司列表
TW_STOCKS = {
    "台積電": "2330.TW",
    "鴻海": "2317.TW",
    "聯發科": "2454.TW",
    "富邦金": "2881.TW",
    "台達電": "2308.TW",
    "國泰金": "2882.TW",
    "中華電": "2412.TW",
    "日月光投控": "3711.TW",
    "廣達": "2382.TW",
    "台塑化": "6505.TW",
    "聯電": "2303.TW",
    "中信金": "2891.TW",
    "兆豐金": "2886.TW",
    "南亞": "1303.TW",
    "統一": "1216.TW",
    "台塑": "1301.TW",
    "玉山金": "2884.TW",
    "世芯‑KY": "3661.TW",
    "華碩": "2357.TW",
    "長榮": "2603.TW",
    "智邦": "2345.TW",
    "元大金": "2885.TW",
    "第一金": "2892.TW",
    "華南金": "2880.TW",
    "合庫金": "5880.TW",
    "台灣大": "3045.TW",
    "緯創": "3231.TW",
    "聯詠": "3034.TW",
    "遠傳": "4904.TW",
    "和泰車": "2207.TW",
    "台光電": "2383.TW",
    "大立光": "3008.TW",
    "永豐金": "2890.TW",
    "瑞昱": "2379.TW",
    "奇鋐": "3017.TW",
    "研華": "2395.TW",
    "中鋼": "2002.TW",
    "統一超": "2912.TW",
    "萬海": "2615.TW",
    "元太": "8069.TWO",
    "國巨": "2327.TW",
    "陽明": "2609.TW",
    "光寶科": "2301.TW",
    "凱基金": "2883.TW",
    "鈊象": "3293.TWO",
    "上海商銀": "5876.TW",
    "長榮航": "2618.TW",
    "中租‑KY": "5871.TW",
    "彰銀": "2801.TW",
    "健策": "3653.TW",
    "台新金": "2887.TW",
    "和碩": "4938.TW",
    "新光金": "2888.TW",
    "藥華藥": "6446.TW",
    "川湖": "2059.TW",
    "台泥": "1101.TW",
    "信驊": "5274.TWO",
    "技嘉": "2376.TW",
    "世界": "5347.TWO",
    "致茂": "2360.TW",
    "力旺": "3529.TWO",
    "遠東新": "1402.TW",
    "創意": "3443.TW",
    "亞德客‑KY": "1590.TW",
    "欣興": "3037.TW",
    "華城": "1519.TW",
    "台灣高鐵": "2633.TW",
    "貿聯‑KY": "3665.TW",
    "英業達": "2356.TW",
    "南亞科": "2408.TW",
    "亞泥": "1102.TW",
    "嘉澤": "3533.TW",
    "文曄": "3036.TW",
    "環球晶": "6488.TWO",
    "臺企銀": "2834.TW",
    "金像電": "2368.TW",
    "可成": "2474.TW",
    "祥碩": "5269.TW",
    "矽力‑KY": "6415.TW",
    "台化": "1326.TW",
    "華航": "2610.TW",
    "健鼎": "3044.TW",
    "京元電子": "2449.TW",
    "仁寶": "2324.TW",
    "正新": "2105.TW",
    "大聯大": "3702.TW",
    "台中銀": "2812.TW",
    "微星": "2377.TW",
    "漢唐": "2404.TW",
    "億豐": "8464.TW",
    "豐泰": "9910.TW",
    "儒鴻": "1476.TW",
    "旭隼": "6409.TW",
    "聯強": "2347.TW",
    "寶成": "9904.TW",
    "東元": "1504.TW",
    "群聯": "8299.TWO"
}

# 合併公司列表
COMPANY_TO_TICKER = {**US_STOCKS, **TW_STOCKS}
COMPANY_CHOICES = list(COMPANY_TO_TICKER.keys())

# --- 核心功能函式 (Core Functions) ---

def get_stock_data(company_name: str, period: str = "1y") -> tuple[pd.DataFrame | None, dict | None, str | None]:
    """
    獲取並處理指定公司的股票數據與財務指標。

    Args:
        company_name: 公司顯示名稱或代號。
        period: 數據期間 (例如 "1y", "6mo")。

    Returns:
        一個包含 (DataFrame, dict, str) 的元組。
        - DataFrame: 包含歷史股價和移動平均線的 pandas DataFrame。
        - dict: 包含關鍵財務指標的字典。
        - str: 用於顯示的公司名稱。
        如果失敗則回傳 (None, None, None)。
    """
    ticker_symbol = COMPANY_TO_TICKER.get(company_name, None)
    display_name = company_name

    if not ticker_symbol:
        # 處理使用者手動輸入的代號
        if "." in company_name.upper() or len(company_name) <= 6:
             ticker_symbol = company_name.upper()
        else:
            return None, None, None

    try:
        ticker = yf.Ticker(ticker_symbol)
        info = ticker.info
        
        # 檢查是否能成功獲取資訊,若否,則此代號無效
        if not info or info.get('regularMarketPrice') is None:
            return None, None, None

        hist_data = ticker.history(period=period)
        if hist_data.empty:
            return None, None, None

        # 如果是手動輸入代號,則更新顯示名稱
        if company_name not in COMPANY_TO_TICKER:
            short_name = info.get('shortName', company_name)
            display_name = f"{short_name} ({ticker_symbol})"

        # 計算移動平均線
        hist_data['SMA_20'] = hist_data['Close'].rolling(window=20).mean()
        hist_data['SMA_60'] = hist_data['Close'].rolling(window=60).mean()

        # 獲取財務指標
        financial_metrics = {
            '市值 (Market Cap)': info.get('marketCap'),
            '本益比 (P/E)': info.get('trailingPE'),
            '股價淨值比 (P/B)': info.get('priceToBook'),
            '每股盈餘 (EPS)': info.get('trailingEps'),
            '毛利率 (Gross Margin)': info.get('grossMargins'),
            '營業利益率 (Operating Margin)': info.get('operatingMargins'),
            '股東權益報酬率 (ROE)': info.get('returnOnEquity'),
            '資產報酬率 (ROA)': info.get('returnOnAssets'),
            '自由現金流 (Free Cash Flow)': info.get('freeCashflow'), 
            '營運現金流 (Operating Cash Flow)': info.get('operatingCashflow'),
            '殖利率 (Yield)': info.get('dividendYield'),
        }
        return hist_data, financial_metrics, display_name
    except Exception as e:
        print(f"Error fetching data for {company_name}: {e}")
        return None, None, None

def plot_stock_chart(df: pd.DataFrame, company_name: str) -> plt.Figure:
    """
    根據 DataFrame 繪製股價走勢圖。
    """
    fig, ax = plt.subplots(figsize=(10, 6))

    x = np.arange(df.shape[0])
    ticks = [
        tick.strftime('%Y/%m/%d') if i % max(int(df.shape[0] / 6), 1) == 0 else ''
        for i, tick in enumerate(df.index)
    ]

    ax.plot(x, df['Close'], label='Close Price', color='royalblue', linewidth=2)
    ax.plot(x, df['SMA_20'], label='20-Day SMA', color='orange', linestyle='--', alpha=0.8)
    ax.plot(x, df['SMA_60'], label='60-Day SMA', color='green', linestyle='--', alpha=0.8)
    ax.set_title(f'{COMPANY_TO_TICKER.get(company_name, company_name)}', fontsize=16)
    ax.set_xlabel('Date', fontsize=12)
    ax.set_ylabel('Price', fontsize=12)
    ax.legend()
    ax.grid(True, axis='y', linestyle='--', alpha=0.6)
    plt.xticks(x, ticks, rotation=30)
    plt.tight_layout()
    return fig

def format_metrics_display(metrics: dict) -> str:
    """
    將財務指標格式化為易於閱讀的 Markdown 字串。
    """
    if not metrics:
        return "無法獲取財務指標。"
    
    display_text = ""
    for key, value in metrics.items():
        if value is None or pd.isna(value):
            formatted_value = "N/A"
        elif key in ['股東權益報酬率 (ROE)', '資產報酬率 (ROA)', '毛利率 (Gross Margin)', '營業利益率 (Operating Margin)']:
            formatted_value = f"{value:.2%}"
        elif key in ['市值 (Market Cap)', '自由現金流 (Free Cash Flow)', '營運現金流 (Operating Cash Flow)']:
            formatted_value = f"{value:,.0f}"
        else:
            formatted_value = f"{value:.2f}"
        display_text += f"- **{key}**: {formatted_value}\n"
    return display_text

def generate_trend_description(company_name: str, df: pd.DataFrame, metrics: dict) -> str:
    """
    使用 Gemini API 生成趨勢描述。
    """
    if not GOOGLE_API_KEY:
        return "### ⚠️ AI 分析失敗\nGoogle API 金鑰未設定。請在應用程式的環境變數中設定 GOOGLE_API_KEY。"
    
    try:
        latest_data = df.iloc[-1]

        ticker_symbol = COMPANY_TO_TICKER.get(company_name, None)
        
        prompt = f"""
        你是一位專業的金融市場分析師。請根據以下關於「{company_name}{"" if not ticker_symbol else " (%s)" % ticker_symbol }」的近期歷史數據和提供的財務指標,提供一份深入淺出的**趨勢分析**。

        **近期股價數據:**
        - 最新收盤價: {latest_data['Close']:.2f}
        - 20日移動平均線: {latest_data['SMA_20']:.2f}
        - 60日移動平均線: {latest_data['SMA_60']:.2f}

        **關鍵財務指標:**
        {format_metrics_display(metrics)}

        請根據以上所有資訊,以繁體中文,詳細分析股價趨勢,例如目前股價與移動平均線的關係,是處於多頭還是空頭排列,並結合財務指標解釋其技術面與基本面的意義,描述可能對未來趨勢的潛在影響。請將分析內容分點說明,使其清晰易讀,並直接輸出**僅包含分析的內容**即可,**不要**加入多餘的訊息。

        重要提醒:此部分僅為基於數據的趨勢**描述性文字**,請勿提供具體的價格預測或投資建議。
        """
        response = client.models.generate_content(
            model=MODEL_NAME,
            contents=prompt
        )
        return response.text
    except Exception as e:
        return f"### ⚠️ AI 分析失敗\n呼叫 AI 模型時發生錯誤:{e}"

def generate_recommendation(company_name: str, metrics: dict) -> str:
    """
    使用 Gemini API 生成投資建議。
    """
    if not GOOGLE_API_KEY:
        return "### ⚠️ 建議生成失敗\nGoogle API 金鑰未設定。"
        
    try:
        ticker_symbol = COMPANY_TO_TICKER.get(company_name, None)
        
        prompt = f"""
        您是一位專業的金融市場分析師。請僅根據以下關於「{company_name}{"" if not ticker_symbol else " (%s)" % ticker_symbol }」的**關鍵財務指標**,提供一個簡潔的投資潛力評估。

        **關鍵財務指標:**
        {format_metrics_display(metrics)}

        請根據這些指標,給出一個總結性的建議,例如「建議觀望」、「建議長期持有」或「建議謹慎評估」。並在建議下方,簡要說明您是基於哪些指標(例如高 ROE、低 P/E 等)做出此判斷的。

        請以繁體中文回覆,直接輸出**包含評估報告的內容**即可,**不要**加入多餘的文字,並強調這僅是基於提供數據的**初步建議**,非投資決策依據。
        """
        response = client.models.generate_content(
            model=MODEL_NAME,
            contents=prompt
        )
        return response.text
    except Exception as e:
        return f"### ⚠️ 建議生成失敗\n呼叫 AI 模型時發生錯誤:{e}"

# --- Gradio 介面 (Gradio Interface) ---

def create_ui():
    """建立並回傳 Gradio 應用程式介面。"""
    
    custom_css = """
    footer {display: none !important;}
    .gradio-container {font-family: 'IBM Plex Sans', sans-serif;}
    #student_footer {text-align: center; margin-top: 20px; color: #888;}
    """

    APP_DESCRIPTION = f"""
    # AI 股市趨勢探索家 📈

    **Designed by: {DEVELOPER_NAMES}**

    歡迎來到 AI 股市趨勢探索家!這是一個結合股票數據獲取、分析與 Google Gemini AI 技術的互動應用。

    **如何使用:**
    1.  從下拉式選單中選擇一家您感興趣的公司或者輸入您想查詢的公司名稱。例如:台積電。
    2.  點擊「分析!」按鈕。
    3.  應用程式將會為您呈現該公司近期的股票走勢圖、關鍵財務指標,並由 AI 分析與見解!
    """

    with gr.Blocks(theme=gr.themes.Monochrome(), css=custom_css) as app:
        gr.Markdown(APP_DESCRIPTION)

        with gr.Row():
            with gr.Column(scale=3):
                company_input = gr.Dropdown(
                    choices=COMPANY_CHOICES,
                    label="選擇或輸入公司名稱/代號",
                    value=COMPANY_CHOICES[0],
                    allow_custom_value=True
                )
            with gr.Column(scale=1):
                analyze_button = gr.Button("分析!", variant="primary")
        
        with gr.Row():
            with gr.Column(scale=3):
                output_plot = gr.Plot(label="股價走勢圖")
            
            with gr.Column(scale=3):
                with gr.Tabs():
                    with gr.TabItem("🤖 AI 趨勢洞察"):
                        output_ai_description = gr.Markdown()
                    with gr.TabItem("💡 AI 投資建議"):
                        output_ai_recommendation = gr.Markdown()
                    with gr.TabItem("📊 關鍵財務指標"):
                        output_metrics = gr.Markdown()

        gr.Markdown(f"**Designed by: {DEVELOPER_NAMES}**", elem_id="student_footer")

        def run_analysis(company_name):
            if not company_name:
                gr.Warning("請輸入或選擇一家公司!")
                return None, "請先選擇一家公司。", "請先選擇一家公司。", "請先選擇一家公司。"

            gr.Info("正在獲取數據並進行 AI 分析,請稍候...")
            
            # 1. 獲取數據
            stock_df, metrics, display_name = get_stock_data(company_name)
            
            if stock_df is None:
                err_msg = f"找不到「{company_name}」的相關數據,請確認公司名稱或代號是否正確。"
                gr.Error(err_msg)
                return None, err_msg, err_msg, err_msg

            # 2. 繪製圖表
            plot = plot_stock_chart(stock_df, display_name)
            
            # 3. 格式化指標
            metrics_display = format_metrics_display(metrics)

            # 4. 進行 AI 分析 (拆分為兩步驟)
            description = generate_trend_description(display_name, stock_df, metrics)
            recommendation = generate_recommendation(display_name, metrics)
            
            # 加上免責聲明
            disclaimer = "\n\n<br>\n*免責聲明:本分析由 AI 模型生成,僅供參考,不構成任何投資建議。*"
            description += disclaimer
            recommendation += disclaimer

            return plot, description, recommendation, metrics_display

        analyze_button.click(
            fn=run_analysis,
            inputs=[company_input],
            outputs=[output_plot, output_ai_description, output_ai_recommendation, output_metrics]
        )
        
        app.load(
            fn=run_analysis,
            inputs=[gr.Textbox(value=COMPANY_CHOICES[0], visible=False)],
            outputs=[output_plot, output_ai_description, output_ai_recommendation, output_metrics]
        )

    return app

stock_analyzer_app = create_ui()
stock_analyzer_app.launch(show_error=True, show_api=False)