import sys import mplfinance as mpf import warning...

Создано: 10 марта 2026 г.

Отвечено с использованием GPT-5.4 Thinking от Chat01

Вопрос

import sys
import mplfinance as mpf
import warnings
import json
import os
from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
QHBoxLayout, QPushButton, QLabel, QTableWidget,
QTableWidgetItem, QProgressBar, QHeaderView, QSplitter,
QMessageBox, QCheckBox, QGroupBox, QTabWidget,
QLineEdit, QFrame, QSpinBox, QDoubleSpinBox)
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QColor, QFont
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.figure import Figure
import matplotlib.pyplot as plt

導入副程式 (請確保 stock_logic_V01.py 在同一目錄且包含 get_stock_initial_data 等函式)

from stock_logic_V01 import StockScanWorker, StockMonitorWorker, get_stock_initial_data

忽略警告

warnings.simplefilter(action='ignore', category=FutureWarning)

設定中文字體

plt.rcParams['font.sans-serif'] = ['Microsoft JhengHei']
plt.rcParams['axes.unicode_minus'] = False

==========================================

分頁 2: AI 模擬下單與監控 UI

==========================================

class SimulatedTradingTab(QWidget):
def init(self):
super().init()
self.stock_data = []
self.monitor_thread = None
self.active_portfolio = {}
self.init_ui()

text
def init_ui(self): layout = QVBoxLayout(self) # 1. 策略控制面板 control_group = QGroupBox("💰 智能下單與監控設定") control_layout = QHBoxLayout(control_group) self.input_capital = QLineEdit("1000000") self.input_capital.setPlaceholderText("輸入本金") self.input_capital.setFixedWidth(100) self.spin_top_n = QSpinBox() self.spin_top_n.setRange(1, 20); self.spin_top_n.setValue(5) self.spin_top_n.setFixedWidth(50) self.spin_stop_loss = QDoubleSpinBox() self.spin_stop_loss.setRange(-50.0, 0.0); self.spin_stop_loss.setValue(-5.0) self.spin_stop_loss.setSuffix("%") self.spin_stop_loss.setStyleSheet("background-color: #333; color: #51cf66; font-weight: bold;") self.spin_take_profit = QDoubleSpinBox() self.spin_take_profit.setRange(0.0, 500.0); self.spin_take_profit.setValue(10.0) self.spin_take_profit.setSuffix("%") self.spin_take_profit.setStyleSheet("background-color: #333; color: #ff6b6b; font-weight: bold;") self.btn_auto_trade = QPushButton("🤖 執行下單並開始追蹤") self.btn_auto_trade.setFixedSize(200, 40) self.btn_auto_trade.setStyleSheet("background-color: #e03131; font-size: 14px; font-weight: bold;") self.btn_auto_trade.clicked.connect(self.run_simulation_and_monitor) control_layout.addWidget(QLabel("本金:")) control_layout.addWidget(self.input_capital) control_layout.addSpacing(10) control_layout.addWidget(QLabel("買 Top:")) control_layout.addWidget(self.spin_top_n) control_layout.addSpacing(10) control_layout.addWidget(QLabel("停損點:")) control_layout.addWidget(self.spin_stop_loss) control_layout.addSpacing(10) control_layout.addWidget(QLabel("停利點:")) control_layout.addWidget(self.spin_take_profit) control_layout.addStretch() control_layout.addWidget(self.btn_auto_trade) layout.addWidget(control_group) # 2. 績效儀表板 stats_group = QGroupBox("📊 投資損益即時追蹤") stats_layout = QHBoxLayout(stats_group) self.lbl_total_assets = self.create_stat_label("即時總市值", "$0") self.lbl_cash = self.create_stat_label("剩餘現金", "$0") self.lbl_profit = self.create_stat_label("未實現損益", "$0", color=True) self.lbl_status = QLabel("狀態: 等待下單...") self.lbl_status.setStyleSheet("color: orange; font-weight: bold; font-size: 14px;") stats_layout.addWidget(self.lbl_total_assets) stats_layout.addWidget(self.lbl_cash) stats_layout.addWidget(self.lbl_profit) stats_layout.addWidget(self.lbl_status) layout.addWidget(stats_group) # 3. 持倉列表 self.portfolio_table = QTableWidget() self.portfolio_table.setColumnCount(10) self.portfolio_table.setHorizontalHeaderLabels( ["代號", "名稱", "買入價", "現價(Live)", "股數", "成本", "即時市值", "損益%", "建議/狀態", "AI評分"] ) self.portfolio_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) layout.addWidget(self.portfolio_table) def create_stat_label(self, title, default_val, color=False): frame = QFrame(); frame.setStyleSheet("background-color: #333; border-radius: 5px;") vbox = QVBoxLayout(frame) tl = QLabel(title); tl.setStyleSheet("color: #aaa; font-size: 12px; border: none;") vl = QLabel(default_val); vl.setStyleSheet("color: white; font-size: 18px; font-weight: bold; border: none;") vl.setAlignment(Qt.AlignmentFlag.AlignCenter) vbox.addWidget(tl); vbox.addWidget(vl) if color: vl.setObjectName("color_label") return frame def update_data(self, data_cache): self.stock_data = list(data_cache.values()) def run_simulation_and_monitor(self): if not self.stock_data: QMessageBox.warning(self, "無資料", "請先執行市場掃描!") return if self.monitor_thread and self.monitor_thread.isRunning(): self.monitor_thread.stop(); self.monitor_thread.wait() try: capital = float(self.input_capital.text()) top_n = self.spin_top_n.value() except: return sorted_stocks = sorted(self.stock_data, key=lambda x: (x.get('Score', 0), x.get('Change', 0)), reverse=True) top_picks = sorted_stocks[:top_n] if not top_picks: return self.portfolio_table.setRowCount(0); self.active_portfolio = {} budget_per_stock = capital / len(top_picks); total_cost = 0 for i, stock in enumerate(top_picks): ticker = stock['Ticker'] buy_price = stock.get('Open', stock['Price']) shares = int(budget_per_stock // buy_price) if shares == 0: continue cost = shares * buy_price; total_cost += cost self.portfolio_table.insertRow(i) self.portfolio_table.setItem(i, 0, QTableWidgetItem(ticker)) self.portfolio_table.setItem(i, 1, QTableWidgetItem(stock['Name'])) self.portfolio_table.setItem(i, 2, QTableWidgetItem(f"{buy_price:.2f}")) self.portfolio_table.setItem(i, 3, QTableWidgetItem(f"{buy_price:.2f}")) self.portfolio_table.setItem(i, 4, QTableWidgetItem(f"{shares:,}")) self.portfolio_table.setItem(i, 5, QTableWidgetItem(f"{cost:,.0f}")) self.portfolio_table.setItem(i, 6, QTableWidgetItem(f"{cost:,.0f}")) p_item = QTableWidgetItem("0.00%"); self.portfolio_table.setItem(i, 7, p_item) self.portfolio_table.setItem(i, 8, QTableWidgetItem("監控中...")) score_item = QTableWidgetItem(f"{stock['Score']:.1f}") score_item.setForeground(QColor("#ffcc00")); self.portfolio_table.setItem(i, 9, score_item) self.active_portfolio[ticker] = {'buy_price': buy_price, 'shares': shares, 'row': i} self.cash_left = capital - total_cost self.set_label_text(self.lbl_cash, f"${self.cash_left:,.0f}") self.lbl_status.setText("🟢 系統監控股價中...") self.monitor_thread = StockMonitorWorker({t: {'buy_price': d['buy_price']} for t, d in self.active_portfolio.items()}, self.spin_stop_loss.value(), self.spin_take_profit.value()) self.monitor_thread.update_signal.connect(self.on_price_update) self.monitor_thread.start() def on_price_update(self, ticker, current_price, profit_pct, status_msg): if ticker not in self.active_portfolio: return data = self.active_portfolio[ticker] row, shares = data['row'], data['shares'] self.portfolio_table.setItem(row, 3, QTableWidgetItem(f"{current_price:.2f}")) self.portfolio_table.setItem(row, 6, QTableWidgetItem(f"{current_price * shares:,.0f}")) p_item = QTableWidgetItem(f"{profit_pct:+.2f}%") p_item.setForeground(QColor("#ff6b6b" if profit_pct > 0 else "#51cf66")) self.portfolio_table.setItem(row, 7, p_item) self.portfolio_table.setItem(row, 8, QTableWidgetItem(status_msg)) self.recalc_total_assets() def recalc_total_assets(self): total_market_val = 0; total_profit = 0 for r in range(self.portfolio_table.rowCount()): try: mv = float(self.portfolio_table.item(r, 6).text().replace(",", "")) ct = float(self.portfolio_table.item(r, 5).text().replace(",", "")) total_market_val += mv; total_profit += (mv - ct) except: pass self.set_label_text(self.lbl_total_assets, f"${self.cash_left + total_market_val:,.0f}") self.set_label_text(self.lbl_profit, f"${total_profit:,.0f}", "#ff6b6b" if total_profit > 0 else "#51cf66") def set_label_text(self, frame, text, color=None): labels = frame.findChildren(QLabel) if len(labels) >= 2: labels[1].setText(text) if color: labels[1].setStyleSheet(f"color: {color}; font-size: 24px; font-weight: bold; border: none;")

==========================================

分頁 3: 自選股分頁 UI (加入 JSON 儲存功能)

==========================================

class SelfSelectTab(QWidget):
def init(self):
super().init()
self.monitor_thread = None
self.active_portfolio = {}
self.db_file = "portfolio.json"
self.init_ui()
self.load_portfolio() # 啟動時讀取 JSON

