CoolFace
Apppublic

liln520/My-AI-Trader

sourceHugging Faceupdated 1mo agoView on Hugging Face
0likes
.gitattributes2691 linesDownload Raw Back to root
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'] = "晨星 (希望之星)"

Showing the first 1,200 of 2691 lines. Download the file for the rest.