liln520/My-AI-Trader
0
1import streamlit as st2import pandas as pd3import requests4import plotly.graph_objects as go5from plotly.subplots import make_subplots6import datetime7import pytz8import numpy as np9import warnings10import yfinance as yf11import json12import os13import time14import io15import math16import re17import urllib.request18import xml.etree.ElementTree as ET19from email.utils import parsedate_to_datetime20from io import StringIO21from decimal import Decimal, ROUND_HALF_UP22import concurrent.futures23import logging24from PIL import Image, ImageDraw, ImageFont25 26# ==========================================27# 0. 系統層級降噪與防護 (解決終端機報錯)28# ==========================================29logging.getLogger('streamlit.runtime.scriptrunner_utils.script_run_context').setLevel(logging.ERROR)30logging.getLogger('yfinance').setLevel(logging.CRITICAL)31 32# ==========================================33# 1. 企業級視覺規範與戰備環境 (V44.2 夜盤修復版)34# ==========================================35VERSION_TAG = "V44.2 全息量價加固版 (夜盤修復)"36warnings.filterwarnings('ignore')37st.set_page_config(page_title=f"戰神企業終端 {VERSION_TAG}", layout="wide", initial_sidebar_state="expanded")38 39st.markdown("""40 <style>41 /* 全域物理禁黑:焦土級強制亮色主題覆蓋 */42 :root { 43 color-scheme: light !important; 44 --text-color: #0F172A !important;45 --background-color: #FFFFFF !important;46 --secondary-background-color: #F8FAFC !important;47 }48 * { box-sizing: border-box !important; -webkit-font-smoothing: antialiased; }49 #MainMenu {visibility: hidden;} footer {visibility: hidden;}50 header[data-testid="stHeader"] { background-color: transparent !important; z-index: 10 !important; }51 header[data-testid="stHeader"] * { color: #0F172A !important; }52 53 /* 空間優化:極致縮減頂部留白與邊距 */54 div[data-testid="stAppViewBlockContainer"] { padding-top: 1rem !important; padding-bottom: 3rem !important; }55 html, body, .stApp, [data-testid="stAppViewContainer"], .main { 56 background-color: #FFFFFF !important; color: #0F172A !important; font-family: "Microsoft JhengHei", sans-serif;57 }58 59 /* 側邊欄淨化 */60 section[data-testid="stSidebar"], [data-testid="stSidebarNav"], div[data-testid="stSidebarContent"] { 61 background-color: #F8FAFC !important; border-right: 2px solid #E2E8F0 !important; z-index: 999999 !important;62 }63 section[data-testid="stSidebar"] * { color: #0F172A !important; }64 65 /* 戰術 LED 燈號樣式 */66 .led-box { width: 12px; height: 12px; border-radius: 50%; display: inline-block; box-shadow: 0 0 4px rgba(0,0,0,0.2); }67 .led-green { background-color: #00FF00; box-shadow: 0 0 10px #00FF00; animation: blink 2s infinite; }68 .led-blue { background-color: #2563EB; box-shadow: 0 0 10px #2563EB; }69 .led-yellow { background-color: #FACC15; box-shadow: 0 0 10px #FACC15; }70 .led-red { background-color: #FF0000; box-shadow: 0 0 10px #FF0000; }71 @keyframes blink { 0% { opacity: 1; } 50% { opacity: 0.4; } 100% { opacity: 1; } }72 73 /* 開關 (Toggle) 與 Radio (時域矩陣) 顯性化與文字強制黑字 */74 div[data-testid="stToggle"] { background-color: transparent !important; }75 div[data-testid="stToggle"] label[data-baseweb="checkbox"] > div:first-of-type { 76 background-color: #E2E8F0 !important; border: 2px solid #0F172A !important;77 }78 div[data-testid="stToggle"] label[data-baseweb="checkbox"] input:checked + div { 79 background-color: #FF0000 !important; border-color: #0F172A !important;80 }81 div[data-testid="stToggle"] *, div[data-testid="stWidgetLabel"] *, div[role="radiogroup"] * { 82 color: #0F172A !important; font-weight: 900 !important; opacity: 1 !important;83 }84 85 /* 按鈕與彈出窗核彈級純白化 */86 button, div[data-testid="stPopover"] > button, div[data-testid="stButton"] > button, 87 div[data-testid="stDownloadButton"] > button, button[data-baseweb="button"] { 88 background-color: #FFFFFF !important; color: #0F172A !important; border: 2px solid #0F172A !important; 89 border-radius: 4px !important; font-weight: 900 !important; box-shadow: none !important; 90 margin-top: 4px !important; transition: all 0.2s; 91 }92 button:hover, div[data-testid="stPopover"] > button:hover, div[data-testid="stButton"] > button:hover {93 background-color: #FFFFFF !important; color: #FF0000 !important; border-color: #FF0000 !important; 94 box-shadow: 0 0 8px rgba(255, 0, 0, 0.4) !important; 95 }96 div[data-baseweb="popover"] > div, div[role="dialog"], div[data-testid="stPopoverBody"] { background-color: #FFFFFF !important; }97 div[data-testid="stPopoverBody"] { 98 border: 3px solid #0F172A !important; border-radius: 4px !important; 99 box-shadow: 4px 4px 0px #0F172A !important; padding: 12px !important; display: flex; flex-direction: column; gap: 8px;100 }101 div[data-testid="stPopoverBody"] * { color: #0F172A !important; font-weight: 900 !important; }102 div[data-testid="stPopoverBody"] hr { display: none !important; }103 104 /* 摺疊面板 (Expander) */105 div[data-testid="stExpander"] { background-color: #F8FAFC !important; border: 2px solid #0F172A !important; border-radius: 4px !important; overflow: hidden; margin-bottom: 10px; }106 div[data-testid="stExpander"] summary { background-color: #F8FAFC !important; color: #0F172A !important; transition: none !important; }107 div[data-testid="stExpander"] summary:hover, div[data-testid="stExpander"] summary:active, div[data-testid="stExpander"] summary:focus { 108 background-color: #FFFFFF !important; color: #FF0000 !important;109 }110 div[data-testid="stExpander"] summary p { font-weight: 900 !important; color: inherit !important; }111 div[data-testid="stExpander"] summary svg { fill: currentColor !important; }112 div[data-testid="stExpander"] div[role="region"] { background-color: #FFFFFF !important; color: #0F172A !important; padding: 10px !important; border-top: 1px dashed #CBD5E1 !important;}113 114 /* 表單元件徹底白化 */115 div[data-baseweb="select"] > div, div[data-baseweb="input"] > div, div[data-baseweb="base-input"], div[data-baseweb="textarea"] { 116 background-color: #FFFFFF !important; border: 2px solid #0F172A !important;117 }118 div[data-baseweb="select"] *, div[data-baseweb="input"] *, input, textarea { 119 color: #0F172A !important; -webkit-text-fill-color: #0F172A !important; font-weight: 900 !important; background-color: transparent !important; 120 }121 122 /* 新聞連結專用樣式 */123 .news-link { color: #2563EB !important; font-weight: 900 !important; text-decoration: none !important; transition: all 0.2s; }124 .news-link:hover { color: #FF0000 !important; text-decoration: underline !important; }125 126 /* 戰情室單行全息佈局 */127 .war-room-header { 128 display: grid; 129 grid-template-columns: 1.5fr 0.8fr 0.8fr 1fr 1.6fr 1.2fr 2fr; 130 background-color: transparent !important; color: #0F172A !important; 131 padding: 4px 10px; font-weight: 900; border-bottom: 2px solid #0F172A !important; margin-bottom: 4px; font-size: 0.9rem; 132 }133 .war-room-row { 134 display: grid; 135 grid-template-columns: 1.5fr 0.8fr 0.8fr 1fr 1.6fr 1.2fr 2fr; 136 background-color: #FFFFFF; border-bottom: 1px solid #F1F5F9; border-left: 5px solid #0F172A; 137 padding: 6px 10px; align-items: center; margin-bottom: 2px; border-radius: 2px; transition: all 0.2s; 138 }139 .war-room-row:hover { background-color: #F8FAFC; border-left-color: #FF0000; box-shadow: 0 2px 4px rgba(0,0,0,0.05); }140 141 .wr-col { font-weight: 900; font-size: 0.95rem; color: #0F172A; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }142 .wr-col-center { display: flex; justify-content: center; align-items: center; text-align: center; }143 .wr-col-right { display: flex; justify-content: flex-end; align-items: center; text-align: right; }144 .wr-col-left { display: flex; justify-content: flex-start; align-items: center; text-align: left; }145 146 @media (max-width: 767px) {147 .war-room-header { display: none !important; }148 .war-room-row { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; border-left-width: 5px; padding: 10px; border-bottom: 1px solid #E2E8F0; }149 .wr-mobile-flex { display: flex; justify-content: space-between; width: 100%; align-items: center; }150 .wr-mobile-tags { display: flex; flex-wrap: wrap; gap: 6px; width: 100%; align-items: center; justify-content: flex-start !important; }151 .war-room-mobile-only { display: block !important; font-size: 1.1rem; }152 .desktop-only-price { display: none !important; }153 }154 155 /* 標籤與名片 (淨化 detail-block) */156 .pill-tag { 157 background:#FFFFFF !important; padding: 2px 6px; border: 2px solid #0F172A; 158 font-weight:900; border-radius: 2px; font-size: 0.85rem; 159 display: inline-flex; justify-content: center; align-items: center; 160 white-space: nowrap; color: #0F172A !important; margin-bottom: 2px; 161 }162 .pill-tag-red { border-color: #FF0000 !important; color: #FF0000 !important; }163 .pill-tag-green { border-color: #008000 !important; color: #008000 !important; }164 .pill-tag-warn { background:#FFFFFF !important; border: 2px solid #D97706 !important; color: #D97706 !important; font-weight:900;}165 .pill-tag-gap { background:#FFF5F5 !important; border: 2px dashed #FF0000 !important; color: #FF0000 !important; font-weight:900;}166 167 /* 漲停鎖死與產業專用特效標籤 */168 .pill-tag-limit { background:#FF0000 !important; color:#FFFFFF !important; border-color:#0F172A !important; font-weight:900; box-shadow: 2px 2px 0px #0F172A;}169 .pill-tag-sector { border-color: #64748B !important; color: #64748B !important; font-weight: 900; }170 .pill-tag-stale { background: #FFFBEB !important; border-color: #D97706 !important; color: #D97706 !important; }171 172 /* 脈衝特效與加固容器防護 */173 @keyframes pulseBorder {174 0% { box-shadow: 0 0 0 0 rgba(255, 0, 0, 0.5); }175 70% { box-shadow: 0 0 0 6px rgba(255, 0, 0, 0); }176 100% { box-shadow: 0 0 0 0 rgba(255, 0, 0, 0); }177 }178 .pulse-active { animation: pulseBorder 1.5s infinite; border-color: #FF0000 !important; }179 .card-box { background: #FFFFFF !important; border: 2px solid #0F172A !important; padding: 10px; margin-bottom: 8px !important; border-radius: 4px !important; display: flex; flex-direction: column; justify-content: space-between; min-height: 125px; position: relative; box-shadow: 2px 2px 0px #E2E8F0; }180 181 /* 其他元件 */182 .giant-price { font-size: 2rem; font-weight: 900; color: #0F172A !important; line-height: 1.1; }183 184 .sync-stamp { color: #008000; font-weight: 900; border: 2px solid #008000; padding: 4px 8px; border-radius:4px; font-size: 0.9rem; display:inline-block; background-color: #FFFFFF; }185 .sleep-stamp { color: #0F172A; font-weight: 900; border: 2px solid #0F172A; padding: 4px 8px; border-radius:4px; font-size: 0.9rem; display:inline-block; background-color: #E2E8F0; }186 .rush-stamp { color: #FF0000; font-weight: 900; border: 2px solid #FF0000; padding: 4px 8px; border-radius:4px; font-size: 0.9rem; display:inline-block; background-color: #FFF5F5; }187 188 div[data-testid="stTabs"] { background-color: #FFFFFF !important; }189 button[data-baseweb="tab"] { background-color: #FFFFFF !important; border-bottom: 3px solid #E2E8F0 !important;}190 button[data-baseweb="tab"] p { color: #0F172A !important; font-weight: 900 !important; font-size: 1.1rem !important; }191 button[data-baseweb="tab"][aria-selected="true"] { border-bottom: 4px solid #FF0000 !important; }192 button[data-baseweb="tab"][aria-selected="true"] p { color: #FF0000 !important; }193 .js-plotly-plot .plotly .main-svg { background: transparent !important; }194 .tw-up { color: #FF0000 !important; font-weight: 900; }195 .tw-down { color: #008000 !important; font-weight: 900; }196 197 .progress-container { width: 100%; background-color: #E2E8F0; border-radius: 4px; overflow: hidden; margin-top: 4px; border: 1px solid #0F172A; }198 .progress-bar { height: 18px; background-color: #0F172A; color: #FFFFFF; font-size: 0.75rem; font-weight: 900; text-align: center; line-height: 18px; }199 200 .mini-radar-box { border: 1px solid #0F172A; padding: 6px; border-radius: 4px; margin-bottom: 6px; background-color: #FFFFFF; }201 .mini-radar-title { font-size: 0.85rem; font-weight: 900; color: #0F172A; border-bottom: 1px dashed #CBD5E1; margin-bottom: 4px; }202 .mini-radar-data { font-size: 1rem; font-weight: 900; display: flex; justify-content: space-between; }203 204 /* 戰略決策樹 抽屜卡片樣式 */205 .drawer-card { border: 1px solid #E2E8F0; border-left: 4px solid #0F172A; background-color: #FFFFFF; padding: 10px; margin-bottom: 8px; border-radius: 4px; }206 .drawer-card-title { font-weight: 900; color: #0F172A; font-size: 0.95rem; border-bottom: 1px dashed #E2E8F0; padding-bottom: 4px; margin-bottom: 6px; }207 .drawer-card-content { font-size: 0.85rem; font-weight: 900; color: #334155; line-height: 1.4; }208 209 /* 當沖極限警示與資料狀態 */210 .daytrade-alert { border: 3px dashed #FF0000 !important; background-color: #FFF5F5 !important; }211 .data-stale-alert { opacity: 0.8; border: 2px dashed #D97706 !important; background-color: #FFFBEB !important; }212 .sector-header { background: #F8FAFC; border-left: 5px solid #0F172A; padding: 6px 12px; margin: 15px 0 8px 0; font-weight: 900; color: #0F172A; font-size: 1.1rem; }213 </style>214""", unsafe_allow_html=True)215 216# ==========================================217# 2. 核心模組、權限識別與持久化目錄定義218# ==========================================219MASTER_ACCOUNT, VICE_ACCOUNT = "最愛你的阿壞", "黃婉蓉"220FUGLE_KEY = os.getenv("FUGLE_KEY", "MTU2Njc0ZjktYTYyYS00OWViLTliNTUtZTJkMWYwYzE1YmQwIGE1NzZkZTE1LTkwNTAtNGJiMC1hYTIwLTFmMjVhYjA5NWRjMw==")221PERSISTENT_DIR = "/data"222REPORT_DIR = os.path.join(PERSISTENT_DIR, "reports")223os.makedirs(REPORT_DIR, exist_ok=True)224 225if os.path.exists(PERSISTENT_DIR) and os.path.isdir(PERSISTENT_DIR):226 DB_FILE = os.path.join(PERSISTENT_DIR, "corporate_db.json")227 TICKER_INDEX_FILE = os.path.join(PERSISTENT_DIR, "ticker_index.json")228 MARKET_SNAPSHOT_FILE = os.path.join(PERSISTENT_DIR, "market_snapshot.json")229else:230 DB_FILE = "corporate_db.json"231 TICKER_INDEX_FILE = "ticker_index.json"232 MARKET_SNAPSHOT_FILE = "market_snapshot.json"233 234class LineBroadcaster:235 def __init__(self, token, user_id):236 self.token = token237 self.user_id = user_id238 239 def send_text(self, text):240 try:241 headers = {'Authorization': f'Bearer {self.token}', 'Content-Type': 'application/json'}242 data = {'to': self.user_id, 'messages': [{'type': 'text', 'text': text}]}243 res = requests.post('https://api.line.me/v2/bot/message/push', headers=headers, json=data, timeout=5)244 return res.status_code == 200245 except: return False246 247def get_market_status():248 tz = pytz.timezone('Asia/Taipei')249 now = datetime.datetime.now(tz)250 if now.weekday() >= 5: return "CLOSED", 3600251 t = now.time()252 if datetime.time(8, 30) <= t < datetime.time(9, 0): return "PRE_RUSH", 5253 elif datetime.time(9, 0) <= t < datetime.time(9, 30): return "GOLDEN", 3254 elif datetime.time(9, 30) <= t < datetime.time(13, 20): return "CRUISE", 10255 elif datetime.time(13, 20) <= t < datetime.time(13, 35): return "FINAL_BATTLE", 3256 elif datetime.time(13, 35) <= t < datetime.time(14, 30): return "SETTLEMENT", 60257 elif datetime.time(14, 30) <= t < datetime.time(15, 0): return "POST_MARKET_SYNC", 60258 return "CLOSED", 3600259 260def get_fragment_decorator():261 status, interval = get_market_status()262 if hasattr(st, "fragment"):263 return st.fragment(run_every=interval) if interval > 0 else st.fragment()264 def dummy_decorator(func): return func265 return dummy_decorator266 267dynamic_refresh = get_fragment_decorator()268fast_sync_refresh = get_fragment_decorator()269 270# ==========================================271# 3. 企業代碼工廠 (動態補正與創新板識別)272# ==========================================273CORE_ASSETS = {274 "2330": {"name": "台積電", "category": "上市", "suffix": ".TW", "sector": "半導體"},275 "2317": {"name": "鴻海", "category": "上市", "suffix": ".TW", "sector": "其他電子"},276 "2454": {"name": "聯發科", "category": "上市", "suffix": ".TW", "sector": "半導體"},277 "3231": {"name": "緯創", "category": "上市", "suffix": ".TW", "sector": "電腦及週邊設備"},278 "2382": {"name": "廣達", "category": "上市", "suffix": ".TW", "sector": "電腦及週邊設備"},279 "2603": {"name": "長榮", "category": "上市", "suffix": ".TW", "sector": "航運業"},280 "2308": {"name": "台達電", "category": "上市", "suffix": ".TW", "sector": "電子零組件"},281 "3711": {"name": "日月光投控", "category": "上市", "suffix": ".TW", "sector": "半導體"},282 "6669": {"name": "緯穎", "category": "上市", "suffix": ".TW", "sector": "電腦及週邊設備"},283 "2881": {"name": "富邦金", "category": "上市", "suffix": ".TW", "sector": "金融保險"},284 "2882": {"name": "國泰金", "category": "上市", "suffix": ".TW", "sector": "金融保險"},285 "2891": {"name": "中信金", "category": "上市", "suffix": ".TW", "sector": "金融保險"},286 "2376": {"name": "技嘉", "category": "上市", "suffix": ".TW", "sector": "電腦及週邊設備"},287 "3017": {"name": "奇鋐", "category": "上市", "suffix": ".TW", "sector": "電腦及週邊設備"},288 "3324": {"name": "雙鴻", "category": "上市", "suffix": ".TW", "sector": "電腦及週邊設備"},289 "2368": {"name": "金像電", "category": "上市", "suffix": ".TW", "sector": "電子零組件"},290 "3037": {"name": "欣興", "category": "上市", "suffix": ".TW", "sector": "電子零組件"},291 "2353": {"name": "宏碁", "category": "上市", "suffix": ".TW", "sector": "電腦及週邊設備"},292 "2409": {"name": "友達", "category": "上市", "suffix": ".TW", "sector": "光電業"},293 "3481": {"name": "群創", "category": "上市", "suffix": ".TW", "sector": "光電業"},294 "2609": {"name": "陽明", "category": "上市", "suffix": ".TW", "sector": "航運業"},295 "2615": {"name": "萬海", "category": "上市", "suffix": ".TW", "sector": "航運業"},296 "2344": {"name": "華邦電", "category": "上市", "suffix": ".TW", "sector": "半導體"},297 "2303": {"name": "聯電", "category": "上市", "suffix": ".TW", "sector": "半導體"},298 "2357": {"name": "華碩", "category": "上市", "suffix": ".TW", "sector": "電腦及週邊設備"},299 "2888": {"name": "新光金", "category": "上市", "suffix": ".TW", "sector": "金融保險"},300 "1519": {"name": "華城", "category": "上市", "suffix": ".TW", "sector": "電機機械"},301 "1504": {"name": "東元", "category": "上市", "suffix": ".TW", "sector": "電機機械"},302 "8996": {"name": "高力", "category": "上市", "suffix": ".TW", "sector": "電機機械"},303 "1301": {"name": "台塑", "category": "上市", "suffix": ".TW", "sector": "塑膠工業"},304 "1326": {"name": "台化", "category": "上市", "suffix": ".TW", "sector": "化學工業"},305 "1722": {"name": "台肥", "category": "上市", "suffix": ".TW", "sector": "化學工業"},306 "1711": {"name": "永光", "category": "上市", "suffix": ".TW", "sector": "化學工業"},307 "4746": {"name": "台耀", "category": "上櫃", "suffix": ".TWO", "sector": "生技醫療業"},308 "1101": {"name": "台泥", "category": "上市", "suffix": ".TW", "sector": "水泥工業"},309 "2002": {"name": "中鋼", "category": "上市", "suffix": ".TW", "sector": "鋼鐵工業"},310 "2618": {"name": "長榮航", "category": "上市", "suffix": ".TW", "sector": "航運業"},311 "2207": {"name": "和泰車", "category": "上市", "suffix": ".TW", "sector": "汽車工業"},312 "8069": {"name": "元太", "category": "上櫃", "suffix": ".TWO", "sector": "光電業"},313 "3149": {"name": "正達", "category": "上市", "suffix": ".TW", "sector": "光電業"},314 "4931": {"name": "新盛力", "category": "上櫃", "suffix": ".TWO", "sector": "電子零組件"},315 "6191": {"name": "精成科", "category": "上市", "suffix": ".TW", "sector": "電子零組件"},316 "3443": {"name": "創意", "category": "上市", "suffix": ".TW", "sector": "半導體"},317 "6231": {"name": "系微", "category": "上櫃", "suffix": ".TWO", "sector": "電子零組件"},318 "3008": {"name": "大立光", "category": "上市", "suffix": ".TW", "sector": "光電業"},319 "8046": {"name": "南電", "category": "上市", "suffix": ".TW", "sector": "電子零組件"},320 "3661": {"name": "世芯-KY", "category": "上市", "suffix": ".TW", "sector": "半導體"},321 "5347": {"name": "世界", "category": "上櫃", "suffix": ".TWO", "sector": "半導體"},322 "6187": {"name": "萬潤", "category": "上櫃", "suffix": ".TWO", "sector": "半導體"},323 "3454": {"name": "晶睿", "category": "上市", "suffix": ".TW", "sector": "光電業"},324 "6415": {"name": "矽力*-KY", "category": "上市", "suffix": ".TW", "sector": "半導體"},325 "8299": {"name": "群聯", "category": "上櫃", "suffix": ".TWO", "sector": "半導體"},326 "2313": {"name": "華通", "category": "上市", "suffix": ".TW", "sector": "電子零組件"},327 "3013": {"name": "晟銘電", "category": "上市", "suffix": ".TW", "sector": "電腦及週邊設備"},328 "2383": {"name": "台光電", "category": "上市", "suffix": ".TW", "sector": "電子零組件"},329 "3034": {"name": "聯詠", "category": "上市", "suffix": ".TW", "sector": "半導體"},330 "3533": {"name": "嘉澤", "category": "上市", "suffix": ".TW", "sector": "電子零組件"},331 "2449": {"name": "京元電子", "category": "上市", "suffix": ".TW", "sector": "半導體"},332 "6282": {"name": "康舒", "category": "上市", "suffix": ".TW", "sector": "電子零組件"},333 "2356": {"name": "英業達", "category": "上市", "suffix": ".TW", "sector": "電腦及週邊設備"},334 "6271": {"name": "同欣電", "category": "上市", "suffix": ".TW", "sector": "半導體"},335 "3035": {"name": "智原", "category": "上市", "suffix": ".TW", "sector": "半導體"},336 "1234": {"name": "黑松", "category": "上市", "suffix": ".TW", "sector": "食品工業"},337 "1802": {"name": "台玻", "category": "上市", "suffix": ".TW", "sector": "玻璃陶瓷"},338 "6477": {"name": "安集", "category": "上市", "suffix": ".TW", "sector": "光電業"},339 "2049": {"name": "上銀", "category": "上市", "suffix": ".TW", "sector": "電機機械"},340 "5452": {"name": "佶優", "category": "上櫃", "suffix": ".TWO", "sector": "電子零組件"},341 "4908": {"name": "前鼎", "category": "上市", "suffix": ".TW", "sector": "光電業"},342 "6689": {"name": "伊雲谷", "category": "上市", "suffix": ".TW", "sector": "資訊服務業"},343 "6789": {"name": "采鈺", "category": "上市", "suffix": ".TW", "sector": "半導體"},344 "3563": {"name": "牧德", "category": "上櫃", "suffix": ".TWO", "sector": "光電業"},345 "2406": {"name": "國碩", "category": "上市", "suffix": ".TW", "sector": "光電業"},346 "2338": {"name": "光罩", "category": "上市", "suffix": ".TW", "sector": "半導體"},347 "6443": {"name": "元晶", "category": "上市", "suffix": ".TW", "sector": "光電業"},348 "6456": {"name": "GIS-KY", "category": "上市", "suffix": ".TW", "sector": "光電業"},349 "2616": {"name": "山富", "category": "上櫃", "suffix": ".TWO", "sector": "觀光事業"},350 "7730": {"name": "暉盛-創", "category": "創新板", "suffix": ".TW", "sector": "未分類"},351 "6179": {"name": "亞通", "category": "上櫃", "suffix": ".TWO", "sector": "電子零組件"},352 "9955": {"name": "佳龍", "category": "上市", "suffix": ".TW", "sector": "其他業"},353 "3092": {"name": "鴻碩", "category": "上櫃", "suffix": ".TWO", "sector": "電子零組件"},354 "2467": {"name": "志聖", "category": "上市", "suffix": ".TW", "sector": "半導體"},355 "0050": {"name": "元大台灣50", "category": "ETF", "suffix": ".TW", "sector": "ETF"},356 "0056": {"name": "元大高股息", "category": "ETF", "suffix": ".TW", "sector": "ETF"},357 "00878": {"name": "國泰永續高股息", "category": "ETF", "suffix": ".TW", "sector": "ETF"},358 "00919": {"name": "群益台灣精選高息", "category": "ETF", "suffix": ".TW", "sector": "ETF"},359 "IX0001": {"name": "加權指數", "category": "大盤", "suffix": "", "sector": "大盤"}360}361 362class EnterpriseTickerFactory:363 @staticmethod364 def _fetch_and_save_index():365 result = {}366 headers = {'User-Agent': 'Mozilla/5.0'}367 try:368 url_twse = "https://openapi.twse.com.tw/v1/exchangeReport/STOCK_DAY_ALL"369 res = requests.get(url_twse, headers=headers, timeout=5).json()370 for item in res:371 code = str(item.get('Code', '')).strip()372 name = str(item.get('Name', '')).strip()373 if code and name and code.isalnum():374 result[code] = {'name': name, 'category': '上市', 'suffix': '.TW', 'code': code, 'sector': '動態標的'}375 result[name] = {'code': code, 'category': '上市', 'suffix': '.TW', 'sector': '動態標的'}376 except: pass377 378 try:379 url_tpex = "https://www.tpex.org.tw/openapi/v1/tpex_mainboard_quotes"380 res = requests.get(url_tpex, headers=headers, timeout=5).json()381 for item in res:382 code = str(item.get('SecuritiesCompanyCode', '')).strip()383 name = str(item.get('CompanyName', '')).strip()384 if code and name and code.isalnum():385 result[code] = {'name': name, 'category': '上櫃', 'suffix': '.TWO', 'code': code, 'sector': '動態標的'}386 result[name] = {'code': code, 'category': '上櫃', 'suffix': '.TWO', 'sector': '動態標的'}387 except: pass388 389 if result:390 try:391 with open(TICKER_INDEX_FILE, 'w', encoding='utf-8') as f:392 json.dump(result, f, ensure_ascii=False)393 except: pass394 return result395 396 @staticmethod397 @st.cache_data(ttl=86400)398 def load_local_index():399 if os.path.exists(TICKER_INDEX_FILE):400 try:401 with open(TICKER_INDEX_FILE, 'r', encoding='utf-8') as f:402 return json.load(f)403 except: pass404 return EnterpriseTickerFactory._fetch_and_save_index()405 406 @staticmethod407 def get_routing_info(symbol_raw):408 val = str(symbol_raw).strip().upper()409 if val == "7730": return "7730.TW", "創新板", "7730 暉盛-創"410 alias_map = {"GG": "2330", "台積電": "2330", "發哥": "2454", "聯發科": "2454", "海公公": "2317", "鴻海": "2317", "大盤": "IX0001", "加權": "IX0001"}411 if val in alias_map: val = alias_map[val]412 413 if val in CORE_ASSETS:414 info = CORE_ASSETS[val]415 return f"{val}{info['suffix']}", info['category'], f"{val} {info['name']}"416 417 isin_cache = EnterpriseTickerFactory.load_local_index()418 if val in isin_cache:419 info = isin_cache[val]420 c = info.get('code', val)421 n = info.get('name', val)422 cat = info.get('category', '上市/上櫃')423 if "-創" in n or "TIB" in cat: cat = "創新板"424 return f"{c}{info.get('suffix', '.TW')}", cat, f"{c} {n}"425 426 clean_val = re.sub(r'[^0-9A-Za-z]', '', val.split('.')[0])427 if clean_val and len(clean_val) >= 4:428 if clean_val == "7730": return "7730.TW", "創新板", "7730 暉盛-創"429 return f"{clean_val}.TW", "動態標的", f"{clean_val} 系統標的"430 431 return f"{val}.TW", "動態標的", f"{val}"432 433def sanitize_ui_text(text):434 if not text: return ""435 return str(text).replace('[', '【').replace(']', '】').replace('\n', ' ').strip()436 437# ==========================================438# 4. 企業輿情矩陣模組 (Enterprise Sentiment Engine)439# ==========================================440class EnterpriseSentimentEngine:441 @staticmethod442 def analyze(news_list):443 if not news_list: return 50444 score = 50445 pos_words = ['漲停', '創高', '獲利', '雙增', '買超', '看好', '大增', '上修', '利多', '新高', '爆發', '受惠', '突破']446 neg_words = ['跌停', '砍單', '衰退', '賣超', '看壞', '大減', '下修', '利空', '破底', '地緣政治', '降評', '出貨', '崩盤']447 448 for n in news_list:449 title = n.get('title', '')450 if "歷史存檔" in title: 451 score -= 5452 continue453 for w in pos_words:454 if w in title: score += 8455 for w in neg_words:456 if w in title: score -= 8457 return max(0, min(100, score))458 459@st.cache_data(ttl=300)460def fetch_target_news(target_name):461 news_list = []462 try:463 url = f"https://news.google.com/rss/search?q={target_name}+股票&hl=zh-TW&gl=TW&ceid=TW:zh-Hant"464 res = requests.get(url, timeout=5)465 root = ET.fromstring(res.text)466 now = datetime.datetime.now(datetime.timezone.utc)467 for item in root.findall('.//item')[:3]:468 title = item.find('title').text469 link = item.find('link').text470 pubDate_elem = item.find('pubDate')471 if pubDate_elem is not None:472 try:473 pub_dt = parsedate_to_datetime(pubDate_elem.text)474 if (now - pub_dt).total_seconds() > 48 * 3600: continue475 except: pass476 news_list.append({'title': title, 'link': link})477 except: pass478 return news_list479 480@st.cache_data(ttl=300) 481def fetch_hybrid_news():482 news_list = []483 now = datetime.datetime.now(datetime.timezone.utc)484 try:485 url = "https://news.google.com/rss/search?q=台股+OR+台積電+OR+美股+OR+聯準會&hl=zh-TW&gl=TW&ceid=TW:zh-Hant"486 res = requests.get(url, timeout=5)487 root = ET.fromstring(res.text)488 for item in root.findall('.//item')[:5]:489 title = item.find('title').text490 link = item.find('link').text491 source_elem = item.find('source')492 pubDate_elem = item.find('pubDate')493 pub = source_elem.text if source_elem is not None else "Google News"494 if pubDate_elem is not None:495 try:496 pub_dt = parsedate_to_datetime(pubDate_elem.text)497 if (now - pub_dt).total_seconds() > 48 * 3600: continue 498 except: pass499 news_list.append({'title': title, 'link': link, 'pub': pub})500 except: pass501 502 try:503 for sym in ["TSM", "SPY"]:504 tk = yf.Ticker(sym)505 news = tk.news506 if news:507 for n in news[:2]:508 title = n.get('title', '')509 link = n.get('link', '')510 pub = n.get('publisher', '')511 if title and link and not any(title in x['title'] for x in news_list):512 news_list.append({'title': title, 'link': link, 'pub': pub})513 except: pass514 return news_list[:5]515 516# ==========================================517# 5. 影像渲染引擎 (PIL) 與全息存檔樞紐518# ==========================================519@st.cache_resource520def get_cjk_font(size=14, bold=False):521 font_filename = "NotoSansTC-Bold.otf" if bold else "NotoSansTC-Regular.otf"522 font_path = os.path.join("/tmp", font_filename)523 if not os.path.exists(font_path):524 try:525 url = f"https://github.com/notofonts/noto-cjk/raw/main/Sans/OTF/TraditionalChinese/NotoSansCJKtc-{'Bold' if bold else 'Regular'}.otf"526 urllib.request.urlretrieve(url, font_path)527 except: pass528 possible_paths = [font_path, "msjh.ttc", "msjhbd.ttc", "simhei.ttf", "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc"]529 for p in possible_paths:530 try: return ImageFont.truetype(p, size)531 except: continue532 return ImageFont.load_default()533 534def wrap_text(text, font, max_width, draw):535 lines = []536 for paragraph in text.split('\n'):537 line = ""538 for char in paragraph:539 if draw.textlength(line + char, font=font) <= max_width: line += char540 else:541 lines.append(line)542 line = char543 lines.append(line)544 return lines545 546def generate_and_save_tactical_report(scan_results, portfolio):547 tz = pytz.timezone('Asia/Taipei')548 now_str = datetime.datetime.now(tz).strftime("%Y%m%d_%H%M%S")549 report = {550 "timestamp": datetime.datetime.now(tz).strftime("%Y-%m-%d %H:%M:%S"),551 "core_layout": [],552 "ai_hunting_list": [],553 "global_scan_summary": []554 }555 if scan_results:556 sorted_scan = sorted([{'symbol': k, **v} for k, v in scan_results.items()], key=lambda x: x['score'], reverse=True)557 for d in sorted_scan:558 item = {"symbol": d['symbol'], "name": d['name'], "price": d['price'], "score": d['score'], "trend": d['trend']}559 report["global_scan_summary"].append(item)560 if d['score'] >= 75: report["ai_hunting_list"].append(item)561 if portfolio:562 for tk, pdata in portfolio.items():563 report["core_layout"].append({"symbol": tk, "name": pdata['name'], "cost": pdata['cost'], "shares": pdata['shares']})564 565 filepath = os.path.join(REPORT_DIR, f"tactical_log_{now_str}.json")566 try:567 with open(filepath, 'w', encoding='utf-8') as f:568 json.dump(report, f, ensure_ascii=False, indent=4)569 return filepath, report, json.dumps(report, ensure_ascii=False, indent=4).encode('utf-8')570 except Exception as e:571 return None, None, None572 573def generate_tactical_image_global(scan_results, portfolio, indices):574 width, height = 800, 1000575 img = Image.new('RGB', (width, height), color='#FFFFFF')576 draw = ImageDraw.Draw(img)577 font_title = get_cjk_font(28, bold=True)578 font_h1 = get_cjk_font(20, bold=True)579 font_body = get_cjk_font(16, bold=True)580 font_small = get_cjk_font(14)581 tz = pytz.timezone('Asia/Taipei')582 now_str = datetime.datetime.now(tz).strftime("%Y-%m-%d %H:%M:%S")583 584 draw.rectangle([(0, 0), (width, 60)], fill="#0F172A")585 draw.text((20, 15), "戰神企業終端 - 戰略全息圖報", fill="#FFFFFF", font=font_title)586 draw.text((width - 200, 25), now_str, fill="#FFFFFF", font=font_body)587 588 y_offset = 80589 draw.text((20, y_offset), "【 全球指數脈動 】", fill="#0F172A", font=font_h1)590 y_offset += 35591 idx_str = " | ".join([f"{k}: {v['price']:.2f} ({v['pct']:+.2f}%)" for k, v in indices.items() if v['price']>0])592 draw.text((20, y_offset), idx_str, fill="#0F172A", font=font_body)593 y_offset += 40594 draw.line([(20, y_offset), (width-20, y_offset)], fill="#E2E8F0", width=2)595 y_offset += 20596 597 sector_heat = {}598 for tk, d in scan_results.items():599 if d.get('chg_pct', 0) >= 3.0:600 sec = d.get('sector', '動態標的')601 if sec != '動態標的':602 sector_heat[sec] = sector_heat.get(sec, 0) + 1603 top_sector = max(sector_heat, key=sector_heat.get) if sector_heat else "無顯著共振"604 605 draw.text((20, y_offset), f"【 戰神全域掃描 - 前 8 強勢指標 】(最強板塊: {top_sector})", fill="#0F172A", font=font_h1)606 y_offset += 40607 sorted_scan = sorted([{'sym': k, **v} for k, v in scan_results.items()], key=lambda x: x['score'], reverse=True)[:8]608 for i, d in enumerate(sorted_scan):609 col_x = 20 if i % 2 == 0 else width // 2 + 10610 color = "#FF0000" if d['chg_pct'] >= 0 else "#008000"611 draw.rectangle([(col_x, y_offset), (col_x + 360, y_offset + 80)], outline="#0F172A", width=2)612 draw.text((col_x + 10, y_offset + 10), f"{d['sym']} {d['name']}", fill="#0F172A", font=font_body)613 draw.text((col_x + 200, y_offset + 10), f"${d['price']:.2f} ({d['chg_pct']:+.2f}%)", fill=color, font=font_body)614 draw.rectangle([(col_x + 10, y_offset + 40), (col_x + 90, y_offset + 65)], fill="#0F172A")615 draw.text((col_x + 15, y_offset + 43), f"評分: {d['score']}", fill="#FFFFFF", font=font_body)616 draw.text((col_x + 100, y_offset + 43), f"{d['action']}", fill=color, font=font_body)617 if i % 2 == 1: y_offset += 90618 if len(sorted_scan) % 2 != 0: y_offset += 90619 y_offset += 10620 draw.line([(20, y_offset), (width-20, y_offset)], fill="#E2E8F0", width=2)621 y_offset += 20622 623 draw.text((20, y_offset), "【 核心存股佈局進度 】", fill="#0F172A", font=font_h1)624 y_offset += 40625 for tk, pdata in portfolio.items():626 q = scan_results.get(tk, {})627 cp = q.get('price', pdata['cost'])628 net, pct = FinancialActuary.calculate_net_profit(pdata['cost'], cp, pdata['shares'])629 color = "#FF0000" if net >= 0 else "#008000"630 draw.text((20, y_offset), f"{tk} {pdata['name']}", fill="#0F172A", font=font_body)631 draw.text((200, y_offset), f"成本: ${pdata['cost']:.2f} 現價: ${cp:.2f}", fill="#0F172A", font=font_small)632 draw.text((450, y_offset), f"獲利: ${int(net):,} ({pct:+.2f}%)", fill=color, font=font_body)633 y_offset += 30634 635 img = img.crop((0, 0, width, y_offset + 40))636 buf = io.BytesIO()637 img.save(buf, format="PNG")638 return buf.getvalue()639 640def generate_tactical_image_radar(target, mx, regime):641 width, height = 800, 650642 # 建立 RGBA 影像以支援半透明圖層合成643 img = Image.new('RGBA', (width, height), color=(255, 255, 255, 255))644 draw = ImageDraw.Draw(img)645 font_title = get_cjk_font(28, bold=True)646 font_h1 = get_cjk_font(22, bold=True)647 font_body = get_cjk_font(16, bold=True)648 font_small = get_cjk_font(14)649 tz = pytz.timezone('Asia/Taipei')650 now_str = datetime.datetime.now(tz).strftime("%Y-%m-%d %H:%M:%S")651 tk_full, cat, name = EnterpriseTickerFactory.get_routing_info(target)652 653 draw.rectangle([(0, 0), (width, 60)], fill="#0F172A")654 draw.text((20, 15), f"戰術雷達鎖定 - {name}", fill="#FFFFFF", font=font_title)655 draw.text((width - 200, 25), now_str, fill="#FFFFFF", font=font_body)656 657 y_offset = 80658 color = "#FF0000" if mx['action_color'] == "#FF0000" else "#008000" if mx['action_color'] == "#008000" else "#D97706"659 draw.text((20, y_offset), f"現價: ${mx['price']:.2f}", fill="#0F172A", font=font_h1)660 draw.text((250, y_offset), f"系統判定: {mx['action_short']} (評分 {mx['score']})", fill=color, font=font_h1)661 662 y_offset += 40663 draw.rectangle([(20, y_offset), (380, y_offset + 90)], outline="#0F172A", width=2)664 draw.text((30, y_offset + 10), f"🎯 目標突破: ${mx['target_price']:.2f}", fill="#FF0000", font=font_body)665 draw.text((30, y_offset + 35), f"🛡️ 嚴格防守: ${mx['stop']:.2f}", fill="#008000", font=font_body)666 draw.text((30, y_offset + 60), f"💡 勝率: {mx['win_rate']*100:.1f}% | 期望值: {mx['expected_value']*100:+.2f}%", fill="#0F172A", font=font_small)667 668 draw.rectangle([(400, y_offset), (780, y_offset + 90)], outline="#0F172A", width=2, fill="#F8FAFC")669 draw.text((410, y_offset + 10), f"環境: {regime}", fill="#0F172A", font=font_body)670 draw.text((410, y_offset + 35), f"軌道: {mx['bb_pattern']}", fill="#0F172A", font=font_body)671 draw.text((410, y_offset + 60), f"籌碼: {mx['chip_status']}", fill="#0F172A", font=font_small)672 673 y_offset += 120674 cx, cy = 200, y_offset + 150675 radius = 120676 stats = mx['hexagon']677 labels = ['趨勢', '動能', '籌碼', '空間', '穩定', '輿情']678 angles = [ -math.pi/2 + i * (2*math.pi/6) for i in range(6) ]679 680 for level in [0.2, 0.4, 0.6, 0.8, 1.0]:681 pts = [(cx + radius*level*math.cos(a), cy + radius*level*math.sin(a)) for a in angles]682 draw.polygon(pts, outline="#CBD5E1")683 for a in angles:684 draw.line([(cx, cy), (cx + radius*math.cos(a), cy + radius*math.sin(a))], fill="#CBD5E1")685 686 data_pts = [(cx + radius*(s/100)*math.cos(a), cy + radius*(s/100)*math.sin(a)) for s, a in zip(stats, angles)]687 fill_rgba = (255, 0, 0, 80) if mx['score'] >= 50 else (0, 128, 0, 80)688 689 # 建立透明圖層畫多邊形並合成690 overlay = Image.new('RGBA', img.size, (255, 255, 255, 0))691 d_overlay = ImageDraw.Draw(overlay)692 d_overlay.polygon(data_pts, fill=fill_rgba, outline=color)693 img = Image.alpha_composite(img, overlay)694 draw = ImageDraw.Draw(img) # 重新獲取繪圖物件695 696 for s, a, l in zip(stats, angles, labels):697 lx = cx + (radius+25)*math.cos(a) - 15698 ly = cy + (radius+25)*math.sin(a) - 10699 draw.text((lx, ly), l, fill="#0F172A", font=font_body)700 701 text_x = 400702 draw.text((text_x, y_offset), "【 戰略全息解析 】", fill="#0F172A", font=font_h1)703 y_offset += 40704 clean_text = str(mx['ai_detail']).replace('<br>', '\n')705 lines = wrap_text(clean_text, font_body, 360, draw)706 for line in lines:707 draw.text((text_x, y_offset), line, fill="#0F172A", font=font_body)708 y_offset += 25709 710 buf = io.BytesIO()711 img.convert('RGB').save(buf, format="PNG")712 return buf.getvalue()713 714# ==========================================715# 6. 資料庫與廣播引擎716# ==========================================717def trigger_line_push(msg_type, data=None):718 if st.session_state.current_user != MASTER_ACCOUNT: return719 if msg_type != "test_conn" and not st.session_state.get('enable_line_broadcast', False): return720 721 user_data = st.session_state.corporate_db[st.session_state.current_user]722 tz = pytz.timezone('Asia/Taipei')723 current_month = datetime.datetime.now(tz).month724 if user_data.get('quota_month') != current_month:725 user_data['line_quota'] = 200726 user_data['quota_month'] = current_month727 save_database()728 729 if user_data.get('line_quota', 0) <= 0:730 st.toast("❌ LINE 廣播失敗:本月配額已耗盡 (0/200)", icon="🚨")731 return732 733 token = st.session_state.get('line_token', '')734 user_id = st.session_state.get('line_user_id', '')735 if not token or not user_id:736 st.toast("❌ LINE 廣播失敗:未設定 Token 或 User ID", icon="🚨")737 return738 739 broadcaster = LineBroadcaster(token, user_id)740 now_str = datetime.datetime.now(tz).strftime("%H:%M:%S")741 742 msg = ""743 if msg_type == "test_conn":744 msg = f"📡 [{VERSION_TAG}] 通訊鏈路校驗成功\n時間: {now_str}\n狀態: 指揮權限確認,系統運作正常。"745 elif msg_type == "alert_extreme":746 tk, name, action, detail = data['tk'], data['name'], data['action'], data['detail']747 msg = f"💥 [極限警示] 系統斷路器觸發\n時間: {now_str}\n標的: {tk} {name}\n狀態: {action}\n明細: {detail}"748 elif msg_type == "scan_report":749 msg = f"🌍 [全域戰略掃描] 摘要報告\n時間: {now_str}\n"750 for item in data[:8]:751 msg += f"▪ {item['name']} | 評分: {item['score']} | {item['chg_pct']:+.2f}%\n"752 elif msg_type == "core_report":753 msg = f"🟡 [核心佈局] 存股進度戰報\n時間: {now_str}\n"754 for item in data[:8]:755 msg += f"▪ {item[1]} | 評分: {item[4]} | 狀態: {item[3]}\n"756 elif msg_type == "short_report":757 msg = f"⚡ [短線突擊] 獵殺名單戰報\n時間: {now_str}\n"758 for item in data[:8]:759 msg += f"▪ {item[1]} | {item[2]}\n👉 戰術: {item[3]}\n"760 elif msg_type == "radar_share":761 tk, name, price, score, action, pred = data['tk'], data['name'], data['price'], data['score'], data['action'], data['pred']762 msg = f"🎯 [戰術雷達全息分享] {name} ({tk})\n時間: {now_str}\n==================\n現價: ${price:.2f}\n系統判定: {action} (評分 {score})\n📈 戰神 3日預期落點: ${pred}"763 if tk in data['portfolio']:764 p_data = data['portfolio'][tk]765 net, pct = FinancialActuary.calculate_net_profit(p_data['cost'], price, p_data['shares'])766 msg += f"\n\n💼 庫存狀態: {p_data['shares']}股\n未實現損益: ${int(net):,} ({pct:+.2f}%)"767 768 if msg:769 success = broadcaster.send_text(msg)770 if success: 771 user_data['line_quota'] = user_data.get('line_quota', 200) - 1772 save_database(silent=True)773 st.toast(f"📲 LINE 戰略分享已送出 (配額剩餘: {user_data['line_quota']})", icon="✅")774 else: st.error(f"❌ LINE 廣播失敗:API 拒絕連線")775 776class FinancialActuary:777 @staticmethod778 def adjust_to_tick_size(price):779 if price < 10: tick = 0.01780 elif price < 50: tick = 0.05781 elif price < 100: tick = 0.1782 elif price < 500: tick = 0.5783 elif price < 1000: tick = 1.0784 else: tick = 5.0785 p_dec, t_dec = Decimal(str(price)), Decimal(str(tick))786 return float((p_dec / t_dec).quantize(Decimal('1'), rounding=ROUND_HALF_UP) * t_dec)787 788 @staticmethod789 def calculate_net_profit(cost_price, current_price, shares):790 buy_cost = (cost_price * shares) * 1.001425791 sell_revenue = (current_price * shares) * (1 - 0.001425 - 0.003)792 net_profit = sell_revenue - buy_cost793 return net_profit, (net_profit / buy_cost) * 100 if buy_cost > 0 else 0794 795def load_database():796 default_db = {MASTER_ACCOUNT: {"portfolio": {}, "audit_log": [], "cash_balance": 0.0, "line_quota": 200, "quota_month": 1, "interaction_count": 0}, 797 VICE_ACCOUNT: {"portfolio": {}, "audit_log": [], "cash_balance": 0.0, "interaction_count": 0}}798 if os.path.exists(DB_FILE):799 try:800 with open(DB_FILE, 'r', encoding='utf-8') as f:801 data = json.load(f)802 if "許立人" in data: data[MASTER_ACCOUNT] = data.pop("許立人")803 if "婉蓉" in data: data[VICE_ACCOUNT] = data.pop("婉蓉")804 for acc in [MASTER_ACCOUNT, VICE_ACCOUNT]:805 if acc not in data: 806 data[acc] = {"portfolio": {}, "audit_log": [], "cash_balance": 0.0, "interaction_count": 0}807 elif "cash_balance" not in data[acc]:808 data[acc]["cash_balance"] = 0.0809 if "interaction_count" not in data[acc]:810 data[acc]["interaction_count"] = 0811 if acc == MASTER_ACCOUNT:812 if "line_quota" not in data[acc]: data[acc]["line_quota"] = 200813 if "quota_month" not in data[acc]: data[acc]["quota_month"] = datetime.datetime.now().month814 return data815 except: pass816 return default_db817 818def save_database(silent=False):819 tmp_file = DB_FILE + ".tmp"820 try:821 with open(tmp_file, 'w', encoding='utf-8') as f:822 json.dump(st.session_state.corporate_db, f, ensure_ascii=False, indent=4)823 os.replace(tmp_file, DB_FILE)824 if not silent: st.toast(f"💾 {st.session_state.current_user}指揮主體,戰略沙盒與帳本已安全寫入磁碟", icon="✅")825 except Exception as e:826 if not silent: st.error(f"❌ 資料庫寫入失敗: {e}")827 828def log_audit(user_data, message):829 tz = pytz.timezone('Asia/Taipei')830 now_str = datetime.datetime.now(tz).strftime("%Y-%m-%d %H:%M:%S")831 user_data['audit_log'].insert(0, f"[{now_str}] {message}")832 if len(user_data['audit_log']) > 150: user_data['audit_log'].pop()833 save_database()834 835def generate_csv_export(user_data, batch_prices):836 output = io.StringIO()837 output.write("股號,名稱,持有成本,現價,持有股數,目標股數,達成率,未實現損益,獲利趴數\n")838 for tk, data in user_data['portfolio'].items():839 q = batch_prices.get(tk, {})840 c_p = q.get('price', data['cost'])841 net, pct = FinancialActuary.calculate_net_profit(data['cost'], c_p, data['shares'])842 tgt = data.get('target_shares', data['shares'])843 achieve = (data['shares'] / tgt * 100) if tgt > 0 else 100844 output.write(f"{tk},{data['name']},{data['cost']:.2f},{c_p:.2f},{data['shares']},{tgt},{achieve:.1f}%,{net:.0f},{pct:.2f}%\n")845 return output.getvalue().encode('utf-8-sig')846 847# ==========================================848# 7. 極速併發資料引擎與全時域多因子引擎849# ==========================================850class EnterpriseDataEngine:851 def __init__(self, fugle_api): 852 self.fugle_api = fugle_api.strip()853 854 @staticmethod855 def fetch_market_top_gainers():856 top_gainers = []857 try:858 url = "https://tw.stock.yahoo.com/rank/change-up"859 headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'}860 res = requests.get(url, headers=headers, timeout=5)861 matches = re.findall(r'/quote/(\d{4,6})\.TW', res.text)862 matches += re.findall(r'/quote/(\d{4,6})\.TWO', res.text)863 if matches:864 seen = set()865 for m in matches:866 if m not in seen:867 seen.add(m)868 top_gainers.append(m)869 except Exception: pass870 return top_gainers[:30]871 872 def fetch_data(self, symbol, tk_full, category, timeframe="D"):873 tz = pytz.timezone('Asia/Taipei')874 end_date = datetime.datetime.now(tz)875 start_date = end_date - datetime.timedelta(days=730)876 df = pd.DataFrame()877 878 # 1. 優先嘗試 yfinance (解決 Fugle 401 封鎖問題)879 try:880 yf_sym = "^TWII" if symbol == "IX0001" else tk_full881 ticker = yf.Ticker(yf_sym)882 if timeframe == "5": df = ticker.history(period="5d", interval="5m")883 elif timeframe == "15": df = ticker.history(period="60d", interval="15m")884 elif timeframe == "30": df = ticker.history(period="60d", interval="30m")885 elif timeframe == "60": df = ticker.history(period="730d", interval="60m")886 elif timeframe == "W": df = ticker.history(period="5y", interval="1wk")887 elif timeframe == "M": df = ticker.history(period="10y", interval="1mo")888 else: df = ticker.history(period="2y", interval="1d")889 890 if df is not None and not df.empty and len(df) >= 2:891 return df892 except: df = pd.DataFrame()893 894 # 2. 備援:若 yfinance 失敗且為日線,嘗試 Fugle895 if df.empty and self.fugle_api and (symbol.isdigit() or symbol == "IX0001") and timeframe == "D":896 try:897 c_url = f"https://api.fugle.tw/marketdata/v1.0/stock/historical/candles/{symbol}?timeframe={timeframe}&from={start_date.strftime('%Y-%m-%d')}&to={end_date.strftime('%Y-%m-%d')}&api_token={self.fugle_api}"898 c_res = requests.get(c_url, timeout=5).json()899 df = pd.DataFrame(c_res.get('data', []))900 if not df.empty:901 df['date'] = pd.to_datetime(df['date'])902 df.set_index('date', inplace=True); df.sort_index(inplace=True)903 df.rename(columns={'open':'Open', 'high':'High', 'low':'Low', 'close':'Close', 'volume':'Volume'}, inplace=True)904 except: pass905 906 return df907 908def fetch_single_quote(s, fugle_api_key):909 tk_full, cat, _ = EnterpriseTickerFactory.get_routing_info(s)910 tz = pytz.timezone('Asia/Taipei')911 now = datetime.datetime.now(tz)912 now_ts = now.timestamp()913 914 # 策略: 優先使用 YFinance 獲取台股報價,擴大為 5d 確保能抓到資料915 try:916 yf_sym = "^TWII" if s == "IX0001" else tk_full917 df = yf.Ticker(yf_sym).history(period="5d")918 if df is not None and not df.empty:919 if len(df) >= 2:920 c, prev = float(df['Close'].iloc[-1]), float(df['Close'].iloc[-2])921 else: # 剛開盤只有1筆資料922 c = float(df['Close'].iloc[-1])923 prev = float(df['Open'].iloc[-1])924 925 if not np.isnan(c) and c > 0:926 chg = c - prev if not np.isnan(prev) else 0927 chg_pct = (chg / prev * 100) if prev > 0 else 0928 return s, {'price': c, 'chg_pct': chg_pct, 'chg': chg, 'delayed': False, 'ts': now_ts}929 except: pass930 931 # 備援: 嘗試 Fugle932 if s.isdigit() or s == "IX0001":933 try:934 url = f"https://api.fugle.tw/marketdata/v1.0/stock/intraday/quote/{s}?api_token={fugle_api_key}"935 res = requests.get(url, timeout=3).json().get('data', {})936 if res:937 trade = res.get('lastTrade', {})938 c = float(trade.get('price', res.get('lastPrice', res.get('close', 0.0))))939 prev = float(res.get('previousClose', 0.0))940 time_str = res.get('lastUpdated', '')941 is_delayed = False942 if time_str:943 try:944 update_dt = datetime.datetime.strptime(time_str[:19], "%Y-%m-%dT%H:%M:%S")945 update_dt = tz.localize(update_dt)946 if (now - update_dt).total_seconds() > 300: is_delayed = True947 except: pass948 if c > 0:949 return s, {'price': c, 'chg_pct': ((c-prev)/prev)*100 if prev>0 else 0, 'chg': c-prev, 'delayed': is_delayed, 'ts': now_ts}950 except: pass951 952 # 終極備援: 證交所 API (延遲報價)953 if s.isdigit() and "上市" in cat:954 try:955 url = f"https://openapi.twse.com.tw/v1/exchangeReport/STOCK_DAY_AVG_ALL"956 res = requests.get(url, timeout=3).json()957 for item in res:958 if str(item.get('Code', '')).strip() == s:959 c = float(item.get('ClosingPrice', 0))960 if c > 0: return s, {'price': c, 'chg_pct': 0, 'chg': 0, 'delayed': True, 'ts': now_ts}961 except: pass962 963 return s, None964 965@st.cache_data(ttl=5)966def get_batch_quotes(symbols, fugle_api_key=FUGLE_KEY):967 quotes = {}968 with concurrent.futures.ThreadPoolExecutor(max_workers=15) as executor:969 futures = [executor.submit(fetch_single_quote, s, fugle_api_key) for s in symbols]970 for future in concurrent.futures.as_completed(futures):971 s, res = future.result()972 if res: quotes[s] = res973 return quotes974 975def fetch_taifex_night_price():976 """從玩股網抓取台指期盤後(夜盤)真實即時報價"""977 try:978 url = "https://www.wantgoo.com/investor/futures/WTXP%26/realtime-data"979 headers = {980 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',981 'Referer': 'https://www.wantgoo.com/option/futures/wtxp%'982 }983 res = requests.get(url, headers=headers, timeout=4)984 if res.status_code == 200:985 data = res.json()986 c = float(data.get('Price', 0))987 change = float(data.get('Change', 0))988 prev = c - change989 pct = (change / prev) * 100 if prev > 0 else 0990 if c > 0:991 return c, pct992 except Exception: pass993 return 0.0, 0.0994 995@st.cache_data(ttl=15) # 縮短至15秒快取,適應夜盤快速波動996def get_global_indices():997 data = {"台股大盤": {"price": 0.0, "pct": 0.0, "delayed": False, "ts": 0.0}, "台指期(TX)": {"price": 0.0, "pct": 0.0, "delayed": True, "ts": 0.0}, 998 "費半(SOX)": {"price": 0.0, "pct": 0.0, "delayed": True, "ts": 0.0}, "那指(IXIC)": {"price": 0.0, "pct": 0.0, "delayed": True, "ts": 0.0}, 999 "TSM(ADR)": {"price": 0.0, "pct": 0.0, "delayed": True, "ts": 0.0}, "NVDA": {"price": 0.0, "pct": 0.0, "delayed": True, "ts": 0.0}, 1000 "日經(N225)": {"price": 0.0, "pct": 0.0, "delayed": True, "ts": 0.0}, "韓國(KOSPI)": {"price": 0.0, "pct": 0.0, "delayed": True, "ts": 0.0}, 1001 "摩台(EWT)": {"price": 0.0, "pct": 0.0, "delayed": True, "ts": 0.0}}1002 1003 tw_quote = {}1004 try:1005 tw_quote = get_batch_quotes(["IX0001"])1006 if "IX0001" in tw_quote:1007 data["台股大盤"] = {"price": tw_quote["IX0001"]["price"], "pct": tw_quote["IX0001"]["chg_pct"], "delayed": tw_quote["IX0001"].get("delayed", False), "ts": tw_quote["IX0001"].get("ts", 0.0)}1008 except: pass1009 1010 # 💥 台指期專屬抓取邏輯 (精準抓取夜盤)1011 tx_price, tx_pct = fetch_taifex_night_price()1012 1013 # 若抓取失敗,再嘗試 yfinance 備援1014 if tx_price == 0.0:1015 tx_symbols = ["TWF=F", "TWN=F", "^TWII"]1016 for sym in tx_symbols:1017 try:1018 ticker = yf.Ticker(sym)1019 df = ticker.history(period="5d")1020 if df is not None and not df.empty and len(df) >= 2:1021 c, prev = float(df['Close'].iloc[-1]), float(df['Close'].iloc[-2])1022 if c > 100: 1023 tx_price = c1024 tx_pct = ((c-prev)/prev)*1001025 break1026 except: pass1027 1028 if tx_price == 0.0 and "IX0001" in tw_quote: # 終極備援:若期貨完全抓不到,跟隨大盤指數1029 tx_price = tw_quote["IX0001"]["price"]1030 tx_pct = tw_quote["IX0001"]["chg_pct"]1031 1032 data["台指期(TX)"] = {"price": tx_price, "pct": tx_pct, "delayed": True, "ts": 0.0}1033 1034 us_symbols = {"費半(SOX)": "^SOX", "那指(IXIC)": "^IXIC", "TSM(ADR)": "TSM", "NVDA": "NVDA", "日經(N225)": "^N225", "韓國(KOSPI)": "^KS11", "摩台(EWT)": "EWT"}1035 for name, sym in us_symbols.items():1036 try:1037 ticker = yf.Ticker(sym)1038 df = ticker.history(period="5d")1039 if df is not None and not df.empty and len(df) >= 2:1040 c, prev = float(df['Close'].iloc[-1]), float(df['Close'].iloc[-2])1041 data[name] = {"price": c, "pct": ((c-prev)/prev)*100, "delayed": True, "ts": 0.0}1042 except: pass1043 return data1044 1045def get_macro_sentiment(g_data):1046 score = 01047 if g_data.get('TSM(ADR)', {}).get('pct', 0) > 1.5: score += 21048 elif g_data.get('TSM(ADR)', {}).get('pct', 0) < -1.5: score -= 21049 1050 if g_data.get('日經(N225)', {}).get('pct', 0) > 0.5: score += 11051 elif g_data.get('日經(N225)', {}).get('pct', 0) < -0.5: score -= 11052 1053 if g_data.get('韓國(KOSPI)', {}).get('pct', 0) > 0.5: score += 11054 elif g_data.get('韓國(KOSPI)', {}).get('pct', 0) < -0.5: score -= 11055 1056 if g_data.get('摩台(EWT)', {}).get('pct', 0) > 0.5: score += 11057 elif g_data.get('摩台(EWT)', {}).get('pct', 0) < -0.5: score -= 11058 1059 twii = g_data.get("台股大盤", {}).get("price", 0)1060 tx = g_data.get("台指期(TX)", {}).get("price", 0)1061 basis_warning = ""1062 if twii > 0 and tx > 0:1063 basis = tx - twii1064 if basis < -100:1065 score -= 21066 basis_warning = f" <span style='color:#008000;'>(⚠️ 深度逆價差 {basis:.0f} 點)</span>"1067 elif basis > 50:1068 score += 11069 basis_warning = f" <span style='color:#FF0000;'>(🔥 強勢正價差 +{basis:.0f} 點)</span>"1070 1071 if score >= 3: return f"🔥 極度亢奮 (多方強攻){basis_warning}", 101072 elif score > 0: return f"🟢 穩健偏多 (伺機點火){basis_warning}", 51073 elif score < -2: return f"❄️ 冰凍縮手 (空方反撲){basis_warning}", -101074 elif score < 0: return f"🔴 承壓震盪 (防禦為主){basis_warning}", -51075 return f"⚪ 中立觀望 (多空交戰){basis_warning}", 01076 1077@st.cache_data(ttl=600)1078def get_market_regime():1079 try:1080 engine = EnterpriseDataEngine(FUGLE_KEY)1081 twii = engine.fetch_data("IX0001", "^TWII", "宏觀", timeframe="D")1082 if twii is not None and not twii.empty and len(twii) >= 60:1083 c = float(twii['Close'].iloc[-1])1084 ma20 = float(twii['Close'].rolling(20).mean().iloc[-1])1085 if c > ma20: return "多頭市場"1086 else: return "空頭市場"1087 except Exception: pass1088 return "震盪市場"1089 1090class AdvancedPatternEngine:1091 @staticmethod1092 def analyze(df):1093 if df is None or df.empty or len(df) < 20: return df1094 df = df.copy()1095 1096 df['EMA200'] = df['Close'].ewm(span=200, adjust=False).mean()1097 df['MA20'] = df['Close'].rolling(20).mean()1098 df['MA60'] = df['Close'].rolling(60).mean()1099 std = df['Close'].rolling(20).std()1100 df['BB_Up'], df['BB_Low'] = df['MA20'] + 2*std, df['MA20'] - 2*std1101 df['BB_Width'] = ((df['BB_Up'] - df['BB_Low']) / df['MA20']) * 1001102 1103 df['BB_PctB'] = (df['Close'] - df['BB_Low']) / (df['BB_Up'] - df['BB_Low'] + 1e-10)1104 1105 df['Typical_Price'] = (df['High'] + df['Low'] + df['Close']) / 31106 df['VWAP_20'] = (df['Typical_Price'] * df['Volume']).rolling(20).sum() / (df['Volume'].rolling(20).sum() + 1e-10)1107 1108 mf_mult = ((df['Close'] - df['Low']) - (df['High'] - df['Close'])) / (df['High'] - df['Low'] + 1e-10)1109 df['CMF_20'] = (mf_mult * df['Volume']).rolling(20).sum() / (df['Volume'].rolling(20).sum() + 1e-10)1110 1111 high_low = df['High'] - df['Low']1112 high_close = np.abs(df['High'] - df['Close'].shift())1113 low_close = np.abs(df['Low'] - df['Close'].shift())1114 ranges = pd.concat([high_low, high_close, low_close], axis=1)1115 true_range = np.max(ranges, axis=1)1116 df['ATR'] = true_range.rolling(14).mean()1117 1118 df['Vol_MA5'] = df['Volume'].rolling(5).mean()1119 df['VP_Div'] = 01120 df.loc[(df['Close'] > df['Close'].shift(1)) & (df['Volume'] < df['Volume'].shift(1)), 'VP_Div'] = -11121 df.loc[(df['Close'] < df['Close'].shift(1)) & (df['Volume'] > df['Volume'].shift(1)), 'VP_Div'] = -21122 df.loc[(df['Close'] > df['Close'].shift(1)) & (df['Volume'] > df['Vol_MA5']), 'VP_Div'] = 11123 1124 delta = df['Close'].diff()1125 gain = (delta.where(delta > 0, 0)).ewm(alpha=1/14).mean()1126 loss = (-delta.where(delta < 0, 0)).ewm(alpha=1/14).mean()1127 df['RSI'] = 100 - (100 / (1 + gain / (loss + 1e-10)))1128 df['Synergy_Index'] = (df['RSI'] - 50) * 1.51129 1130 df['MACD'] = df['Close'].ewm(span=12, adjust=False).mean() - df['Close'].ewm(span=26, adjust=False).mean()1131 df['MACD_Signal'] = df['MACD'].ewm(span=9, adjust=False).mean()1132 df['MACD_Hist'] = df['MACD'] - df['MACD_Signal']1133 1134 low_min = df['Low'].rolling(9).min(); high_max = df['High'].rolling(9).max()1135 df['RSV'] = 100 * ((df['Close'] - low_min) / (high_max - low_min + 1e-10))1136 df['K'] = df['RSV'].ewm(alpha=1/3, adjust=False).mean()1137 df['D'] = df['K'].ewm(alpha=1/3, adjust=False).mean()1138 1139 df['OBV'] = (np.sign(df['Close'].diff()) * df['Volume']).fillna(0).cumsum()1140 1141 df['Body'] = abs(df['Close'] - df['Open'])1142 df['Upper_Shadow'] = df['High'] - df[['Open', 'Close']].max(axis=1)1143 df['Lower_Shadow'] = df[['Open', 'Close']].min(axis=1) - df['Low']1144 df['TR_Strict'] = df['High'] - df['Low']1145 1146 df['Is_High_Zone'] = (df['RSI'] > 65) | (df['Close'] > df['BB_Up'] * 0.98)1147 df['Is_Low_Zone'] = (df['RSI'] < 35) | (df['Close'] < df['BB_Low'] * 1.02)1148 1149 df['K_Pattern'] = "無明顯特徵"1150 df['K_Meaning'] = "動能平穩,多空雙方未見明顯表態。"1151 1152 is_doji = df['Body'] <= (df['TR_Strict'] * 0.1)1153 mask_oneline = df['TR_Strict'] == 01154 df.loc[mask_oneline, 'K_Pattern'] = "一字線(四價合一)"1155 df.loc[mask_oneline, 'K_Meaning'] = "極度冷門或開盤即漲/跌停鎖死,流動性極端。"1156 1157 df['Prev_Open'] = df['Open'].shift(1)1158 df['Prev_Close'] = df['Close'].shift(1)1159 df['Prev_High'] = df['High'].shift(1)1160 df['Prev_Low'] = df['Low'].shift(1)1161 df['Prev_Body'] = abs(df['Prev_Close'] - df['Prev_Open'])1162 1163 df['Prev2_Open'] = df['Open'].shift(2)1164 df['Prev2_Close'] = df['Close'].shift(2)1165 df['Prev2_Body'] = abs(df['Prev2_Close'] - df['Prev2_Open'])1166 1167 avg_body = df['Body'].rolling(10).mean()1168 1169 # 貫穿線 (Piercing Line)1170 is_piercing = (df['Prev_Close'] < df['Prev_Open']) & (df['Prev_Body'] > avg_body) & \1171 (df['Close'] > df['Open']) & (df['Open'] < df['Prev_Low']) & \1172 (df['Close'] > (df['Prev_Open'] + df['Prev_Close'])/2) & (df['Close'] < df['Prev_Open'])1173 df.loc[is_piercing, 'K_Pattern'] = "貫穿線 (破底翻)"1174 df.loc[is_piercing, 'K_Meaning'] = "空方動能耗盡,多方強勢反撲,強烈見底訊號。"1175 1176 # 烏雲罩頂 (Dark Cloud Cover)1177 is_dark_cloud = (df['Prev_Close'] > df['Prev_Open']) & (df['Prev_Body'] > avg_body) & \1178 (df['Open'] > df['Close']) & (df['Open'] > df['Prev_High']) & \1179 (df['Close'] < (df['Prev_Open'] + df['Prev_Close'])/2) & (df['Close'] > df['Prev_Open'])1180 df.loc[is_dark_cloud, 'K_Pattern'] = "烏雲罩頂"1181 df.loc[is_dark_cloud, 'K_Meaning'] = "多方動能耗盡,空方強壓,強烈見頂訊號。"1182 1183 # 母子線 (Harami)1184 is_harami_bull = (df['Prev_Close'] < df['Prev_Open']) & (df['Prev_Body'] > avg_body) & \1185 (df['Body'] < df['Prev_Body'] * 0.5) & \1186 (df['High'] < df['Prev_Open']) & (df['Low'] > df['Prev_Close'])1187 df.loc[is_harami_bull, 'K_Pattern'] = "多方母子 (孕線)"1188 df.loc[is_harami_bull, 'K_Meaning'] = "跌勢中出現孕線,暗示跌勢暫緩,醞釀反轉。"1189 1190 is_harami_bear = (df['Prev_Close'] > df['Prev_Open']) & (df['Prev_Body'] > avg_body) & \1191 (df['Body'] < df['Prev_Body'] * 0.5) & \1192 (df['High'] < df['Prev_Close']) & (df['Low'] > df['Prev_Open'])1193 df.loc[is_harami_bear, 'K_Pattern'] = "空方母子 (孕線)"1194 df.loc[is_harami_bear, 'K_Meaning'] = "漲勢中出現孕線,暗示漲勢暫緩,變盤疑慮。"1195 1196 # 晨星 (Morning Star)1197 is_morning_star = (df['Prev2_Close'] < df['Prev2_Open']) & (df['Prev2_Body'] > avg_body) & \1198 (df['Prev_Body'] < avg_body * 0.5) & (df['Prev_High'] < df['Prev2_Close']) & \1199 (df['Close'] > df['Open']) & (df['Close'] > (df['Prev2_Open'] + df['Prev2_Close'])/2)1200 df.loc[is_morning_star, 'K_Pattern'] = "晨星 (希望之星)"