text
def init_ui(self): layout = QVBoxLayout(self) input_group = QGroupBox("📝 編輯自選股") input_layout = QHBoxLayout(input_group) self.input_ticker = QLineEdit(); self.input_ticker.setPlaceholderText("代號 (如 2330)"); self.input_ticker.setFixedWidth(100) self.input_price = QDoubleSpinBox(); self.input_price.setRange(0, 10000); self.input_price.setPrefix("$"); self.input_price.setFixedWidth(100) self.input_shares = QSpinBox(); self.input_shares.setRange(1, 1000000); self.input_shares.setValue(1000); self.input_shares.setSuffix(" 股"); self.input_shares.setFixedWidth(120) btn_add = QPushButton("➕ 加入/更新"); btn_add.clicked.connect(self.add_stock) btn_del = QPushButton("❌ 刪除選取"); btn_del.setStyleSheet("background-color: #555; color: white;"); btn_del.clicked.connect(self.remove_stock) input_layout.addWidget(QLabel("股票代號:")); input_layout.addWidget(self.input_ticker) input_layout.addWidget(QLabel("買入成本:")); input_layout.addWidget(self.input_price) input_layout.addWidget(QLabel("持有股數:")); input_layout.addWidget(self.input_shares) input_layout.addWidget(btn_add); input_layout.addWidget(btn_del); input_layout.addStretch() layout.addWidget(input_group) monitor_group = QGroupBox("🔍 監控設定") monitor_layout = QHBoxLayout(monitor_group) self.spin_stop_loss = QDoubleSpinBox(); self.spin_stop_loss.setRange(-50.0, 0.0); self.spin_stop_loss.setValue(-5.0); self.spin_stop_loss.setSuffix("%") self.spin_take_profit = QDoubleSpinBox(); self.spin_take_profit.setRange(0.0, 500.0); self.spin_take_profit.setValue(10.0); self.spin_take_profit.setSuffix("%") self.btn_monitor = QPushButton("▶ 開始即時監控"); self.btn_monitor.setFixedSize(150, 40); self.btn_monitor.setStyleSheet("background-color: #e03131; font-weight: bold;"); self.btn_monitor.clicked.connect(self.toggle_monitoring) monitor_layout.addWidget(QLabel("停損點:")); monitor_layout.addWidget(self.spin_stop_loss); monitor_layout.addWidget(QLabel("停利點:")); monitor_layout.addWidget(self.spin_take_profit); monitor_layout.addStretch(); monitor_layout.addWidget(self.btn_monitor) layout.addWidget(monitor_group) stats_group = QGroupBox("📊 自選股損益概況") stats_layout = QHBoxLayout(stats_group) self.lbl_total_value = self.create_stat_label("持倉總市值", "$0") self.lbl_total_cost = self.create_stat_label("總投入成本", "$0") self.lbl_total_profit = self.create_stat_label("未實現損益", "$0", color=True) stats_layout.addWidget(self.lbl_total_value); stats_layout.addWidget(self.lbl_total_cost); stats_layout.addWidget(self.lbl_total_profit); layout.addWidget(stats_group) self.table = QTableWidget(); self.table.setColumnCount(9) self.table.setHorizontalHeaderLabels(["代號", "名稱", "買入價", "現價", "股數", "成本", "市值", "損益%", "狀態"]) self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) layout.addWidget(self.table) def save_portfolio(self): data_list = [] for r in range(self.table.rowCount()): data_list.append({ "ticker": self.table.item(r, 0).text(), "name": self.table.item(r, 1).text(), "buy_price": float(self.table.item(r, 2).text()), "shares": int(self.table.item(r, 4).text().replace(",", "")) }) with open(self.db_file, "w", encoding="utf-8") as f: json.dump(data_list, f, ensure_ascii=False, indent=4) def load_portfolio(self): if not os.path.exists(self.db_file): return try: with open(self.db_file, "r", encoding="utf-8") as f: data_list = json.load(f) QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) for item in data_list: live = get_stock_initial_data(item['ticker']) curr_p = live['Price'] if live else item['buy_price'] self.add_to_table(item['ticker'], item['name'], item['buy_price'], curr_p, item['shares']) QApplication.restoreOverrideCursor() self.recalc_totals() except: pass def add_to_table(self, ticker, name, buy_price, current_price, shares): row = self.table.rowCount(); self.table.insertRow(row) cost = buy_price * shares; mv = current_price * shares profit_pct = ((mv - cost) / cost * 100) if cost > 0 else 0 self.table.setItem(row, 0, QTableWidgetItem(ticker)) self.table.setItem(row, 1, QTableWidgetItem(name)) self.table.setItem(row, 2, QTableWidgetItem(f"{buy_price:.2f}")) self.table.setItem(row, 3, QTableWidgetItem(f"{current_price:.2f}")) self.table.setItem(row, 4, QTableWidgetItem(f"{shares:,}")) self.table.setItem(row, 5, QTableWidgetItem(f"{cost:,.0f}")) self.table.setItem(row, 6, QTableWidgetItem(f"{mv:,.0f}")) p_item = QTableWidgetItem(f"{profit_pct:+.2f}%") p_item.setForeground(QColor("#ff6b6b" if profit_pct > 0 else "#51cf66")) self.table.setItem(row, 7, p_item) self.table.setItem(row, 8, QTableWidgetItem("等待監控")) def add_stock(self): ticker = self.input_ticker.text().strip(); cost = self.input_price.value(); shares = self.input_shares.value() if not ticker: return QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) data = get_stock_initial_data(ticker); QApplication.restoreOverrideCursor() if not data: return if cost == 0: cost = data['Price'] for r in range(self.table.rowCount()): if self.table.item(r, 0).text() == data['Ticker']: self.table.removeRow(r); break self.add_to_table(data['Ticker'], data['Name'], cost, data['Price'], shares) self.recalc_totals(); self.save_portfolio(); self.input_ticker.clear() def remove_stock(self): row = self.table.currentRow() if row >= 0: self.table.removeRow(row); self.recalc_totals(); self.save_portfolio() def toggle_monitoring(self): if self.monitor_thread and self.monitor_thread.isRunning(): self.monitor_thread.stop(); self.monitor_thread.wait() self.btn_monitor.setText("▶ 開始即時監控"); return if self.table.rowCount() == 0: return self.active_portfolio = {} for r in range(self.table.rowCount()): self.active_portfolio[self.table.item(r,0).text()] = {'buy_price': float(self.table.item(r,2).text()), 'shares': int(self.table.item(r,4).text().replace(",","")), 'row': r} self.monitor_thread = StockMonitorWorker({t: {'buy_price': d['buy_price']} for t, d in self.active_portfolio.items()}, self.spin_stop_loss.value(), self.spin_take_profit.value()) self.monitor_thread.update_signal.connect(self.on_price_update); self.monitor_thread.start() self.btn_monitor.setText("⏹ 停止監控") def on_price_update(self, ticker, current_price, profit_pct, status_msg): if ticker not in self.active_portfolio: return d = self.active_portfolio[ticker]; r = d['row'] self.table.setItem(r, 3, QTableWidgetItem(f"{current_price:.2f}")) self.table.setItem(r, 6, QTableWidgetItem(f"{current_price * d['shares']:,.0f}")) p_item = QTableWidgetItem(f"{profit_pct:+.2f}%") p_item.setForeground(QColor("#ff6b6b" if profit_pct > 0 else "#51cf66")) self.table.setItem(r, 7, p_item) self.table.setItem(r, 8, QTableWidgetItem(status_msg)) self.recalc_totals() def recalc_totals(self): v = 0; c = 0 for r in range(self.table.rowCount()): try: shares = int(self.table.item(r,4).text().replace(",","")) v += shares * float(self.table.item(r,3).text()); c += shares * float(self.table.item(r,2).text()) except: pass self.set_lbl(self.lbl_total_value, f"${v:,.0f}"); self.set_lbl(self.lbl_total_cost, f"${c:,.0f}") self.set_lbl(self.lbl_total_profit, f"${v-c:,.0f}", "#ff6b6b" if v-c > 0 else "#51cf66") def set_lbl(self, frame, text, color=None): ls = frame.findChildren(QLabel) if len(ls) >= 2: ls[1].setText(text) if color: ls[1].setStyleSheet(f"color: {color}; font-size: 24px; font-weight: bold; border: none;") def create_stat_label(self, title, default_val, color=False): frame = QFrame(); frame.setStyleSheet("background-color: #333; border-radius: 5px;") vbox = QVBoxLayout(frame); tl = QLabel(title); tl.setStyleSheet("color: #aaa; font-size: 12px; border: none;") vl = QLabel(default_val); vl.setStyleSheet("color: white; font-size: 18px; font-weight: bold; border: none;") vl.setAlignment(Qt.AlignmentFlag.AlignCenter); vbox.addWidget(tl); vbox.addWidget(vl) if color: vl.setObjectName("color_label") return frame

==========================================

主視窗 UI

==========================================

class StockAnalyzerApp(QMainWindow):
def init(self):
super().init()
self.setWindowTitle("AI 電子族群全掃描 & 智能監控系統")
self.resize(1500, 900)
self.stock_data_cache = {}
self.init_ui()
self.apply_stylesheet()

text
def init_ui(self): main_widget = QWidget(); self.setCentralWidget(main_widget) main_layout = QVBoxLayout(main_widget) self.tabs = QTabWidget(); main_layout.addWidget(self.tabs) self.tab_scan = QWidget(); self.setup_scan_tab() self.tabs.addTab(self.tab_scan, "🔍 市場掃描") self.tab_trading = SimulatedTradingTab() self.tabs.addTab(self.tab_trading, "💹 AI 下單與即時監控") self.tab_self_select = SelfSelectTab() self.tabs.addTab(self.tab_self_select, "📝 我的自選股") def setup_scan_tab(self): layout = QVBoxLayout(self.tab_scan) top_bar = QHBoxLayout() self.btn_scan = QPushButton("🚀 開始掃描"); self.btn_scan.setFixedSize(180, 40); self.btn_scan.clicked.connect(self.start_scan) strategy_group = QGroupBox("選擇策略"); strategy_layout = QHBoxLayout(strategy_group) self.cb_basic = QCheckBox("基本"); self.cb_quant = QCheckBox("量化"); self.cb_flow = QCheckBox("資金"); self.cb_pattern = QCheckBox("形態"); self.cb_sentiment = QCheckBox("情緒") for cb in [self.cb_basic, self.cb_quant, self.cb_flow, self.cb_pattern, self.cb_sentiment]: cb.setChecked(True); strategy_layout.addWidget(cb) self.lbl_status = QLabel("準備就緒"); self.progress_bar = QProgressBar() top_bar.addWidget(self.btn_scan); top_bar.addWidget(strategy_group); top_bar.addWidget(self.lbl_status); top_bar.addWidget(self.progress_bar) layout.addLayout(top_bar) splitter = QSplitter(Qt.Orientation.Horizontal) self.table = QTableWidget(); self.table.setColumnCount(6) self.table.setHorizontalHeaderLabels(["代號", "名稱", "現價", "漲跌%", "策略分析與建議", "評分"]) # --- 優化表格欄位寬度 --- header = self.table.horizontalHeader() header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) # 代號 header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) # 名稱 header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents) # 現價 header.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents) # 漲跌% header.setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch) # 策略 (撐開) header.setSectionResizeMode(5, QHeaderView.ResizeMode.ResizeToContents) # 評分 self.table.setWordWrap(True) self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) self.table.cellClicked.connect(self.on_table_click) splitter.addWidget(self.table) self.chart_widget = QWidget(); self.chart_layout = QVBoxLayout(self.chart_widget) self.lbl_current_price = QLabel(""); self.lbl_price_change = QLabel("") info_box = QHBoxLayout(); info_box.addWidget(self.lbl_current_price); info_box.addWidget(self.lbl_price_change); info_box.addStretch() self.chart_layout.addLayout(info_box) self.figure = Figure(figsize=(10, 8), facecolor='#2b2b2b'); self.canvas = FigureCanvas(self.figure) self.chart_layout.addWidget(self.canvas); splitter.addWidget(self.chart_widget) splitter.setStretchFactor(0, 6); splitter.setStretchFactor(1, 4) layout.addWidget(splitter) def apply_stylesheet(self): self.setStyleSheet(""" QMainWindow, QWidget { background-color: black; color: #e0e0e0; } QTabWidget::pane { border: 1px solid #444; background-color: black; } QTabBar::tab { background: black; color: #aaa; padding: 10px 20px; border: 1px solid #333; } QTabBar::tab:selected { background: #222; color: white; border-top: 3px solid #0d6efd; } QTableWidget { background-color: black; gridline-color: #333; color: white; } QHeaderView::section { background-color: #111; color: white; padding: 5px; } QPushButton { background-color: #0d6efd; color: white; border-radius: 5px; font-weight: bold; } QProgressBar { text-align: center; background-color: #111; color: white; } QProgressBar::chunk { background-color: #0d6efd; } QLineEdit, QSpinBox, QDoubleSpinBox { background-color: #111; color: white; border: 1px solid #555; } """) def start_scan(self): strategies = [] if self.cb_basic.isChecked(): strategies.append('basic') if self.cb_quant.isChecked(): strategies.append('quant') if self.cb_flow.isChecked(): strategies.append('flow') if self.cb_pattern.isChecked(): strategies.append('pattern') if self.cb_sentiment.isChecked(): strategies.append('sentiment') self.btn_scan.setEnabled(False); self.btn_scan.setText("掃描中..."); self.table.setRowCount(0); self.stock_data_cache = {} self.worker = StockScanWorker(strategies); self.worker.progress_signal.connect(self.update_progress) self.worker.result_signal.connect(self.add_result); self.worker.finished_signal.connect(self.scan_finished); self.worker.start() def update_progress(self, current, total, text): self.lbl_status.setText(text) if total > 0: self.progress_bar.setValue(int((current / total) * 100)) def add_result(self, data): row = self.table.rowCount(); self.table.insertRow(row) score = data.get('Score', 0) if score >= 9.5: rec, rec_color, bold = "🔥 強烈建議", "#ff3333", True elif score >= 8.5: rec, rec_color, bold = "✅ 明天建議", "#ff8c00", True else: rec, rec_color, bold = "🔍 觀察", "#aaaaaa", False self.table.setItem(row, 0, QTableWidgetItem(data['Ticker'])) name_item = QTableWidgetItem(data['Name']) if bold: f = name_item.font(); f.setBold(True); name_item.setFont(f) self.table.setItem(row, 1, name_item) p_item = QTableWidgetItem(f"{data['Price']:.2f}"); p_color = "#ff6b6b" if data['Change'] > 0 else "#51cf66" p_item.setForeground(QColor(p_color)); self.table.setItem(row, 2, p_item) c_item = QTableWidgetItem(f"{data['Change']:+.2f}%"); c_item.setForeground(QColor(p_color)); self.table.setItem(row, 3, c_item) strat_item = QTableWidgetItem(f"{rec} | {data['Strategy']}") strat_item.setToolTip(f"{rec} | {data['Strategy']}") strat_item.setForeground(QColor(rec_color)) if bold: f = strat_item.font(); f.setBold(True); strat_item.setFont(f) self.table.setItem(row, 4, strat_item) s_item = QTableWidgetItem(f"{score:.1f}"); s_item.setForeground(QColor(rec_color)); self.table.setItem(row, 5, s_item) self.stock_data_cache[row] = data def scan_finished(self, msg): self.btn_scan.setEnabled(True); self.btn_scan.setText("🚀 開始掃描"); self.tab_trading.update_data(self.stock_data_cache) QMessageBox.information(self, "完成", msg) def on_table_click(self, row, col): if row in self.stock_data_cache: self.plot_chart(self.stock_data_cache[row]) def plot_chart(self, data): self.figure.clear(); df = data['Data'] mc = mpf.make_marketcolors(up='#ff4d4d', down='#00cc66', inherit=True) s = mpf.make_mpf_style(marketcolors=mc, gridstyle='--', facecolor='#2b2b2b', edgecolor='white', figcolor='#2b2b2b') ax1 = self.figure.add_subplot(2, 1, 1); ax2 = self.figure.add_subplot(2, 1, 2, sharex=ax1) if 'MA20' not in df.columns: df['MA20'] = df['Close'].rolling(20).mean() mpf.plot(df, type='candle', style=s, ax=ax1, volume=ax2, addplot=[mpf.make_addplot(df['MA20'], ax=ax1, color='orange')]) ax1.set_title(f"{data['Ticker']} {data['Name']}", color='white'); self.canvas.draw() self.lbl_current_price.setText(f"現價: {data['Price']}"); self.lbl_price_change.setText(f"漲跌: {data['Change']:+.2f}%")

