Spaces:
Runtime error
Runtime error
| # src/features.py | |
| import pandas as pd | |
| import numpy as np | |
| import yfinance as yf | |
| from .config import Config | |
| from .data import data_service | |
| import twstock | |
| class FeatureLayer: | |
| def calculate_scores(ticker, df_price, df_inst, df_rev, fund, price, data_source): | |
| # ... (這部分保持不變,為了節省篇幅省略) ... | |
| scores = {} | |
| details = {} | |
| close = df_price['Close'] | |
| ma20 = close.rolling(20).mean().iloc[-1] | |
| ma60 = close.rolling(60).mean().iloc[-1] | |
| ts = 0; tm = [] | |
| if price > ma20: ts += 40; tm.append("站上月線") | |
| if price > ma60: ts += 40; tm.append("站上季線") | |
| if close.rolling(5).mean().iloc[-1] > ma20 > ma60: ts += 20; tm.append("多頭排列") | |
| scores['技術'] = min(ts, 100); details['技術'] = tm | |
| cs = 50; cm = [] | |
| if data_source == "FinMind": | |
| if not df_inst.empty and '外資' in df_inst.columns: | |
| f_sum = df_inst['外資'].tail(5).sum() | |
| if f_sum > 1: cs += 20; cm.append("外資買超") | |
| elif f_sum < -1: cs -= 20; cm.append("外資賣超") | |
| else: | |
| if not df_inst.empty and '主力動向' in df_inst.columns: | |
| m_sum = df_inst['主力動向'].tail(5).sum() | |
| if m_sum > 0: cs += 15; cm.append("主力吸籌(估)") | |
| else: cs -= 15; cm.append("主力調節(估)") | |
| scores['籌碼'] = max(0, min(cs, 100)); details['籌碼'] = cm if cm else ["籌碼中性"] | |
| vs = 0; vm = [] | |
| dy = (fund['avg_div']/price)*100 if price > 0 else 0 | |
| if dy > 4: vs += 50; vm.append(f"殖利率 {dy:.1f}%") | |
| try: | |
| pe = yf.Ticker(ticker).info.get('trailingPE', 999) | |
| if 0 < pe < 15: vs += 50; vm.append(f"PE {pe:.1f}") | |
| except: pass | |
| scores['價值'] = min(vs, 100); details['價值'] = vm | |
| ms = 50; mm = [] | |
| ret = (close.iloc[-1]/close.iloc[-20]-1)*100 | |
| if ret > 5: ms += 30; mm.append("月勢轉強") | |
| elif ret < -5: ms -= 20; mm.append("月勢轉弱") | |
| scores['動能'] = min(ms, 100); details['動能'] = mm | |
| gs = 40; gm = [] | |
| if not df_rev.empty: | |
| try: | |
| yoy = ((df_rev.iloc[-1]['revenue'] - df_rev.iloc[-13]['revenue']) / df_rev.iloc[-13]['revenue'])*100 | |
| if yoy > 20: gs = 100; gm.append(f"YoY +{yoy:.1f}%") | |
| elif yoy > 0: gs = 70; gm.append("營收成長") | |
| else: gs = 30; gm.append("營收衰退") | |
| except: pass | |
| scores['成長'] = gs; details['成長'] = gm | |
| rs = 80; rm = [] | |
| if df_price['Volume'].iloc[-1] > df_price['Volume'].tail(20).mean()*2: | |
| if price < ma60: rs += 20; rm.append("低檔爆量") | |
| else: rs -= 40; rm.append("高檔爆量") | |
| scores['風險'] = max(0, min(rs, 100)); details['風險'] = rm | |
| return scores, details | |
| def calculate_weighted_score(scores, strategy_name): | |
| weights = Config.STRATEGY_PROFILES.get(strategy_name, Config.STRATEGY_PROFILES["成長型 (預設)"]) | |
| total_score = 0 | |
| for k, v in scores.items(): | |
| total_score += v * weights.get(k, 0.16) | |
| return round(total_score, 1) | |
| def simple_backtest(df, hold_days=20): | |
| # ... (保持不變) ... | |
| if len(df) < 60: return None | |
| ma20 = df['Close'].rolling(20).mean() | |
| signal = (df['Close'] > ma20) & (ma20 > ma20.shift(1)) | |
| results = [] | |
| start_idx = max(20, len(df) - 120) | |
| for i in range(start_idx, len(df)-hold_days, 5): | |
| if signal.iloc[i]: | |
| entry = df['Open'].iloc[i+1] | |
| exit_p = df['Close'].iloc[i+hold_days] | |
| ret = (exit_p / entry - 1) * 100 | |
| results.append(ret) | |
| start_price = df['Close'].iloc[start_idx] | |
| end_price = df['Close'].iloc[-1] | |
| bench_ret = (end_price / start_price - 1) * 100 | |
| if not results: return {"勝率": 0, "平均報酬": 0, "次數": 0, "基準報酬": round(bench_ret, 2)} | |
| wins = [r for r in results if r > 0] | |
| return {"勝率": round(len(wins)/len(results)*100, 1), "平均報酬": round(np.mean(results), 2), "次數": len(results), "基準報酬": round(bench_ret, 2)} | |
| # ★★★ 重點修改區域:ETF 智能顧問 ★★★ | |
| def run_etf_screener(category, sort_by): | |
| if category != "全部": target_dict = Config.ETF_DATABASE.get(category, {}) | |
| else: target_dict = {k:v for d in Config.ETF_DATABASE.values() for k,v in d.items()} | |
| tickers = list(target_dict.keys()) | |
| if not tickers: return pd.DataFrame() | |
| data = data_service.fetch_batch_history(tickers) | |
| results = [] | |
| for ticker in tickers: | |
| try: | |
| hist = data[ticker] if len(tickers) > 1 else data | |
| hist = hist.dropna(subset=['Close']) | |
| if len(hist) < 60: continue | |
| # 1. 數據計算 | |
| price = hist['Close'].iloc[-1] | |
| # 季報酬 (動能) | |
| r3 = ((hist['Close'].iloc[-1]/hist['Close'].iloc[-60])-1)*100 | |
| # 月報酬 (短期) | |
| r1 = ((hist['Close'].iloc[-1]/hist['Close'].iloc[-20])-1)*100 | |
| # 波動率 (風險) | |
| vol = hist['Close'].pct_change().std() * 100 * (252**0.5) | |
| # 季線趨勢 (MA60) | |
| ma60 = hist['Close'].rolling(60).mean().iloc[-1] | |
| # 2. AI 評分與診斷 | |
| score = 0 | |
| tags = [] | |
| # 動能分 (40%) | |
| if r3 > 10: score += 40; tags.append("🚀 強勢") | |
| elif r3 > 0: score += 20 | |
| elif r3 < -5: tags.append("📉 弱勢") | |
| # 趨勢分 (30%) | |
| if price > ma60: score += 30 | |
| # 風險分 (30%) - 波動越低分越高 (針對 ETF 屬性) | |
| if vol < 12: score += 30; tags.append("🛡️ 穩健") | |
| elif vol < 20: score += 20 | |
| else: tags.append("🌊 活潑") # 波動大 | |
| # 亮點標籤:高CP值 (漲幅/波動 > 1) | |
| if vol > 0 and (r3 / vol) > 1: tags.append("💎 高CP") | |
| ai_comment = " ".join(tags[:2]) # 取前兩個 | |
| # 3. 操作建議 | |
| if score >= 80: suggestion = "🟢 順勢佈局" | |
| elif score >= 60: suggestion = "🟡 區間操作" | |
| else: suggestion = "⚪ 暫時觀望" | |
| results.append({ | |
| "代碼": ticker.replace('.TW',''), | |
| "ETF名稱": target_dict[ticker], | |
| "建議": suggestion, # 新增 | |
| "AI 診斷": ai_comment, # 新增 | |
| "綜合評分": score, # 新增 | |
| "現價": round(price, 2), | |
| "季報酬%": round(r3, 2), | |
| "波動率%": round(vol, 1) | |
| }) | |
| except: continue | |
| df = pd.DataFrame(results) | |
| if df.empty: return df | |
| # 欄位排序 | |
| cols = ["代碼", "ETF名稱", "建議", "AI 診斷", "綜合評分", "季報酬%", "波動率%", "現價"] | |
| df = df[cols] | |
| # 根據使用者選擇排序 | |
| sort_col = sort_by if sort_by in df.columns else "綜合評分" | |
| return df.sort_values(sort_col, ascending=False) | |
| def run_value_screener(category): | |
| # ... (保持不變) ... | |
| candidates = Config.STOCK_POOLS.get(category, []) | |
| results = [] | |
| for t in candidates: | |
| try: | |
| stock = yf.Ticker(t) | |
| info = stock.info | |
| pe = info.get('trailingPE', 999) or 999 | |
| dy = (info.get('dividendYield', 0) or 0) * 100 | |
| pb = info.get('priceToBook', 999) or 999 | |
| roe = (info.get('returnOnEquity', 0) or 0) * 100 | |
| beta = info.get('beta', 1.0) or 1.0 | |
| price = info.get('currentPrice', 0) | |
| score = 0 | |
| if 0 < pe < 12: score += 25 | |
| elif pe < 18: score += 15 | |
| if dy > 5: score += 25 | |
| elif dy > 3: score += 15 | |
| if pb < 1.2: score += 15 | |
| if roe > 15: score += 20 | |
| elif roe > 10: score += 10 | |
| if 0 < beta < 0.8: score += 15 | |
| elif beta > 1.5: score -= 10 | |
| tags = [] | |
| if roe > 15: tags.append("🏆 績優") | |
| if dy > 5: tags.append("💰 現金牛") | |
| if 0 < pe < 10: tags.append("💎 超值") | |
| if 0 < beta < 0.6: tags.append("🛡️ 抗跌") | |
| if not tags: tags.append("⚖️ 中性") | |
| ai_comment = " ".join(tags[:2]) | |
| if score >= 75: suggestion = "🟢 積極佈局" | |
| elif score >= 60: suggestion = "🟡 逢低買進" | |
| else: suggestion = "⚪ 再觀察" | |
| name = t | |
| if t.split('.')[0] in twstock.codes: name = twstock.codes[t.split('.')[0]].name | |
| results.append({ | |
| "代碼": t, "名稱": name, "AI 診斷": ai_comment, "建議": suggestion, | |
| "評分": int(score), "ROE%": round(roe, 1), "殖利率%": round(dy, 1), | |
| "本益比": round(pe, 1) if pe != 999 else "N/A", "風險係數": round(beta, 2), "現價": price | |
| }) | |
| except: continue | |
| df = pd.DataFrame(results) | |
| if df.empty: return df | |
| cols = ["代碼", "名稱", "建議", "AI 診斷", "評分", "ROE%", "殖利率%", "本益比", "風險係數", "現價"] | |
| df = df[cols] | |
| return df.sort_values("評分", ascending=False) |