if name == 'main':
app = QApplication(sys.argv); app.setFont(QFont("Microsoft JhengHei", 10))
window = StockAnalyzerApp(); window.show(); sys.exit(app.exec())
主程式如上
副程式如下:
import time
import io
import json
import os
import pandas as pd
import numpy as np
import yfinance as yf
import requests
from PyQt6.QtCore import QThread, pyqtSignal

==========================================

全域設定與工具函式

==========================================

TRACKING_FILE = "institutional_tracking.json"

def load_tracking_data():
if os.path.exists(TRACKING_FILE):
try:
with open(TRACKING_FILE, 'r', encoding='utf-8') as f:
return json.load(f)
except: return {}
return {}

def save_tracking_data(data):
try:
with open(TRACKING_FILE, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=4)
except: pass

def get_chinese_day_str(n):
units = ["零", "一", "二", "三", "四", "五", "六", "七", "八", "九", "十"]
return f"第{units[n] if n<=10 else n}天"

==========================================

核心分析邏輯 (Worker Thread)

==========================================

class StockScanWorker(QThread):
progress_signal = pyqtSignal(int, int, str)
result_signal = pyqtSignal(dict)
finished_signal = pyqtSignal(str)

text
def __init__(self, strategies=None): super().__init__() # 預設執行所有策略 self.strategies = strategies or ['basic', 'quant', 'flow', 'pattern', 'sentiment'] self.tracking_data = load_tracking_data() def run(self): try: self.progress_signal.emit(0, 0, "正在下載上市櫃電子與機電族群清單...") stock_dict = self.get_stock_list() stock_items = list(stock_dict.items()) total = len(stock_items) if total == 0: self.finished_signal.emit("錯誤:無法取得股票清單。") return for i, (ticker, name) in enumerate(stock_items): self.progress_signal.emit(i + 1, total, f"分析中: {name} ({ticker})") res = self.analyze_stock(ticker, name) if res: self.result_signal.emit(res) # 稍微延遲避免被 API 阻擋 time.sleep(0.01) save_tracking_data(self.tracking_data) self.finished_signal.emit(f"掃描完成,共分析 {total} 檔股票。") except Exception as e: self.finished_signal.emit(f"發生錯誤: {str(e)}") def get_stock_list(self): print("開始爬取股票代號...") """爬取台股上市櫃電子族群與機電族群清單""" urls = [("https://isin.twse.com.tw/isin/C_public.jsp?strMode=2", ".TW"), ("https://isin.twse.com.tw/isin/C_public.jsp?strMode=4", ".TWO")] headers = {"User-Agent": "Mozilla/5.0"} all_stocks = {} # 加入 '電機機械業' 到目標產業清單 target_industries = [ '半導體業', '電腦及週邊設備業', '光電業', '通信網路業', '電子零組件業', '電子通路業', '資訊服務業', '其他電子業', '電機機械', '電機機械業' # 涵蓋兩種可能的命名方式業' # <-- 新增機電股族群 ] for url, suffix in urls: try: res = requests.get(url, headers=headers, timeout=10) res.encoding = 'big5' dfs = pd.read_html(io.StringIO(res.text)) df = dfs[0] df.columns = df.iloc[0] df = df.iloc[1:] if '產業別' in df.columns: # 過濾出電子族群與機電族群 target_df = df[df['產業別'].isin(target_industries)] for _, row in target_df.iterrows(): code_name = row['有價證券代號及名稱'] try: # 格式通常是 "2330 台積電" parts = code_name.split() code = parts[0] name = parts[-1] # 確保代號是4位數字(普通股) if len(code) == 4 and code.isdigit(): all_stocks[code + suffix] = name except: continue except: continue # 若爬取失敗的保底清單 return all_stocks if all_stocks else {'2330.TW':'台積電', '2317.TW':'鴻海', '1504.TW':'東元'} def analyze_stock(self, ticker, name): """下載數據並執行多重策略分析""" try: # 獲取 6 個月歷史數據用於分析長線,並獲取當天 1 分鐘數據用於模擬分時圖 df = yf.download(ticker, period="6mo", progress=False, auto_adjust=True) if df.empty or len(df) < 40: return None if isinstance(df.columns, pd.MultiIndex): df.columns = df.columns.get_level_values(0) # 獲取股本資訊以計算換手率 (Turnover Rate) # 註:yfinance 的 info 請求較慢,若掃描量大建議預設一個流通股數 stock_info = yf.Ticker(ticker).info shares_outstanding = stock_info.get('sharesOutstanding', None) market_cap = stock_info.get('marketCap', 0) curr = df.iloc[-1] prev = df.iloc[-2] # --- 執行新:一夜持股法策略 --- overnight_res = self.overnight_strategy_analysis(ticker, name, df, shares_outstanding, market_cap) analysis_results = [] if overnight_res: analysis_results.append(overnight_res) # 原有的其他策略 if 'flow' in self.strategies: res = self.institutional_flow_analysis(ticker, name, df) if res: analysis_results.append(res) if 'basic' in self.strategies: res = self.basic_strategy_analysis(ticker, name, df) if res: analysis_results.append(res) if analysis_results: # 以評分最高者優先顯示 analysis_results.sort(key=lambda x: x.get('Score', 0), reverse=True) best_result = analysis_results[0] best_result['Open'] = float(curr['Open']) best_result['Data'] = df return best_result except Exception as e: print(f"分析 {ticker} 出錯: {e}") return None def overnight_strategy_analysis(self, ticker, name, df, shares, mkt_cap): """ 圖示【一夜持股法】規則實現: 1. 當日漲幅在 3%~5% 之間 2. 20天內有過漲停 (台股約 9.8% 以上) 3. 量比 (當日成交量 / 5日均量) > 1.5 4. 換手率在 5%~10% 之間 5. 分時均線判定 (以收盤價在高價區且大於開盤價模擬) """ try: close = df['Close'] volume = df['Volume'] high = df['High'] low = df['Low'] # 規則 1: 當日漲幅 3%~5% change_pct = ((close.iloc[-1] - close.iloc[-2]) / close.iloc[-2]) * 100 if not (3.0 <= change_pct <= 5.5): # 稍微寬限一點點 return None # 規則 2: 20天內有過漲停 (台股漲停為10%,判定設為 9.8%) recent_20 = df.tail(20) daily_returns = recent_20['Close'].pct_change() had_limit_up = (daily_returns >= 0.098).any() if not had_limit_up: return None # 規則 3: 量比 (量比大於 1.2 視為放量) vol_ma5 = volume.rolling(5).mean().iloc[-2] volume_ratio = volume.iloc[-1] / vol_ma5 if vol_ma5 > 0 else 0 if volume_ratio < 1.2: return None # 規則 4: 換手率 5%~10% # 換手率 = 當日成交股數 / 發行股數 turnover_rate = 0 if shares: turnover_rate = (volume.iloc[-1] / shares) * 100 else: # 若無股本資訊,以成交金額或歷史量能估算,此處暫設為通過但分數微扣 turnover_rate = 7.0 if not (5.0 <= turnover_rate <= 12.0): # 依圖示排除低於5%或高於10%(寬限至12%) return None # 規則 5: 分時均線判定 (模擬:收盤價位於當天高點附近且高於均價) # 真實分時均線需要 1m 數據,此處以 (高+低+收)/3 作為模擬均價 avg_price = (high.iloc[-1] + low.iloc[-1] + close.iloc[-1]) / 3 is_above_avg = close.iloc[-1] > avg_price and close.iloc[-1] > df['Open'].iloc[-1] if not is_above_avg: return None # 滿足所有條件,給予高分 score = 9.5 + (volume_ratio * 0.1) # 量比越高分數微增 desc = f"【一夜持股法】20日內曾漲停、當天強勢站穩均線、換手率({turnover_rate:.1f}%)適中" return { 'Ticker': ticker, 'Name': name, 'Price': float(close.iloc[-1]), 'Change': round(change_pct, 2), 'Strategy': desc, 'Score': round(score, 1) } except: return None def institutional_flow_analysis(self, ticker, name, df): try: close = df['Close'] volume = df['Volume'] high_20 = df['High'].rolling(20).max() vol_ma5 = volume.rolling(5).mean() curr_close = close.iloc[-1] is_breakout = (curr_close >= high_20.iloc[-2]) and (volume.iloc[-1] > vol_ma5.iloc[-1] * 1.5) if is_breakout: today_date = df.index[-1].strftime('%Y-%m-%d') yesterday_date = df.index[-2].strftime('%Y-%m-%d') record = self.tracking_data.get(ticker, {"count": 0, "last_date": ""}) if record["last_date"] == yesterday_date: new_count = record["count"] + 1 else: new_count = 1 self.tracking_data[ticker] = {"count": new_count, "last_date": today_date} if new_count >= 2: score = 10.0; strat_name = f"機構連兩日強勢噴發" else: score = 9.2; strat_name = f"機構首日突破" return {'Ticker': ticker, 'Name': name, 'Price': float(curr_close), 'Change': round(((curr_close - close.iloc[-2])/close.iloc[-2]*100),2), 'Strategy': strat_name, 'Score': score} except: pass return None def basic_strategy_analysis(self, ticker, name, df): try: close = df['Close']; volume = df['Volume'] curr = df.iloc[-1]; prev = df.iloc[-2] high_9 = df['High'].rolling(9).max(); low_9 = df['Low'].rolling(9).min() rsv = (close - low_9) / (high_9 - low_9) * 100 k = rsv.ewm(com=2).mean(); d = k.ewm(com=2).mean() ma20 = close.rolling(20).mean(); vol_ma5 = volume.rolling(5).mean() if curr['Volume'] > vol_ma5.iloc[-1] * 2.5 and curr['Close'] > ma20.iloc[-1]: return {'Ticker': ticker, 'Name': name, 'Price': float(curr['Close']), 'Change': round(((curr['Close']-prev['Close'])/prev['Close']*100),2), 'Strategy': "主力大單敲進+突破月線", 'Score': 9.6} if k.iloc[-1] < 30 and k.iloc[-2] < d.iloc[-2] and k.iloc[-1] > d.iloc[-1]: return {'Ticker': ticker, 'Name': name, 'Price': float(curr['Close']), 'Change': round(((curr['Close']-prev['Close'])/prev['Close']*100),2), 'Strategy': "低檔KD金叉(反彈)", 'Score': 8.6} except: pass return None def advanced_quantitative_analysis(self, ticker, name, df): try: close = df['Close']; ma5 = close.rolling(5).mean(); ma10 = close.rolling(10).mean(); ma20 = close.rolling(20).mean() if ma5.iloc[-1] > ma10.iloc[-1] > ma20.iloc[-1] and close.iloc[-1] > ma5.iloc[-1]: return {'Ticker': ticker, 'Name': name, 'Price': float(close.iloc[-1]), 'Change': round(((close.iloc[-1]-close.iloc[-2])/close.iloc[-2]*100),2), 'Strategy': "均線多頭排列(強勢股)", 'Score': 8.8} except: pass return None def market_sentiment_analysis(self, ticker, name, df): try: close = df['Close']; ma20 = close.rolling(20).mean() bias = (close.iloc[-1] / ma20.iloc[-1] - 1) * 100 if bias > 8: return {'Ticker': ticker, 'Name': name, 'Price': float(close.iloc[-1]), 'Change': round(((close.iloc[-1]-close.iloc[-2])/close.iloc[-2]*100),2), 'Strategy': "極度強勢(乖離噴發)", 'Score': 9.1} except: pass return None

==========================================

監控與工具函式

==========================================

class StockMonitorWorker(QThread):
update_signal = pyqtSignal(str, float, float, str)
def init(self, portfolio, stop_loss_pct, take_profit_pct):
super().init()
self.portfolio = portfolio
self.stop_loss_pct = stop_loss_pct
self.take_profit_pct = take_profit_pct
self.running = True

text
def run(self): while self.running: for ticker, data in self.portfolio.items(): if not self.running: break try: stock = yf.Ticker(ticker) hist = stock.history(period="1d") if hist.empty: continue current_price = float(hist['Close'].iloc[-1]) buy_price = data['buy_price'] profit_pct = ((current_price - buy_price) / buy_price) * 100 status = "正常追蹤中" if profit_pct <= self.stop_loss_pct: status = "🚨 建議停損賣出" elif profit_pct >= self.take_profit_pct: status = "💰 建議停利賣出" self.update_signal.emit(ticker, current_price, profit_pct, status) except: continue time.sleep(1) for _ in range(30): if not self.running: break time.sleep(1) def stop(self): self.running = False

def get_stock_initial_data(ticker):
"""查詢單一股票基本資訊,並強制從證交所抓取中文名稱"""
try:
import re
t_str = str(ticker).strip().upper()
pure_code = re.findall(r'\d+', t_str)[0]
formatted = f"{pure_code}.TW"
chinese_name = None
urls = ["https://isin.twse.com.tw/isin/C_public.jsp?strMode=2", "https://isin.twse.com.tw/isin/C_public.jsp?strMode=4"]
for url in urls:
try:
res = requests.get(url, timeout=5)
res.encoding = 'big5'
match = re.search(rf"{pure_code}\s+([^<\t\s\u3000]+)", res.text)
if match:
chinese_name = match.group(1).strip()
break
except: continue
stock = yf.Ticker(formatted); hist = stock.history(period="1d")
if hist.empty:
formatted = f"{pure_code}.TWO"; stock = yf.Ticker(formatted); hist = stock.history(period="1d")
if hist.empty: return None
if not chinese_name: chinese_name = stock.info.get('shortName', formatted)
return {'Ticker': formatted, 'Name': chinese_name, 'Price': float(hist['Close'].iloc[-1])}
except Exception as e:
print(f"抓取中文名稱失敗: {e}")
return None
請修改上面主程式或是副程式,參考市面上獲利最高的策略修改上面的程式

Мысль

Deciding on skill usage for code modification

I need to check if there are any specific skills for code modification. Since I can’t see the skill library here, and the task might not require specialized skills, I’ll assume it’s fine to proceed with a normal approach. If needed, I can revisit it.

Evaluating strategy and web search for profitability

The user asks about the most profitable strategies, which could need up-to-date, data-driven insights. I’ll search online for current strategies, but I can’t give financial advice directly. I'll include citations from reputable sources like studies on momentum or trend following. Time to gather these details carefully!### Searching for current profitable strategies and reliable sources

To help with the user’s request for profitable strategies, I should focus on authoritative, up-to-date sources like academic papers or reputable platforms. Momentum strategies, trend following, and breakout strategies could be useful. I’ll also check Quantpedia and SSRN, ensuring I include relevant citations to support my findings.

{"search_query":[{"q":"Jegadeesh Titman 1993 momentum profits abstract PDF","domains":["nber.org"]},{"q":"AQR trend following white paper century of evidence 2017 PDF","domains":["aqr.com"]},{"q":"Novy-Marx gross profitability premium paper PDF","domains":["nber.org"]},{"q":"Moskowitz Ooi Pedersen time series momentum paper PDF","domains":["journaloffinancialeconomics.com","nber.org"]}],"response_length":"short"}

Searching for breakout strategy evidence

I should look into evidence supporting 52-week high and Donchian breakout strategies. These could be helpful for the user’s request. I'll search for relevant information and supporting studies, focusing on real-world applicability and strengthening the strategy with evidence to back up its use.

{"search_query":[{"q":"52-week high momentum effect academic paper PDF","domains":["nber.org","sciencedirect.com"]},{"q":"Donchian channel breakout trend following academic evidence PDF","domains":["ssrn.com","nber.org"]},{"q":"Antonacci dual momentum paper PDF","domains":["optimalmomentum.com","ssrn.com"]}],"response_length":"short"}

Preparing data sources and considering code modifications

I need to focus on robust profit strategies, looking at primary technical sources like ScienceDirect and time-series momentum across assets. Since the user wants code modifications, I’ll ensure global evidence supports this. I'll explore stop-loss, trend-following, and ATR-based strategies. I also need to review the code, run some static checks, and possibly craft modifications.### Developing momentum-based stock strategy

The plan is to develop a subprogram based on a "dual momentum" strategy, using absolute and relative momentum for Taiwanese stocks, alongside an ATR stop and trend filter. The strategy will rank stocks using factors like 3m/6m momentum, volume breakouts, and profitability. I'll focus on price/volume to streamline the data. Let's inspect the issues with the target industry list and make sure the code's syntax for comments and strings are fine. I'll also focus on implementing the composite ranking strategy.

Developing stock screening strategy

I need to adjust the 52-week high calculation to use a 1-year (252 days) rolling window instead of 6 months. The metrics will include various rates of change, moving average crossovers, proximity to the 52-week high, breakout confirmations, and volume surges. I'll also incorporate liquidity filters and possibly a risk control mechanism based on ATR. I'll integrate this with existing institutional flow strategies and implement pattern breakout and sentiment analysis to gauge volatility contraction. I might modify it to match the user's focus on profitable strategies.### Simplifying stock strategy with risk management

I’m considering adding position sizing based on ATR, but the UI currently uses equal-weight. Introducing risk parity for shares might be more advanced. To keep it simple, we could add an internal risk cap and label the new column. User requested modifications, so I might draft a revised version of "stock_logic_V02.py" and keep UI changes minimal, ensuring the syntax works. I’ll create files in a container for this.

bash -lc cat > /tmp/stock_logic_V02.py <<'PY' import time import io import json import os import re import pandas as pd import numpy as np import yfinance as yf import requests from PyQt6.QtCore import QThread, pyqtSignal

TRACKING_FILE = "institutional_tracking.json"

def load_tracking_data(): if os.path.exists(TRACKING_FILE): try: with open(TRACKING_FILE, 'r', encoding='utf-8') as f: return json.load(f) except Exception: return {} return {}

def save_tracking_data(data): try: with open(TRACKING_FILE, 'w', encoding='utf-8') as f: json.dump(data, f, ensure_ascii=False, indent=4) except Exception: pass

def safe_float(value, default=0.0): try: if pd.isna(value): return default return float(value) except Exception: return default

class StockScanWorker(QThread): progress_signal = pyqtSignal(int, int, str) result_signal = pyqtSignal(dict) finished_signal = pyqtSignal(str)

text
def __init__(self, strategies=None): super().__init__() self.strategies = strategies or ['basic', 'quant', 'flow', 'pattern', 'sentiment'] self.tracking_data = load_tracking_data() def run(self): try: self.progress_signal.emit(0, 0, "正在下載上市櫃電子與機電族群清單...") stock_dict = self.get_stock_list() stock_items = list(stock_dict.items()) total = len(stock_items) if total == 0: self.finished_signal.emit("錯誤:無法取得股票清單。") return for i, (ticker, name) in enumerate(stock_items): self.progress_signal.emit(i + 1, total, f"分析中: {name} ({ticker})") res = self.analyze_stock(ticker, name) if res: self.result_signal.emit(res) time.sleep(0.02) save_tracking_data(self.tracking_data) self.finished_signal.emit(f"掃描完成,共分析 {total} 檔股票。") except Exception as e: self.finished_signal.emit(f"發生錯誤: {str(e)}") def get_stock_list(self): urls = [ ("https://isin.twse.com.tw/isin/C_public.jsp?strMode=2", ".TW"), ("https://isin.twse.com.tw/isin/C_public.jsp?strMode=4", ".TWO"), ] headers = {"User-Agent": "Mozilla/5.0"} all_stocks = {} target_industries = [ '半導體業', '電腦及週邊設備業', '光電業', '通信網路業', '電子零組件業', '電子通路業', '資訊服務業', '其他電子業', '電機機械', '電機機械業' ] for url, suffix in urls: try: res = requests.get(url, headers=headers, timeout=10) res.encoding = 'big5' dfs = pd.read_html(io.StringIO(res.text)) df = dfs[0] df.columns = df.iloc[0] df = df.iloc[1:] if '產業別' not in df.columns: continue target_df = df[df['產業別'].isin(target_industries)] for _, row in target_df.iterrows(): code_name = str(row.get('有價證券代號及名稱', '')).strip() parts = code_name.split() if not parts: continue code = parts[0] name = parts[-1] if len(code) == 4 and code.isdigit(): all_stocks[code + suffix] = name except Exception: continue return all_stocks if all_stocks else {'2330.TW': '台積電', '2317.TW': '鴻海', '1504.TW': '東元'} def analyze_stock(self, ticker, name): try: df = yf.download(ticker, period="1y", progress=False, auto_adjust=True) if df.empty or len(df) < 140: return None if isinstance(df.columns, pd.MultiIndex): df.columns = df.columns.get_level_values(0) df = self.add_indicators(df) if df is None or df.empty: return None analysis_results = [] # 核心:多因子趨勢動能(最優先) core = self.composite_momentum_breakout_analysis(ticker, name, df) if core: analysis_results.append(core) # 補充:量價突破 / 機構流向 / 情緒延伸 if 'flow' in self.strategies: res = self.institutional_flow_analysis(ticker, name, df) if res: analysis_results.append(res) if 'basic' in self.strategies: res = self.basic_strategy_analysis(ticker, name, df) if res: analysis_results.append(res) if 'quant' in self.strategies: res = self.advanced_quantitative_analysis(ticker, name, df) if res: analysis_results.append(res) if 'pattern' in self.strategies: res = self.volatility_contraction_breakout_analysis(ticker, name, df) if res: analysis_results.append(res) if 'sentiment' in self.strategies: res = self.market_sentiment_analysis(ticker, name, df) if res: analysis_results.append(res) if not analysis_results: return None analysis_results.sort(key=lambda x: x.get('Score', 0), reverse=True) best_result = analysis_results[0] best_result['Open'] = safe_float(df['Open'].iloc[-1]) best_result['ATR'] = safe_float(df['ATR20'].iloc[-1]) best_result['TrailStop'] = safe_float(df['Close'].iloc[-1] - 2.5 * df['ATR20'].iloc[-1]) best_result['Data'] = df return best_result except Exception as e: print(f"分析 {ticker} 出錯: {e}") return None def add_indicators(self, df): try: close = df['Close'] high = df['High'] low = df['Low'] volume = df['Volume'] df['MA20'] = close.rolling(20).mean() df['MA60'] = close.rolling(60).mean() df['MA120'] = close.rolling(120).mean() df['VOL_MA20'] = volume.rolling(20).mean() df['ROC20'] = close.pct_change(20) * 100 df['ROC60'] = close.pct_change(60) * 100 df['ROC120'] = close.pct_change(120) * 100 df['HH20_PREV'] = high.shift(1).rolling(20).max() df['HH55_PREV'] = high.shift(1).rolling(55).max() df['HIGH_252'] = high.rolling(252, min_periods=120).max() df['DIST_52W_HIGH'] = (close / df['HIGH_252']) * 100 prev_close = close.shift(1) tr = pd.concat([ (high - low), (high - prev_close).abs(), (low - prev_close).abs() ], axis=1).max(axis=1) df['ATR20'] = tr.rolling(20).mean() df['ATR_PCT'] = (df['ATR20'] / close) * 100 df['VALUE_MA20'] = (close * volume).rolling(20).mean() return df.dropna().copy() except Exception: return None def build_result(self, ticker, name, close, change_pct, strategy, score, df): atr = safe_float(df['ATR20'].iloc[-1]) return { 'Ticker': ticker, 'Name': name, 'Price': safe_float(close), 'Change': round(safe_float(change_pct), 2), 'Strategy': strategy, 'Score': round(float(score), 2), 'ATR': round(atr, 2), 'TrailStop': round(safe_float(close) - 2.5 * atr, 2), } def composite_momentum_breakout_analysis(self, ticker, name, df): """ 核心策略:多因子趨勢動能 + 52 週高點 + 風險過濾 靈感來自:cross-sectional momentum / trend following / 52-week high effect """ try: curr = df.iloc[-1] prev = df.iloc[-2] close = safe_float(curr['Close']) prev_close = safe_float(prev['Close']) change_pct = ((close - prev_close) / prev_close) * 100 if prev_close else 0 # 流動性與價格品質 if curr['VALUE_MA20'] < 30_000_000: return None if curr['ATR_PCT'] > 9.5: return None # 趨勢濾網:多頭排列 + 價格站上均線 trend_ok = ( curr['Close'] > curr['MA20'] > curr['MA60'] > curr['MA120'] and curr['ROC60'] > 0 and curr['ROC120'] > 0 ) if not trend_ok: return None # 突破確認:接近/突破 52 週高點或 55 日高點 breakout_ok = ( curr['Close'] >= curr['HH55_PREV'] or curr['DIST_52W_HIGH'] >= 95 ) if not breakout_ok: return None # 量能確認 vol_ratio = curr['Volume'] / curr['VOL_MA20'] if curr['VOL_MA20'] else 0 if vol_ratio < 1.2: return None # 分數:中期動能 > 短期動能,避免只追單日脈衝 score = 0 score += min(max(curr['ROC20'], 0), 25) * 0.18 score += min(max(curr['ROC60'], 0), 40) * 0.14 score += min(max(curr['ROC120'], 0), 60) * 0.08 score += max(curr['DIST_52W_HIGH'] - 90, 0) * 0.18 score += min(vol_ratio, 3.0) * 0.9 score += 2.0 if curr['Close'] >= curr['HH55_PREV'] else 0 score += 1.2 if 0 < curr['ATR_PCT'] <= 6 else 0 if score < 8.8: return None desc = ( f"【核心策略】多因子趨勢動能 + 52週高點:" f"60日動能 {curr['ROC60']:.1f}% / 120日動能 {curr['ROC120']:.1f}% / " f"距52週高 {curr['DIST_52W_HIGH']:.1f}% / 量比 {vol_ratio:.2f}" ) return self.build_result(ticker, name, close, change_pct, desc, min(score, 10.0), df) except Exception: return None def institutional_flow_analysis(self, ticker, name, df): try: curr = df.iloc[-1] prev = df.iloc[-2] close = safe_float(curr['Close']) prev_close = safe_float(prev['Close']) change_pct = ((close - prev_close) / prev_close) * 100 if prev_close else 0 vol_ratio = curr['Volume'] / curr['VOL_MA20'] if curr['VOL_MA20'] else 0 breakout = curr['Close'] >= curr['HH20_PREV'] and vol_ratio >= 1.8 if not breakout: return None today_date = df.index[-1].strftime('%Y-%m-%d') yesterday_date = df.index[-2].strftime('%Y-%m-%d') record = self.tracking_data.get(ticker, {"count": 0, "last_date": ""}) new_count = record["count"] + 1 if record["last_date"] == yesterday_date else 1 self.tracking_data[ticker] = {"count": new_count, "last_date": today_date} score = 8.8 + min(vol_ratio, 3.0) * 0.3 + (0.6 if new_count >= 2 else 0) strat_name = f"量價突破 {'連續追蹤' if new_count >= 2 else '首日突破'} | 量比 {vol_ratio:.2f}" return self.build_result(ticker, name, close, change_pct, strat_name, min(score, 10.0), df) except Exception: return None def basic_strategy_analysis(self, ticker, name, df): try: curr = df.iloc[-1] prev = df.iloc[-2] close = safe_float(curr['Close']) prev_close = safe_float(prev['Close']) change_pct = ((close - prev_close) / prev_close) * 100 if prev_close else 0 pullback_entry = ( curr['Close'] > curr['MA20'] > curr['MA60'] and 0 <= (curr['Close'] / curr['MA20'] - 1) * 100 <= 4 and curr['ROC20'] > 0 and curr['ROC60'] > 0 and curr['Volume'] >= curr['VOL_MA20'] * 0.9 ) if not pullback_entry: return None score = 8.5 + min(curr['ROC60'], 20) * 0.03 return self.build_result( ticker, name, close, change_pct, "趨勢回檔承接:站上20/60日均線,屬順勢低風險切入", min(score, 9.4), df ) except Exception: return None def advanced_quantitative_analysis(self, ticker, name, df): try: curr = df.iloc[-1] prev = df.iloc[-2] close = safe_float(curr['Close']) prev_close = safe_float(prev['Close']) change_pct = ((close - prev_close) / prev_close) * 100 if prev_close else 0 quality_trend = ( curr['ROC20'] > 5 and curr['ROC60'] > 10 and curr['ROC120'] > 15 and curr['ATR_PCT'] <= 6.5 and curr['Close'] > curr['MA20'] > curr['MA60'] ) if not quality_trend: return None score = 8.9 + min(curr['ROC120'], 40) * 0.02 + (1.0 if curr['DIST_52W_HIGH'] > 97 else 0) return self.build_result( ticker, name, close, change_pct, "量化強勢股:中長期動能一致、波動受控、接近波段新高", min(score, 9.8), df ) except Exception: return None def volatility_contraction_breakout_analysis(self, ticker, name, df): try: if len(df) < 30: return None curr = df.iloc[-1] prev = df.iloc[-2] recent_atr = df['ATR_PCT'].tail(5).mean() past_atr = df['ATR_PCT'].tail(25).head(20).mean() vol_dry = df['Volume'].tail(5).mean() < df['VOL_MA20'].iloc[-1] * 0.9 breakout = curr['Close'] >= curr['HH20_PREV'] and curr['Volume'] > curr['VOL_MA20'] * 1.3 if not (recent_atr < past_atr and vol_dry and breakout): return None close = safe_float(curr['Close']) prev_close = safe_float(prev['Close']) change_pct = ((close - prev_close) / prev_close) * 100 if prev_close else 0 score = 8.7 + min((past_atr - recent_atr) * 0.6, 0.7) + min(curr['Volume'] / curr['VOL_MA20'], 2.5) * 0.2 return self.build_result( ticker, name, close, change_pct, "型態突破:波動收縮後放量突破,類似 VCP / 窄幅整理噴出", min(score, 9.7), df ) except Exception: return None def market_sentiment_analysis(self, ticker, name, df): try: curr = df.iloc[-1] prev = df.iloc[-2] close = safe_float(curr['Close']) prev_close = safe_float(prev['Close']) change_pct = ((close - prev_close) / prev_close) * 100 if prev_close else 0 bias20 = (curr['Close'] / curr['MA20'] - 1) * 100 bias60 = (curr['Close'] / curr['MA60'] - 1) * 100 if not (4 <= bias20 <= 12 and 8 <= bias60 <= 20 and curr['ATR_PCT'] <= 7): return None score = 8.4 + min(bias20, 10) * 0.05 + min(curr['ROC60'], 30) * 0.02 return self.build_result( ticker, name, close, change_pct, f"市場情緒強勢延續:20日乖離 {bias20:.1f}% / 60日乖離 {bias60:.1f}%", min(score, 9.5), df ) except Exception: return None

class StockMonitorWorker(QThread): update_signal = pyqtSignal(str, float, float, str)

text
def __init__(self, portfolio, stop_loss_pct, take_profit_pct): super().__init__() self.portfolio = portfolio self.stop_loss_pct = stop_loss_pct self.take_profit_pct = take_profit_pct self.running = True def run(self): while self.running: for ticker, data in self.portfolio.items(): if not self.running: break try: stock = yf.Ticker(ticker) hist = stock.history(period="5d", interval="1d") if hist.empty: continue current_price = safe_float(hist['Close'].iloc[-1]) buy_price = safe_float(data.get('buy_price')) if not buy_price: continue atr = safe_float(data.get('atr', 0)) data['highest_price'] = max(safe_float(data.get('highest_price', buy_price)), current_price) fixed_stop = buy_price * (1 + self.stop_loss_pct / 100) trailing_stop = data['highest_price'] - (2.5 * atr) if atr > 0 else fixed_stop effective_stop = max(fixed_stop, trailing_stop) take_profit = buy_price * (1 + self.take_profit_pct / 100) profit_pct = ((current_price - buy_price) / buy_price) * 100 status = f"正常追蹤中 | 停損參考 {effective_stop:.2f}" if current_price <= effective_stop: status = f"🚨 跌破動態停損 {effective_stop:.2f}" elif current_price >= take_profit and current_price >= data['highest_price'] * 0.985: status = f"💰 達停利區,續抱但嚴守移動停損" elif current_price >= take_profit: status = f"💡 已達停利區,可分批落袋" self.update_signal.emit(ticker, current_price, profit_pct, status) except Exception: continue time.sleep(1) for _ in range(20): if not self.running: break time.sleep(1) def stop(self): self.running = False

def get_stock_initial_data(ticker): try: t_str = str(ticker).strip().upper() pure_code = re.findall(r'\d+', t_str)[0] chinese_name = None

text
for suffix in ['.TW', '.TWO']: formatted = f"{pure_code}{suffix}" stock = yf.Ticker(formatted) hist = stock.history(period="5d", interval="1d") if hist.empty: continue if not chinese_name: urls = [ "https://isin.twse.com.tw/isin/C_public.jsp?strMode=2", "https://isin.twse.com.tw/isin/C_public.jsp?strMode=4" ] for url in urls: try: res = requests.get(url, timeout=5) res.encoding = 'big5' match = re.search(rf"{pure_code}\s+([^<\t\s\u3000]+)", res.text) if match: chinese_name = match.group(1).strip() break except Exception: continue close = safe_float(hist['Close'].iloc[-1]) high = hist['High'] low = hist['Low'] prev_close = hist['Close'].shift(1) tr = pd.concat([ (high - low), (high - prev_close).abs(), (low - prev_close).abs() ], axis=1).max(axis=1) atr = safe_float(tr.tail(5).mean(), 0.0) if not chinese_name: try: chinese_name = stock.info.get('shortName', formatted) except Exception: chinese_name = formatted return {'Ticker': formatted, 'Name': chinese_name, 'Price': close, 'ATR': atr} return None except Exception as e: print(f"抓取中文名稱失敗: {e}") return None

PY python3 -m py_compile /tmp/stock_logic_V02.py && echo '[OK] compiled stock_logic_V02.py'

Adding ATR and dynamic stop-loss features

I’m thinking about adding a new column for ATR and possibly a dynamic stop-loss to the portfolio table. The simpler approach might be just updating suggestions and status text rather than modifying table headers. I will ensure ATR is passed to the monitoring system and included in portfolio tracking for the user.

Modifying program with minimal changes

The user wants modifications, so I’ll update the program to sort by Score and include strategy text. I should address potential bugs in cached stock data and the SelfSelectTab issue with ATR not being saved/loaded. I'll minimize UI changes, adding just the required columns for ATR and dynamic stop-loss, then patch the code accordingly.

bash -lc cat > /tmp/main_V02.py <<'PY' import sys import mplfinance as mpf import warnings import json import os from PyQt6.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QTableWidget, QTableWidgetItem, QProgressBar, QHeaderView, QSplitter, QMessageBox, QCheckBox, QGroupBox, QTabWidget, QLineEdit, QFrame, QSpinBox, QDoubleSpinBox ) from PyQt6.QtCore import Qt from PyQt6.QtGui import QColor, QFont from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas from matplotlib.figure import Figure import matplotlib.pyplot as plt

from stock_logic_V02 import StockScanWorker, StockMonitorWorker, get_stock_initial_data

warnings.simplefilter(action='ignore', category=FutureWarning) plt.rcParams['font.sans-serif'] = ['Microsoft JhengHei'] plt.rcParams['axes.unicode_minus'] = False

class SimulatedTradingTab(QWidget): def init(self): super().init() self.stock_data = [] self.monitor_thread = None self.active_portfolio = {} self.cash_left = 0 self.init_ui()

text
def init_ui(self): layout = QVBoxLayout(self) control_group = QGroupBox("💰 智能下單與監控設定") control_layout = QHBoxLayout(control_group) self.input_capital = QLineEdit("1000000") self.input_capital.setPlaceholderText("輸入本金") self.input_capital.setFixedWidth(100) self.spin_top_n = QSpinBox() self.spin_top_n.setRange(1, 20) self.spin_top_n.setValue(5) self.spin_top_n.setFixedWidth(50) self.spin_stop_loss = QDoubleSpinBox() self.spin_stop_loss.setRange(-50.0, 0.0) self.spin_stop_loss.setValue(-5.0) self.spin_stop_loss.setSuffix("%") self.spin_stop_loss.setStyleSheet("background-color: #333; color: #51cf66; font-weight: bold;") self.spin_take_profit = QDoubleSpinBox() self.spin_take_profit.setRange(0.0, 500.0) self.spin_take_profit.setValue(15.0) self.spin_take_profit.setSuffix("%") self.spin_take_profit.setStyleSheet("background-color: #333; color: #ff6b6b; font-weight: bold;") self.btn_auto_trade = QPushButton("🤖 執行下單並開始追蹤") self.btn_auto_trade.setFixedSize(220, 40) self.btn_auto_trade.setStyleSheet("background-color: #e03131; font-size: 14px; font-weight: bold;") self.btn_auto_trade.clicked.connect(self.run_simulation_and_monitor) control_layout.addWidget(QLabel("本金:")) control_layout.addWidget(self.input_capital) control_layout.addSpacing(10) control_layout.addWidget(QLabel("買 Top:")) control_layout.addWidget(self.spin_top_n) control_layout.addSpacing(10) control_layout.addWidget(QLabel("固定停損:")) control_layout.addWidget(self.spin_stop_loss) control_layout.addSpacing(10) control_layout.addWidget(QLabel("停利區:")) control_layout.addWidget(self.spin_take_profit) control_layout.addStretch() control_layout.addWidget(self.btn_auto_trade) layout.addWidget(control_group) stats_group = QGroupBox("📊 投資損益即時追蹤") stats_layout = QHBoxLayout(stats_group) self.lbl_total_assets = self.create_stat_label("即時總市值", "$0") self.lbl_cash = self.create_stat_label("剩餘現金", "$0") self.lbl_profit = self.create_stat_label("未實現損益", "$0", color=True) self.lbl_status = QLabel("狀態: 等待下單...") self.lbl_status.setStyleSheet("color: orange; font-weight: bold; font-size: 14px;") stats_layout.addWidget(self.lbl_total_assets) stats_layout.addWidget(self.lbl_cash) stats_layout.addWidget(self.lbl_profit) stats_layout.addWidget(self.lbl_status) layout.addWidget(stats_group) self.portfolio_table = QTableWidget() self.portfolio_table.setColumnCount(11) self.portfolio_table.setHorizontalHeaderLabels([ "代號", "名稱", "買入價", "現價(Live)", "股數", "成本", "即時市值", "損益%", "ATR", "動態停損", "建議/狀態" ]) self.portfolio_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) layout.addWidget(self.portfolio_table) def create_stat_label(self, title, default_val, color=False): frame = QFrame() frame.setStyleSheet("background-color: #333; border-radius: 5px;") vbox = QVBoxLayout(frame) tl = QLabel(title) tl.setStyleSheet("color: #aaa; font-size: 12px; border: none;") vl = QLabel(default_val) vl.setStyleSheet("color: white; font-size: 18px; font-weight: bold; border: none;") vl.setAlignment(Qt.AlignmentFlag.AlignCenter) vbox.addWidget(tl) vbox.addWidget(vl) if color: vl.setObjectName("color_label") return frame def update_data(self, data_cache): self.stock_data = list(data_cache.values()) def run_simulation_and_monitor(self): if not self.stock_data: QMessageBox.warning(self, "無資料", "請先執行市場掃描!") return if self.monitor_thread and self.monitor_thread.isRunning(): self.monitor_thread.stop() self.monitor_thread.wait() try: capital = float(self.input_capital.text()) top_n = self.spin_top_n.value() except Exception: QMessageBox.warning(self, "輸入錯誤", "本金格式不正確") return sorted_stocks = sorted( self.stock_data, key=lambda x: (x.get('Score', 0), x.get('Change', 0), -x.get('ATR', 0)), reverse=True ) top_picks = sorted_stocks[:top_n] if not top_picks: return self.portfolio_table.setRowCount(0) self.active_portfolio = {} budget_per_stock = capital / len(top_picks) total_cost = 0 actual_row = 0 for stock in top_picks: ticker = stock['Ticker'] buy_price = stock.get('Open', stock['Price']) atr = float(stock.get('ATR', 0) or 0) shares = int(budget_per_stock // buy_price) if shares == 0: continue cost = shares * buy_price total_cost += cost self.portfolio_table.insertRow(actual_row) self.portfolio_table.setItem(actual_row, 0, QTableWidgetItem(ticker)) self.portfolio_table.setItem(actual_row, 1, QTableWidgetItem(stock['Name'])) self.portfolio_table.setItem(actual_row, 2, QTableWidgetItem(f"{buy_price:.2f}")) self.portfolio_table.setItem(actual_row, 3, QTableWidgetItem(f"{buy_price:.2f}")) self.portfolio_table.setItem(actual_row, 4, QTableWidgetItem(f"{shares:,}")) self.portfolio_table.setItem(actual_row, 5, QTableWidgetItem(f"{cost:,.0f}")) self.portfolio_table.setItem(actual_row, 6, QTableWidgetItem(f"{cost:,.0f}")) p_item = QTableWidgetItem("0.00%") self.portfolio_table.setItem(actual_row, 7, p_item) self.portfolio_table.setItem(actual_row, 8, QTableWidgetItem(f"{atr:.2f}")) trail_stop = max( buy_price * (1 + self.spin_stop_loss.value() / 100), buy_price - (2.5 * atr) if atr > 0 else buy_price * (1 + self.spin_stop_loss.value() / 100) ) self.portfolio_table.setItem(actual_row, 9, QTableWidgetItem(f"{trail_stop:.2f}")) self.portfolio_table.setItem(actual_row, 10, QTableWidgetItem("監控中...")) self.active_portfolio[ticker] = { 'buy_price': buy_price, 'shares': shares, 'row': actual_row, 'atr': atr, 'highest_price': buy_price } actual_row += 1 self.cash_left = capital - total_cost self.set_label_text(self.lbl_cash, f"${self.cash_left:,.0f}") self.lbl_status.setText("🟢 系統監控股價中(固定停損 + ATR 移動停損)...") monitor_payload = { t: { 'buy_price': d['buy_price'], 'atr': d['atr'], 'highest_price': d['highest_price'] } for t, d in self.active_portfolio.items() } self.monitor_thread = StockMonitorWorker( monitor_payload, self.spin_stop_loss.value(), self.spin_take_profit.value() ) self.monitor_thread.update_signal.connect(self.on_price_update) self.monitor_thread.start() def on_price_update(self, ticker, current_price, profit_pct, status_msg): if ticker not in self.active_portfolio: return data = self.active_portfolio[ticker] row, shares = data['row'], data['shares'] data['highest_price'] = max(data.get('highest_price', data['buy_price']), current_price) atr = float(data.get('atr', 0) or 0) trail_stop = max( data['buy_price'] * (1 + self.spin_stop_loss.value() / 100), data['highest_price'] - (2.5 * atr) if atr > 0 else data['buy_price'] * (1 + self.spin_stop_loss.value() / 100) ) self.portfolio_table.setItem(row, 3, QTableWidgetItem(f"{current_price:.2f}")) self.portfolio_table.setItem(row, 6, QTableWidgetItem(f"{current_price * shares:,.0f}")) p_item = QTableWidgetItem(f"{profit_pct:+.2f}%") p_item.setForeground(QColor("#ff6b6b" if profit_pct > 0 else "#51cf66")) self.portfolio_table.setItem(row, 7, p_item) self.portfolio_table.setItem(row, 9, QTableWidgetItem(f"{trail_stop:.2f}")) self.portfolio_table.setItem(row, 10, QTableWidgetItem(status_msg)) self.recalc_total_assets() def recalc_total_assets(self): total_market_val = 0 total_profit = 0 for r in range(self.portfolio_table.rowCount()): try: mv = float(self.portfolio_table.item(r, 6).text().replace(",", "")) ct = float(self.portfolio_table.item(r, 5).text().replace(",", "")) total_market_val += mv total_profit += (mv - ct) except Exception: pass self.set_label_text(self.lbl_total_assets, f"${self.cash_left + total_market_val:,.0f}") self.set_label_text(self.lbl_profit, f"${total_profit:,.0f}", "#ff6b6b" if total_profit > 0 else "#51cf66") def set_label_text(self, frame, text, color=None): labels = frame.findChildren(QLabel) if len(labels) >= 2: labels[1].setText(text) if color: labels[1].setStyleSheet(f"color: {color}; font-size: 24px; font-weight: bold; border: none;")

class SelfSelectTab(QWidget): def init(self): super().init() self.monitor_thread = None self.active_portfolio = {} self.db_file = "portfolio.json" self.init_ui() self.load_portfolio()

text
def init_ui(self): layout = QVBoxLayout(self) input_group = QGroupBox("📝 編輯自選股") input_layout = QHBoxLayout(input_group) self.input_ticker = QLineEdit() self.input_ticker.setPlaceholderText("代號 (如 2330)") self.input_ticker.setFixedWidth(100) self.input_price = QDoubleSpinBox() self.input_price.setRange(0, 10000) self.input_price.setPrefix("$") self.input_price.setFixedWidth(100) self.input_shares = QSpinBox() self.input_shares.setRange(1, 1000000) self.input_shares.setValue(1000) self.input_shares.setSuffix(" 股") self.input_shares.setFixedWidth(120) btn_add = QPushButton("➕ 加入/更新") btn_add.clicked.connect(self.add_stock) btn_del = QPushButton("❌ 刪除選取") btn_del.setStyleSheet("background-color: #555; color: white;") btn_del.clicked.connect(self.remove_stock) input_layout.addWidget(QLabel("股票代號:")) input_layout.addWidget(self.input_ticker) input_layout.addWidget(QLabel("買入成本:")) input_layout.addWidget(self.input_price) input_layout.addWidget(QLabel("持有股數:")) input_layout.addWidget(self.input_shares) input_layout.addWidget(btn_add) input_layout.addWidget(btn_del) input_layout.addStretch() layout.addWidget(input_group) monitor_group = QGroupBox("🔍 監控設定") monitor_layout = QHBoxLayout(monitor_group) self.spin_stop_loss = QDoubleSpinBox() self.spin_stop_loss.setRange(-50.0, 0.0) self.spin_stop_loss.setValue(-5.0) self.spin_stop_loss.setSuffix("%") self.spin_take_profit = QDoubleSpinBox() self.spin_take_profit.setRange(0.0, 500.0) self.spin_take_profit.setValue(15.0) self.spin_take_profit.setSuffix("%") self.btn_monitor = QPushButton("▶ 開始即時監控") self.btn_monitor.setFixedSize(150, 40) self.btn_monitor.setStyleSheet("background-color: #e03131; font-weight: bold;") self.btn_monitor.clicked.connect(self.toggle_monitoring) monitor_layout.addWidget(QLabel("固定停損:")) monitor_layout.addWidget(self.spin_stop_loss) monitor_layout.addWidget(QLabel("停利區:")) monitor_layout.addWidget(self.spin_take_profit) monitor_layout.addStretch() monitor_layout.addWidget(self.btn_monitor) layout.addWidget(monitor_group) stats_group = QGroupBox("📊 自選股損益概況") stats_layout = QHBoxLayout(stats_group) self.lbl_total_value = self.create_stat_label("持倉總市值", "$0") self.lbl_total_cost = self.create_stat_label("總投入成本", "$0") self.lbl_total_profit = self.create_stat_label("未實現損益", "$0", color=True) stats_layout.addWidget(self.lbl_total_value) stats_layout.addWidget(self.lbl_total_cost) stats_layout.addWidget(self.lbl_total_profit) layout.addWidget(stats_group) self.table = QTableWidget() self.table.setColumnCount(11) self.table.setHorizontalHeaderLabels([ "代號", "名稱", "買入價", "現價", "股數", "成本", "市值", "損益%", "ATR", "動態停損", "狀態" ]) self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) layout.addWidget(self.table) def save_portfolio(self): data_list = [] for r in range(self.table.rowCount()): data_list.append({ "ticker": self.table.item(r, 0).text(), "name": self.table.item(r, 1).text(), "buy_price": float(self.table.item(r, 2).text()), "shares": int(self.table.item(r, 4).text().replace(",", "")) }) with open(self.db_file, "w", encoding="utf-8") as f: json.dump(data_list, f, ensure_ascii=False, indent=4) def load_portfolio(self): if not os.path.exists(self.db_file): return try: with open(self.db_file, "r", encoding="utf-8") as f: data_list = json.load(f) QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) for item in data_list: live = get_stock_initial_data(item['ticker']) curr_p = live['Price'] if live else item['buy_price'] atr = live.get('ATR', 0) if live else 0 self.add_to_table(item['ticker'], item['name'], item['buy_price'], curr_p, item['shares'], atr) QApplication.restoreOverrideCursor() self.recalc_totals() except Exception: QApplication.restoreOverrideCursor() def add_to_table(self, ticker, name, buy_price, current_price, shares, atr=0.0): row = self.table.rowCount() self.table.insertRow(row) cost = buy_price * shares mv = current_price * shares profit_pct = ((mv - cost) / cost * 100) if cost > 0 else 0 trail_stop = max(buy_price * (1 + self.spin_stop_loss.value() / 100), buy_price - (2.5 * atr) if atr > 0 else buy_price * (1 + self.spin_stop_loss.value() / 100)) self.table.setItem(row, 0, QTableWidgetItem(ticker)) self.table.setItem(row, 1, QTableWidgetItem(name)) self.table.setItem(row, 2, QTableWidgetItem(f"{buy_price:.2f}")) self.table.setItem(row, 3, QTableWidgetItem(f"{current_price:.2f}")) self.table.setItem(row, 4, QTableWidgetItem(f"{shares:,}")) self.table.setItem(row, 5, QTableWidgetItem(f"{cost:,.0f}")) self.table.setItem(row, 6, QTableWidgetItem(f"{mv:,.0f}")) p_item = QTableWidgetItem(f"{profit_pct:+.2f}%") p_item.setForeground(QColor("#ff6b6b" if profit_pct > 0 else "#51cf66")) self.table.setItem(row, 7, p_item) self.table.setItem(row, 8, QTableWidgetItem(f"{atr:.2f}")) self.table.setItem(row, 9, QTableWidgetItem(f"{trail_stop:.2f}")) self.table.setItem(row, 10, QTableWidgetItem("等待監控")) def add_stock(self): ticker = self.input_ticker.text().strip() cost = self.input_price.value() shares = self.input_shares.value() if not ticker: return QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) data = get_stock_initial_data(ticker) QApplication.restoreOverrideCursor() if not data: return if cost == 0: cost = data['Price'] for r in range(self.table.rowCount()): if self.table.item(r, 0).text() == data['Ticker']: self.table.removeRow(r) break self.add_to_table(data['Ticker'], data['Name'], cost, data['Price'], shares, data.get('ATR', 0)) self.recalc_totals() self.save_portfolio() self.input_ticker.clear() def remove_stock(self): row = self.table.currentRow() if row >= 0: self.table.removeRow(row) self.recalc_totals() self.save_portfolio() def toggle_monitoring(self): if self.monitor_thread and self.monitor_thread.isRunning(): self.monitor_thread.stop() self.monitor_thread.wait() self.btn_monitor.setText("▶ 開始即時監控") return if self.table.rowCount() == 0: return self.active_portfolio = {} for r in range(self.table.rowCount()): self.active_portfolio[self.table.item(r, 0).text()] = { 'buy_price': float(self.table.item(r, 2).text()), 'shares': int(self.table.item(r, 4).text().replace(",", "")), 'row': r, 'atr': float(self.table.item(r, 8).text()), 'highest_price': float(self.table.item(r, 3).text()) } self.monitor_thread = StockMonitorWorker( { t: { 'buy_price': d['buy_price'], 'atr': d['atr'], 'highest_price': d['highest_price'] } for t, d in self.active_portfolio.items() }, self.spin_stop_loss.value(), self.spin_take_profit.value() ) self.monitor_thread.update_signal.connect(self.on_price_update) self.monitor_thread.start() self.btn_monitor.setText("⏹ 停止監控") def on_price_update(self, ticker, current_price, profit_pct, status_msg): if ticker not in self.active_portfolio: return d = self.active_portfolio[ticker] r = d['row'] d['highest_price'] = max(d.get('highest_price', d['buy_price']), current_price) atr = float(d.get('atr', 0) or 0) trail_stop = max( d['buy_price'] * (1 + self.spin_stop_loss.value() / 100), d['highest_price'] - (2.5 * atr) if atr > 0 else d['buy_price'] * (1 + self.spin_stop_loss.value() / 100) ) self.table.setItem(r, 3, QTableWidgetItem(f"{current_price:.2f}")) self.table.setItem(r, 6, QTableWidgetItem(f"{current_price * d['shares']:,.0f}")) p_item = QTableWidgetItem(f"{profit_pct:+.2f}%") p_item.setForeground(QColor("#ff6b6b" if profit_pct > 0 else "#51cf66")) self.table.setItem(r, 7, p_item) self.table.setItem(r, 9, QTableWidgetItem(f"{trail_stop:.2f}")) self.table.setItem(r, 10, QTableWidgetItem(status_msg)) self.recalc_totals() def recalc_totals(self): v = 0 c = 0 for r in range(self.table.rowCount()): try: shares = int(self.table.item(r, 4).text().replace(",", "")) v += shares * float(self.table.item(r, 3).text()) c += shares * float(self.table.item(r, 2).text()) except Exception: pass self.set_lbl(self.lbl_total_value, f"${v:,.0f}") self.set_lbl(self.lbl_total_cost, f"${c:,.0f}") self.set_lbl(self.lbl_total_profit, f"${v-c:,.0f}", "#ff6b6b" if v-c > 0 else "#51cf66") def set_lbl(self, frame, text, color=None): ls = frame.findChildren(QLabel) if len(ls) >= 2: ls[1].setText(text) if color: ls[1].setStyleSheet(f"color: {color}; font-size: 24px; font-weight: bold; border: none;") def create_stat_label(self, title, default_val, color=False): frame = QFrame() frame.setStyleSheet("background-color: #333; border-radius: 5px;") vbox = QVBoxLayout(frame) tl = QLabel(title) tl.setStyleSheet("color: #aaa; font-size: 12px; border: none;") vl = QLabel(default_val) vl.setStyleSheet("color: white; font-size: 18px; font-weight: bold; border: none;") vl.setAlignment(Qt.AlignmentFlag.AlignCenter) vbox.addWidget(tl) vbox.addWidget(vl) if color: vl.setObjectName("color_label") return frame

class StockAnalyzerApp(QMainWindow): def init(self): super().init() self.setWindowTitle("AI 電子族群全掃描 & 智能監控系統") self.resize(1500, 900) self.stock_data_cache = {} self.init_ui() self.apply_stylesheet()

text
def init_ui(self): main_widget = QWidget() self.setCentralWidget(main_widget) main_layout = QVBoxLayout(main_widget) self.tabs = QTabWidget() main_layout.addWidget(self.tabs) self.tab_scan = QWidget() self.setup_scan_tab() self.tabs.addTab(self.tab_scan, "🔍 市場掃描") self.tab_trading = SimulatedTradingTab() self.tabs.addTab(self.tab_trading, "💹 AI 下單與即時監控") self.tab_self_select = SelfSelectTab() self.tabs.addTab(self.tab_self_select, "📝 我的自選股") def setup_scan_tab(self): layout = QVBoxLayout(self.tab_scan) top_bar = QHBoxLayout() self.btn_scan = QPushButton("🚀 開始掃描") self.btn_scan.setFixedSize(180, 40) self.btn_scan.clicked.connect(self.start_scan) strategy_group = QGroupBox("選擇策略") strategy_layout = QHBoxLayout(strategy_group) self.cb_basic = QCheckBox("基本") self.cb_quant = QCheckBox("量化") self.cb_flow = QCheckBox("資金") self.cb_pattern = QCheckBox("形態") self.cb_sentiment = QCheckBox("情緒") for cb in [self.cb_basic, self.cb_quant, self.cb_flow, self.cb_pattern, self.cb_sentiment]: cb.setChecked(True) strategy_layout.addWidget(cb) self.lbl_status = QLabel("準備就緒") self.progress_bar = QProgressBar() top_bar.addWidget(self.btn_scan) top_bar.addWidget(strategy_group) top_bar.addWidget(self.lbl_status) top_bar.addWidget(self.progress_bar) layout.addLayout(top_bar) splitter = QSplitter(Qt.Orientation.Horizontal) self.table = QTableWidget() self.table.setColumnCount(8) self.table.setHorizontalHeaderLabels(["代號", "名稱", "現價", "漲跌%", "ATR", "動態停損", "策略分析與建議", "評分"]) header = self.table.horizontalHeader() header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(5, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(6, QHeaderView.ResizeMode.Stretch) header.setSectionResizeMode(7, QHeaderView.ResizeMode.ResizeToContents) self.table.setWordWrap(True) self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) self.table.cellClicked.connect(self.on_table_click) splitter.addWidget(self.table) self.chart_widget = QWidget() self.chart_layout = QVBoxLayout(self.chart_widget) self.lbl_current_price = QLabel("") self.lbl_price_change = QLabel("") info_box = QHBoxLayout() info_box.addWidget(self.lbl_current_price) info_box.addWidget(self.lbl_price_change) info_box.addStretch() self.chart_layout.addLayout(info_box) self.figure = Figure(figsize=(10, 8), facecolor='#2b2b2b') self.canvas = FigureCanvas(self.figure) self.chart_layout.addWidget(self.canvas) splitter.addWidget(self.chart_widget) splitter.setStretchFactor(0, 6) splitter.setStretchFactor(1, 4) layout.addWidget(splitter) def apply_stylesheet(self): self.setStyleSheet(""" QMainWindow, QWidget { background-color: black; color: #e0e0e0; } QTabWidget::pane { border: 1px solid #444; background-color: black; } QTabBar::tab { background: black; color: #aaa; padding: 10px 20px; border: 1px solid #333; } QTabBar::tab:selected { background: #222; color: white; border-top: 3px solid #0d6efd; } QTableWidget { background-color: black; gridline-color: #333; color: white; } QHeaderView::section { background-color: #111; color: white; padding: 5px; } QPushButton { background-color: #0d6efd; color: white; border-radius: 5px; font-weight: bold; } QProgressBar { text-align: center; background-color: #111; color: white; } QProgressBar::chunk { background-color: #0d6efd; } QLineEdit, QSpinBox, QDoubleSpinBox { background-color: #111; color: white; border: 1px solid #555; } """) def start_scan(self): strategies = [] if self.cb_basic.isChecked(): strategies.append('basic') if self.cb_quant.isChecked(): strategies.append('quant') if self.cb_flow.isChecked(): strategies.append('flow') if self.cb_pattern.isChecked(): strategies.append('pattern') if self.cb_sentiment.isChecked(): strategies.append('sentiment') self.btn_scan.setEnabled(False) self.btn_scan.setText("掃描中...") self.table.setRowCount(0) self.stock_data_cache = {} self.worker = StockScanWorker(strategies) self.worker.progress_signal.connect(self.update_progress) self.worker.result_signal.connect(self.add_result) self.worker.finished_signal.connect(self.scan_finished) self.worker.start() def update_progress(self, current, total, text): self.lbl_status.setText(text) if total > 0: self.progress_bar.setValue(int((current / total) * 100)) def add_result(self, data): row = self.table.rowCount() self.table.insertRow(row) score = data.get('Score', 0) if score >= 9.5: rec, rec_color, bold = "🔥 強勢突破", "#ff3333", True elif score >= 8.8: rec, rec_color, bold = "✅ 順勢關注", "#ff8c00", True else: rec, rec_color, bold = "🔍 觀察", "#aaaaaa", False self.table.setItem(row, 0, QTableWidgetItem(data['Ticker'])) name_item = QTableWidgetItem(data['Name']) if bold: f = name_item.font() f.setBold(True) name_item.setFont(f) self.table.setItem(row, 1, name_item) p_item = QTableWidgetItem(f"{data['Price']:.2f}") p_color = "#ff6b6b" if data['Change'] > 0 else "#51cf66" p_item.setForeground(QColor(p_color)) self.table.setItem(row, 2, p_item) c_item = QTableWidgetItem(f"{data['Change']:+.2f}%") c_item.setForeground(QColor(p_color)) self.table.setItem(row, 3, c_item) self.table.setItem(row, 4, QTableWidgetItem(f"{data.get('ATR', 0):.2f}")) self.table.setItem(row, 5, QTableWidgetItem(f"{data.get('TrailStop', 0):.2f}")) strat_item = QTableWidgetItem(f"{rec} | {data['Strategy']}") strat_item.setToolTip(f"{rec} | {data['Strategy']}") strat_item.setForeground(QColor(rec_color)) if bold: f = strat_item.font() f.setBold(True) strat_item.setFont(f) self.table.setItem(row, 6, strat_item) s_item = QTableWidgetItem(f"{score:.1f}") s_item.setForeground(QColor(rec_color)) self.table.setItem(row, 7, s_item) self.stock_data_cache[row] = data def scan_finished(self, msg): self.btn_scan.setEnabled(True) self.btn_scan.setText("🚀 開始掃描") self.tab_trading.update_data(self.stock_data_cache) QMessageBox.information(self, "完成", msg) def on_table_click(self, row, col): if row in self.stock_data_cache: self.plot_chart(self.stock_data_cache[row]) def plot_chart(self, data): self.figure.clear() df = data['Data'].copy() mc = mpf.make_marketcolors(up='#ff4d4d', down='#00cc66', inherit=True) s = mpf.make_mpf_style(marketcolors=mc, gridstyle='--', facecolor='#2b2b2b', edgecolor='white', figcolor='#2b2b2b') ax1 = self.figure.add_subplot(2, 1, 1) ax2 = self.figure.add_subplot(2, 1, 2, sharex=ax1) if 'MA20' not in df.columns: df['MA20'] = df['Close'].rolling(20).mean() if 'MA60' not in df.columns: df['MA60'] = df['Close'].rolling(60).mean() apds = [ mpf.make_addplot(df['MA20'], ax=ax1, color='orange'), mpf.make_addplot(df['MA60'], ax=ax1, color='cyan') ] mpf.plot(df.tail(120), type='candle', style=s, ax=ax1, volume=ax2, addplot=apds) ax1.set_title(f"{data['Ticker']} {data['Name']}", color='white') self.canvas.draw() self.lbl_current_price.setText(f"現價: {data['Price']}") self.lbl_price_change.setText(f"漲跌: {data['Change']:+.2f}% | ATR: {data.get('ATR', 0):.2f}")

if name == 'main': app = QApplication(sys.argv) app.setFont(QFont("Microsoft JhengHei", 10)) window = StockAnalyzerApp() window.show() sys.exit(app.exec()) PY python3 -m py_compile /tmp/main_V02.py && echo '[OK] compiled main_V02.py'

Handling file links

I might need to provide file links to the user. It could be simpler to attach generated files from a temporary location and move them to a more accessible folder like /mnt/data. I'll copy the files there and assign them appropriate names for easy access.

bash -lc cp /tmp/main_V02.py /mnt/data/main_V02.py && cp /tmp/stock_logic_V02.py /mnt/data/stock_logic_V02.py && ls -l /mnt/data/main_V02.py /mnt/data/stock_logic_V02.py

Summarizing strategy and citations

I'll provide a final answer with citations clarifying that there's no universally highest-profit strategy. Momentum/trend-following, 52-week highs, and profitability are robust in the literature. I'll highlight our multi-factor approach, but note the missing quant/pattern/sentiment components. Also, I'll mention compile success, and the caveat "no guaranteed profit, backtest first."

Thought for 8m 11s

Ответ

我幫你改成一版比較像「可系統化交易」的版本了,不再只是幾個條件硬拼。先講結論:市場上沒有一個被普遍證明「永遠獲利最高」的單一策略,但學術與實務上被反覆驗證、而且最適合你這種掃描器架構的,通常是 趨勢追蹤 / 動能、52 週高點突破、再加上嚴格風險控管;動能與趨勢追蹤長期有證據,52 週高效應在多國市場也有研究支持,而把絕對動能加進來通常有助於降低波動與回撤。(NBER)

你原本程式有幾個關鍵問題:

  1. quant / pattern / sentiment 勾選存在,但 analyze_stock() 沒有完整接進去,所以 UI 與實際策略不一致。
  2. 原本主策略偏向「單日條件篩選」,比較不像可持續回測的交易系統。
  3. 監控只用固定停損/停利,沒有移動停損,容易在趨勢股提前被洗掉,或獲利回吐太多。
  4. 只抓 6mo 歷史資料,卻想做較強的中期趨勢判斷,不夠穩。

我這次改成的核心邏輯是:

  • 掃描改抓 1 年資料,加入 MA20/60/120ROC20/60/12052 週高點距離ATR20量能/成交值濾網
  • 新增 核心策略:多因子趨勢動能 + 52 週高點突破
  • 補齊你 UI 上已經有但沒真的跑的策略:quantpatternsentiment
  • 監控改成 固定停損 + ATR 移動停損
  • 主畫面與持倉畫面新增 ATR / 動態停損 欄位

這種修改方向,是因為動能、趨勢追蹤、52 週高點效應都比你原本的「單日漲幅 + 漲停史 + 量比」更接近被反覆研究過的可量化框架;至於像「獲利因子」也有很強文獻,但你現在的資料源主要是 yfinance 價量資料,沒有穩定的台股基本面欄位,所以這版先不硬加財報因子,避免速度慢又不穩。(NBER)

檔案我已經整理好了:

下載主程式 main_V02.py
下載副程式 stock_logic_V02.py

你直接用法有兩種:

  • 直接把這兩個檔案放在同一資料夾,執行 main_V02.py
  • 或把 stock_logic_V02.py 內容覆蓋你原本的 stock_logic_V01.py,再把主程式的 import 保持一致

這版最重要的修改點,在副程式裡是這些:

python
# 核心:多因子趨勢動能(最優先) core = self.composite_momentum_breakout_analysis(ticker, name, df) if 'flow' in self.strategies: res = self.institutional_flow_analysis(ticker, name, df) if 'basic' in self.strategies: res = self.basic_strategy_analysis(ticker, name, df) if 'quant' in self.strategies: res = self.advanced_quantitative_analysis(ticker, name, df) if 'pattern' in self.strategies: res = self.volatility_contraction_breakout_analysis(ticker, name, df) if 'sentiment' in self.strategies: res = self.market_sentiment_analysis(ticker, name, df)

核心新策略大意是:

python
# 趨勢濾網 curr['Close'] > curr['MA20'] > curr['MA60'] > curr['MA120'] # 中期動能為正 curr['ROC60'] > 0 and curr['ROC120'] > 0 # 52週高點 / 55日突破 curr['Close'] >= curr['HH55_PREV'] or curr['DIST_52W_HIGH'] >= 95 # 量能確認 vol_ratio >= 1.2 # 波動過高則排除 curr['ATR_PCT'] <= 9.5

監控邏輯也改成了:

python
fixed_stop = buy_price * (1 + self.stop_loss_pct / 100) trailing_stop = highest_price - 2.5 * atr effective_stop = max(fixed_stop, trailing_stop)

這樣比單純「跌 5% 就賣、漲 10% 就賣」合理,因為趨勢股常常需要讓利潤奔跑;研究上,趨勢追蹤本來就仰賴「截斷虧損、延長獲利」,而不是固定獲利了結。(AQR Capital Management)

再提醒你一個實務重點:這版是把你的掃描器改成比較像樣的「候選股 + 風控」系統,但還不是完整驗證過的交易系統。真正要知道在台股電子/機電族群是否有效,下一步最值得做的是補一個 回測模組,至少檢查勝率、平均盈虧比、最大回撤、年化報酬。沒有回測,任何「最賺策略」都只是猜測。

Вопрос

(SBIR_2025) $ py stock_main_V02.py
Traceback (most recent call last):
File "D:\venv\SBIR_2025\stock_main_V02.py", line 683, in on_table_click
self.plot_chart(self.stock_data_cache[row])
File "D:\venv\SBIR_2025\stock_main_V02.py", line 700, in plot_chart
mpf.plot(df.tail(120), type='candle', style=s, ax=ax1, volume=ax2, addplot=apds)
File "D:\venv\SBIR_2025\lib\site-packages\mplfinance\plotting.py", line 808, in plot
ax = _addplot_columns(panid,panels,ydata,apdict,xdates,config,colcount)
File "D:\venv\SBIR_2025\lib\site-packages\mplfinance\plotting.py", line 1151, in _addplot_columns
ax.plot(xdates,ydata,linestyle=ls,color=color,linewidth=width,alpha=alpha,label=label)
File "D:\venv\SBIR_2025\lib\site-packages\matplotlib\axes_axes.py", line 1779, in plot
lines = [*self._get_lines(self, *args, data=data, **kwargs)]
File "D:\venv\SBIR_2025\lib\site-packages\matplotlib\axes_base.py", line 296, in call
yield from self._plot_args(
File "D:\venv\SBIR_2025\lib\site-packages\matplotlib\axes_base.py", line 486, in _plot_args
raise ValueError(f"x and y must have same first dimension, but "
ValueError: x and y must have same first dimension, but have shapes (120,) and (123,)

Мысль

我已經把根因縮小到 plot_chart() 了:mplfinanceaddplot 資料長度必須和傳給 mpf.plot() 的 OHLC 資料長度一致,而且它用的是主資料的 x 軸,所以就算 Series 有自己的 index,也不能比主資料多幾筆。你這次是主圖用了 tail(120),但均線/停損線仍拿完整 df 去畫,才會變成 (120,)(123,)。 (GitHub)

Thought for 44s

Ответ

plot_chart() 改掉就能解。

你現在的錯誤原因是:

  • 主 K 線用的是 df.tail(120)
  • addplot 很可能還在用完整 df['MA20']df['MA60']df['TrailingStop'] 之類的欄位
  • mplfinance 要求 addplot 的資料筆數必須和 mpf.plot() 的主資料完全一致;而且就算你傳 Series/DataFrame,它實際上仍共用主圖的 x 軸,所以長度不一致就會直接報這個錯。 (GitHub)

你直接把主程式裡的 plot_chart() 換成下面這版:

python
def plot_chart(self, data): self.figure.clear() df = data['Data'].copy() if df.empty: return # 先在完整 df 上算指標,再切最後 120 根來畫 if 'MA20' not in df.columns: df['MA20'] = df['Close'].rolling(20).mean() if 'MA60' not in df.columns: df['MA60'] = df['Close'].rolling(60).mean() if 'MA120' not in df.columns: df['MA120'] = df['Close'].rolling(120).mean() # 若你有動態停損線/追蹤停損線,也在這裡先補欄位 if 'TrailStop' not in df.columns: df['TrailStop'] = np.nan # 重點:主圖資料與 addplot 資料一定要用同一個 plot_df plot_df = df.tail(120).copy() mc = mpf.make_marketcolors(up='#ff4d4d', down='#00cc66', inherit=True) s = mpf.make_mpf_style( marketcolors=mc, gridstyle='--', facecolor='#2b2b2b', edgecolor='white', figcolor='#2b2b2b' ) ax1 = self.figure.add_subplot(2, 1, 1) ax2 = self.figure.add_subplot(2, 1, 2, sharex=ax1) apds = [ mpf.make_addplot(plot_df['MA20'], ax=ax1, color='orange', width=1.0), mpf.make_addplot(plot_df['MA60'], ax=ax1, color='deepskyblue', width=1.0), mpf.make_addplot(plot_df['MA120'], ax=ax1, color='violet', width=1.0), ] # 有值才加,避免全 NaN 也硬畫 if plot_df['TrailStop'].notna().any(): apds.append( mpf.make_addplot(plot_df['TrailStop'], ax=ax1, color='yellow', width=1.0) ) mpf.plot( plot_df, type='candle', style=s, ax=ax1, volume=ax2, addplot=apds ) ax1.set_title(f"{data['Ticker']} {data['Name']}", color='white') self.canvas.draw() self.lbl_current_price.setText(f"現價: {data['Price']:.2f}") self.lbl_price_change.setText(f"漲跌: {data['Change']:+.2f}%")

如果你有畫買賣訊號點,也要同樣改成用 plot_df,不能混用完整 df。例如:

python
buy_signal = np.where(plot_df['Close'] > plot_df['MA20'], plot_df['Low'] * 0.98, np.nan) apds.append( mpf.make_addplot(buy_signal, ax=ax1, type='scatter', marker='^', markersize=50) )

不要這樣寫:

python
buy_signal = np.where(df['Close'] > df['MA20'], df['Low'] * 0.98, np.nan) # 錯

再補一個更穩的寫法:如果你想畫最後 120 根,所有要進 addplot 的欄位都統一從 plot_df 取;如果你想省事,也可以乾脆整張圖都用完整 df,那 addplot 就也用完整 df。原則只有一個:主圖幾筆,addplot 就幾筆。 (GitHub)

你這個錯誤幾乎可以確定不是別的地方造成,而是 plot_chart() 內的資料長度不一致。改完後再點表格應該就不會炸了。

如果你要,我下一則可以直接把你整份 stock_main_V02.pyplot_chart() 和相關欄位一起整理成可直接貼上的完整版本。

Вопрос

Traceback (most recent call last):
File "D:\venv\SBIR_2025\stock_main_V02.py", line 683, in on_table_click
self.plot_chart(self.stock_data_cache[row])
File "D:\venv\SBIR_2025\stock_main_V02.py", line 702, in plot_chart
df['TrailStop'] = np.nan
NameError: name 'np' is not defined

Мысль

Thought for 5s

Ответ

這是因為你在主程式的 plot_chart() 裡用了 np.nan,但主程式最上面沒有:

python
import numpy as np

你直接在 stock_main_V02.py 開頭 imports 區加這行就好:

python
import sys import mplfinance as mpf import warnings import json import os import numpy as np

或者不想多載入 numpy,也可以把這行:

python
df['TrailStop'] = np.nan

改成:

python
df['TrailStop'] = float('nan')

我建議用第一種,因為之後你如果還要做訊號點、條件欄位、向量化運算,numpy 幾乎一定還會用到。

順手再檢查一次主程式有沒有這類寫法:

python
np.where(...) np.nan np.array(...)

只要有,就一定要在主程式 import numpy as np

這個修完後,如果還有下一個 traceback,貼上來我直接幫你連續修到能跑。

Поделиться этим Q&A