Skill: jcsoftear/portfolio-analysis-pro (version 1.0.0)

--- name: portfolio-analysis description: 股票持仓分析系统，用于管理股票投资组合，实时更新价格，分析盈亏，生成报告。使用当用户需要管理股票持仓、查看实时价格、分析盈亏情况、生成投资报告时。 version: 1.0.0 icon: 📊 metadata: clawdbot: emoji: 📊 requires: bins: ["python"] install: - id: python-deps kind: pip packages: - requests - flask label: Install Python dependencies for portfolio analysis --- # 股票持仓分析 Skill 提供股票投资组合管理、实时价格更新、盈亏分析和报告生成功能。 ## 功能说明 本skill提供以下功能： 1. **持仓管理** - 添加、编辑、删除股票持仓记录，支持股票代码、名称、持仓数量、成本价的完整编辑 2. **实时价格更新** - 手动或自动更新持仓价格，支持多数据源，自动更新仅在交易时间执行 3. **盈亏分析** - 计算总市值、总成本、总盈亏、盈亏比例、个股盈亏、仓位占比等 4. **操作记录** - 自动记录所有操作历史，支持查看和追溯 5. **报告生成** - 生成Markdown格式的持仓报告，支持下载导出 6. **股票价格查询** - 查询单只股票的实时价格信息 ## 使用方法 ### 1. 启动系统 ```python from portfolio_system import PortfolioManager, start_server # 启动Web服务器 start_server(host='0.0.0.0', port=5000, auto_update=True) ``` ### 2. 使用API接口 ```python import requests # 获取持仓数据 response = requests.get('http://localhost:5000/api/portfolio') data = response.json() # 更新价格 response = requests.post('http://localhost:5000/api/portfolio/update') # 添加持仓 response = requests.post('http://localhost:5000/api/portfolio/add', json={ 'symbol': '600519', 'name': '贵州茅台', 'quantity': 100, 'cost_price': 1800.00 }) # 查询股票价格 response = requests.get('http://localhost:5000/api/stock/price/600519') ``` ### 3. 使用PortfolioManager类 ```python from portfolio_system import PortfolioManager manager = PortfolioManager('portfolio.db') # 添加持仓 result = manager.add_holding('600519', '贵州茅台', 100, 1800.00) # 获取持仓数据 portfolio = manager.get_portfolio_data() # 更新所有价格 result = manager.update_all_prices() # 生成报告 result = manager.generate_report() ``` ## API接口说明 | 接口 | 方法 | 说明 | |------|------|------| | `/api/portfolio` | GET | 获取持仓数据 | | `/api/portfolio/update` | POST | 手动更新所有价格 | | `/api/portfolio/add` | POST | 添加持仓 | | `/api/portfolio/edit/<symbol>` | PUT | 编辑单个字段 | | `/api/portfolio/edit-multiple/<symbol>` | PUT | 批量编辑持仓 | | `/api/portfolio/delete/<symbol>` | DELETE | 删除持仓 | | `/api/portfolio/logs` | GET | 获取操作记录 | | `/api/portfolio/report` | GET | 生成持仓报告 | | `/api/portfolio/export` | GET | 下载报告文件 | | `/api/stock/price/<symbol>` | GET | 查询单只股票价格 | ## 依赖项 - requests - flask 安装依赖： ```bash pip install requests flask ``` ## 交易时间 自动更新仅在以下时间段执行： - 工作日（周一到周五） - 上午：9:30 - 11:30 - 下午：13:00 - 15:00 ## 股票代码格式 - 上海股票：6位数字代码（如 600519） - 深圳股票：6位数字代码（如 000001） - 创业板：300开头（如 300750） ## 数据精度 所有价格相关数据支持4位小数精度，包括： - 成本价 - 当前价 - 盈亏额 - 盈亏百分比 ## 颜色标识 - 红色：代表盈利 - 绿色：代表亏损 ## 排序功能 持仓列表支持按以下字段排序： - 股票名称 - 股票代码 - 市值 - 盈亏额 - 盈亏百分比

path: .gitignore
content: __pycache__/holding_manager.cpython-312.pyc
__pycache__/llm_manager.cpython-312.pyc


path: holding_manager.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
持仓管理器 - 负责股票持仓的核心业务逻辑
"""

import sqlite3
import requests
import json
import os
import threading
import time
from datetime import datetime

# 模拟浏览器的headers
REQUEST_HEADERS = {
    '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',
    'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8',
    'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
    'Accept-Encoding': 'gzip, deflate, br',
    'Connection': 'keep-alive',
    'Upgrade-Insecure-Requests': '1',
    'Cache-Control': 'max-age=0'
}

class HoldingManager:
    """持仓管理器 - 核心业务逻辑"""
    
    def __init__(self, db_path='portfolio.db'):
        self.db_path = db_path
        self.auto_update_thread = None
        self.auto_update_running = False
    
    def init_database(self):
        """初始化数据库"""
        conn = sqlite3.connect(self.db_path)
        cursor = conn.cursor()
        
        # 创建持仓表
        cursor.execute('''
        CREATE TABLE IF NOT EXISTS holdings (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            symbol TEXT NOT NULL UNIQUE,
            name TEXT NOT NULL,
            quantity INTEGER NOT NULL,
            cost_price REAL NOT NULL,
            current_price REAL NOT NULL,
            last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        )
        ''')
        
        # 创建操作记录表
        cursor.execute('''
        CREATE TABLE IF NOT EXISTS operation_log (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            operation_type TEXT NOT NULL,
            symbol TEXT NOT NULL,
            details TEXT,
            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        )
        ''')
        
        conn.commit()
        conn.close()
    
    def get_stock_price(self, symbol):
        """获取股票实时价格 - 支持多个数据源"""
        try:
            # 判断市场
            if symbol.startswith('6'):
                market = 'sh'
            elif symbol.startswith('0') or symbol.startswith('3'):
                market = 'sz'
            elif symbol.startswith('688'):
                market = 'sh'
            elif symbol.startswith('301'):
                market = 'sz'
            elif symbol.startswith('512'):
                market = 'sh'
            elif symbol.startswith('000'):
                market = 'sz'
            elif symbol.startswith('1'):
                market = 'sz'
            else:
                market = 'sh'
            
            # 尝试新浪财经API
            url = f'https://hq.sinajs.cn/list={market}{symbol}'
            response = requests.get(url, headers=REQUEST_HEADERS, timeout=10)
            
            if response.status_code == 200:
                content = response.content.decode('gbk')
                if '"' in content:
                    data_str = content.split('"')[1]
                    values = data_str.split(',')
                    
                    if len(values) >= 32 and values[3]:
                        return {
                            'price': float(values[3]),
                            'name': values[0],
                            'source': '新浪财经'
                        }
            
            # 如果新浪失败，尝试腾讯API
            code = f'{market}{symbol}'
            url = f'https://qt.gtimg.cn/q={code}'
            response = requests.get(url, headers=REQUEST_HEADERS, timeout=10)
            # print('response', response.text)
            if response.status_code == 200:
                text = response.text
                if '=' in text:
                    parts = text.split('=')
                    if len(parts) > 1:
                        values = parts[1].strip('"').split('~')
                        if len(values) >= 4 and values[3]:
                            return {
                                'price': float(values[3]),
                                'name': values[1],
                                'source': '腾讯财经'
                            }
            
            return None
            
        except Exception as e:
            print(f"获取价格失败 {symbol}: {e}")
            return None
    
    def get_stock_detail(self, symbol):
        """获取股票详细信息，包括涨跌和分时图数据"""
        try:
            # 判断市场
            if symbol.startswith('6'):
                market = 'sh'
            elif symbol.startswith('0') or symbol.startswith('3'):
                market = 'sz'
            elif symbol.startswith('688'):
                market = 'sh'
            elif symbol.startswith('301'):
                market = 'sz'
            elif symbol.startswith('512'):
                market = 'sh'
            elif symbol.startswith('000'):
                market = 'sz'
            elif symbol.startswith('1'):
                market = 'sz'
            else:
                market = 'sh'
            
            # 尝试新浪财经API获取详细数据
            url = f'https://hq.sinajs.cn/list={market}{symbol}'
            response = requests.get(url, headers=REQUEST_HEADERS, timeout=10)
            
            detail_data = None
            
            if response.status_code == 200:
                content = response.content.decode('gbk')
                if '"' in content:
                    data_str = content.split('"')[1]
                    values = data_str.split(',')
                    
                    if len(values) >= 32:
                        detail_data = {
                            'symbol': symbol,
                            'name': values[0],
                            'open': float(values[1]) if values[1] else 0,
                            'pre_close': float(values[2]) if values[2] else 0,
                            'current': float(values[3]) if values[3] else 0,
                            'high': float(values[4]) if values[4] else 0,
                            'low': float(values[5]) if values[5] else 0,
                            'source': '新浪财经'
                        }
            
            # 如果新浪失败，尝试腾讯API
            if not detail_data:
                code = f'{market}{symbol}'
                url = f'https://qt.gtimg.cn/q={code}'
                response = requests.get(url, headers=REQUEST_HEADERS, timeout=10)
                if response.status_code == 200:
                    text = response.text
                    if '=' in text:
                        parts = text.split('=')
                        if len(parts) > 1:
                            values = parts[1].strip('"').split('~')
                            if len(values) >= 40:
                                detail_data = {
                                    'symbol': symbol,
                                    'name': values[1],
                                    'open': float(values[5]) if values[5] else 0,
                                    'pre_close': float(values[4]) if values[4] else 0,
                                    'current': float(values[3]) if values[3] else 0,
                                    'high': float(values[33]) if values[33] else 0,
                                    'low': float(values[34]) if values[34] else 0,
                                    'source': '腾讯财经'
                                }
            
            if detail_data:
                # 计算涨跌
                change = detail_data['current'] - detail_data['pre_close']
                change_percent = (change / detail_data['pre_close'] * 100) if detail_data['pre_close'] != 0 else 0
                detail_data['change'] = change
                detail_data['change_percent'] = change_percent
                
                # 获取分时图数据
                detail_data['min_data'] = self.get_minute_data(market, symbol)
            
            return detail_data
            
        except Exception as e:
            print(f"获取股票详情失败 {symbol}: {e}")
            return None
    
    def get_minute_data(self, market, symbol):
        """获取分时图数据"""
        try:
            # 获取今天的日期
            today = datetime.now().strftime('%Y-%m-%d')
            
            # 使用新浪财经分钟级K线API（5分钟）
            url = 'https://money.finance.sina.com.cn/quotes_service/api/json_v2.php/CN_MarketData.getKLineData'
            params = {
                'symbol': f'{market}{symbol}',
                'scale': '5',
                'datalen': '320',
                'ma': 'no'
            }
            
            response = requests.get(url, headers=REQUEST_HEADERS, params=params, timeout=10)
            
            if response.status_code == 200:
                text = response.text
                # 解析返回的数据
                try:
                    # 清理可能的格式问题
                    if text.startswith('('):
                        text = text[1:]
                    if text.endswith(')'):
                        text = text[:-1]
                    
                    import json
                    kline_data = json.loads(text)
                    
                    if kline_data and len(kline_data) > 0:
                        # 先按日期分组收集所有数据
                        data_by_date = {}
                        for kline in kline_data:
                            if 'day' in kline:
                                day_str = kline['day']
                                if len(day_str) >= 16:
                                    date_part = day_str[:10]
                                    time_part = day_str[11:16]
                                    hour = int(time_part[:2])
                                    minute = int(time_part[3:])
                                    
                                    # 只保留交易时间的数据（9:30-15:00）
                                    if (hour > 9 or (hour == 9 and minute >= 30)) and hour < 15:
                                        if date_part not in data_by_date:
                                            data_by_date[date_part] = []
                                        data_by_date[date_part].append({
                                            'time': time_part,
                                            'kline': kline
                                        })
                        
                        # 查找目标日期（今天或最近的有数据日期）
                        target_date = None
                        if today in data_by_date and data_by_date[today]:
                            target_date = today
                        else:
                            # 如果没有今天的数据，找到最近的有数据的日期
                            available_dates = sorted(data_by_date.keys(), reverse=True)
                            if available_dates:
                                target_date = available_dates[0]
                                print(f'今天({today})无数据，使用最近有数据的日期: {target_date}')
                        
                        # 如果找到了目标日期且有数据，提取分时数据
                        if target_date and target_date in data_by_date and data_by_date[target_date]:
                            # 提取目标日期的分时数据
                            minute_data = {
                                'times': [],
                                'prices': [],
                                'volumes': [],
                                'opens': [],
                                'highs': [],
                                'lows': []
                            }
                            
                            # 按时间排序数据
                            for item in sorted(data_by_date[target_date], key=lambda x: x['time']):
                                kline = item['kline']
                                time_part = item['time']
                                
                                minute_data['times'].append(time_part)
                                
                                # 提取价格（使用close）
                                if 'close' in kline and kline['close']:
                                    minute_data['prices'].append(float(kline['close']))
                                elif 'open' in kline and kline['open']:
                                    minute_data['prices'].append(float(kline['open']))
                                else:
                                    minute_data['prices'].append(0)
                                
                                # 提取成交量
                                if 'volume' in kline and kline['volume']:
                                    minute_data['volumes'].append(int(kline['volume']))
                                else:
                                    minute_data['volumes'].append(0)
                                
                                # 提取其他K线数据
                                if 'open' in kline and kline['open']:
                                    minute_data['opens'].append(float(kline['open']))
                                if 'high' in kline and kline['high']:
                                    minute_data['highs'].append(float(kline['high']))
                                if 'low' in kline and kline['low']:
                                    minute_data['lows'].append(float(kline['low']))
                            
                            if minute_data['times'] and minute_data['prices']:
                                return minute_data
                
                except Exception as e:
                    print(f'解析K线数据失败: {e}')
            
            # 如果新浪K线失败，尝试原来的分时数据API
            url = f'https://hq.sinajs.cn/list={market}{symbol}_min'
            response = requests.get(url, headers=REQUEST_HEADERS, timeout=10)
            
            if response.status_code == 200:
                content = response.content.decode('gbk')
                if '"' in content:
                    data_str = content.split('"')[1]
                    if data_str:
                        values = data_str.split(',')
                        
                        if len(values) >= 1:
                            # 解析分时数据
                            minute_data = {
                                'times': [],
                                'prices': [],
                                'volumes': []
                            }
                            
                            # 新浪数据格式：价格,均价,成交量,时间
                            for i in range(0, len(values), 4):
                                if i + 3 < len(values) and values[i]:
                                    time_str = values[i + 3]
                                    if time_str:
                                        minute_data['times'].append(time_str)
                                        minute_data['prices'].append(float(values[i]) if values[i] else 0)
                                        minute_data['volumes'].append(int(values[i + 2]) if values[i + 2] else 0)
                            
                            if minute_data['times']:
                                return minute_data
            
            # 如果新浪失败，尝试腾讯财经分时图
            url = f'https://web.ifzq.gtimg.cn/appstock/app/fqkline/get?_var=kline_min&param={market}{symbol},min,86400,1'
            response = requests.get(url, headers=REQUEST_HEADERS, timeout=10)
            
            if response.status_code == 200:
                text = response.text
                if '=' in text:
                    json_str = text.split('=', 1)[1]
                    try:
                        import json
                        data = json.loads(json_str)
                        if data.get('data') and data['data'].get(market + symbol):
                            kline_data = data['data'][market + symbol]['min']
                            if kline_data:
                                # 先按日期分组收集所有数据
                                data_by_date = {}
                                for kline in kline_data:
                                    if len(kline) >= 6:
                                        time_str = kline[0]
                                        if time_str and len(time_str) >= 16:
                                            date_part = time_str[:10]
                                            time_part = time_str[11:16]
                                            
                                            if date_part not in data_by_date:
                                                data_by_date[date_part] = []
                                            data_by_date[date_part].append({
                                                'time': time_part,
                                                'kline': kline
                                            })
                                
                                # 检查是否有今天的数据
                                target_date = today
                                if today not in data_by_date or not data_by_date[today]:
                                    # 如果没有今天的数据，找到最近的有数据的日期
                                    available_dates = sorted(data_by_date.keys(), reverse=True)
                                    if available_dates:
                                        target_date = available_dates[0]
                                        print(f'今天({today})无数据，使用最近有数据的日期: {target_date}')
                                    else:
                                        # 如果没有任何数据，继续尝试其他API
                                        target_date = None
                                
                                # 确保目标日期存在数据
                                if target_date in data_by_date and data_by_date[target_date]:
                                    # 提取目标日期的分时数据
                                    minute_data = {
                                        'times': [],
                                        'prices': [],
                                        'volumes': []
                                    }
                                    
                                    # 按时间排序数据
                                    for item in sorted(data_by_date[target_date], key=lambda x: x['time']):
                                        kline = item['kline']
                                        time_part = item['time']
                                        
                                        minute_data['times'].append(time_part)
                                        minute_data['prices'].append(float(kline[1]) if kline[1] else 0)
                                        minute_data['volumes'].append(int(kline[5]) if kline[5] else 0)
                                    
                                    if minute_data['times']:
                                        return minute_data
                    except Exception as e:
                        print(f'解析腾讯分时数据失败: {e}')
                        pass
            
            # 如果都失败，返回示例数据
            return {
                'times': ['09:30', '10:00', '10:30', '11:00', '11:30', '13:00', '13:30', '14:00', '14:30', '15:00'],
                'prices': [],
                'volumes': []
            }
            
        except Exception as e:
            print(f"获取分时图数据失败 {symbol}: {e}")
            # 返回示例数据
            return {
                'times': ['09:30', '10:00', '10:30', '11:00', '11:30', '13:00', '13:30', '14:00', '14:30', '15:00'],
                'prices': [],
                'volumes': []
            }
    
    def update_all_prices(self):
        """更新所有持仓价格"""
        try:
            conn = sqlite3.connect(self.db_path)
            cursor = conn.cursor()
            
            # 获取所有持仓
            cursor.execute('SELECT symbol, name, current_price FROM holdings')
            holdings = cursor.fetchall()
            
            updated_count = 0
            failed_list = []
            
            for symbol, name, old_price in holdings:
                try:
                    price_data = self.get_stock_price(symbol)
                    print(price_data, symbol)
                    if price_data:
                        new_price = price_data['price']
                        cursor.execute('''
                        UPDATE holdings 
                        SET current_price = ?, last_updated = CURRENT_TIMESTAMP
                        WHERE symbol = ?
                        ''', (new_price, symbol))
                        updated_count += 1
                    else:
                        failed_list.append(f'{name}({symbol})')
                except Exception:
                    failed_list.append(f'{name}({symbol})')
            
            conn.commit()
            conn.close()
            
            message = f'更新完成: 成功 {updated_count} 只, 失败 {len(failed_list)} 只'
            if failed_list:
                message += f'\n失败的股票: {', '.join(failed_list)}'
            
            return {
                'status': 'success',
                'message': message,
                'updated_count': updated_count,
                'failed_count': len(failed_list),
                'failed_symbols': failed_list
            }
            
        except Exception as e:
            return {
                'status': 'error',
                'error': str(e)
            }
    
    def get_single_holding(self, symbol):
        """获取单只股票的持仓信息"""
        try:
            conn = sqlite3.connect(self.db_path)
            cursor = conn.cursor()
            
            # 获取单只股票的持仓数据
            cursor.execute('''
            SELECT 
                symbol,
                name,
                quantity,
                cost_price,
                current_price,
                ROUND((current_price - cost_price) * quantity, 4) as pnl,
                ROUND((current_price - cost_price) / cost_price * 100, 4) as pnl_percent
            FROM holdings
            WHERE symbol = ?
            ''', (symbol,))
            
            holding = cursor.fetchone()
            conn.close()
            
            if holding:
                symbol, name, quantity, cost_price, current_price, pnl, pnl_percent = holding
                return {
                    'symbol': symbol,
                    'name': name,
                    'quantity': quantity,
                    'cost_price': round(cost_price, 4),
                    'current_price': round(current_price, 4),
                    'pnl': round(pnl, 4),
                    'pnl_percent': round(pnl_percent, 4)
                }
            return None
            
        except Exception as e:
            print(f"获取单只股票持仓数据失败: {e}")
            return None
    
    def get_portfolio_data(self):
        """获取完整的持仓数据"""
        try:
            conn = sqlite3.connect(self.db_path)
            cursor = conn.cursor()
            
            # 获取持仓数据
            cursor.execute('''
            SELECT 
                symbol,
                name,
                quantity,
                cost_price,
                current_price,
                ROUND((current_price - cost_price) * quantity, 4) as pnl,
                ROUND((current_price - cost_price) / cost_price * 100, 4) as pnl_percent,
                ROUND((quantity * current_price) / (SELECT SUM(quantity * current_price) FROM holdings) * 100, 4) as weight_percent,
                last_updated
            FROM holdings
            ORDER BY weight_percent DESC
            ''')
            
            holdings = cursor.fetchall()
            
            # 计算总体情况
            cursor.execute('''
            SELECT 
                SUM(quantity * current_price) as total_value,
                SUM(quantity * cost_price) as total_cost,
                SUM(quantity * (current_price - cost_price)) as total_pnl,
                COUNT(*) as holding_count
            FROM holdings
            ''')
            
            result = cursor.fetchone()
            if result:
                total_value, total_cost, total_pnl, holding_count = result
                total_pnl_percent = (total_pnl / total_cost * 100) if total_cost != 0 else 0
            else:
                total_value = total_cost = total_pnl = total_pnl_percent = holding_count = 0
            
            conn.close()
            
            # 整理数据
            portfolio_data = {
                'holdings': [],
                'summary': {
                    'total_value': round(total_value, 4),
                    'total_cost': round(total_cost, 4),
                    'total_pnl': round(total_pnl, 4),
                    'total_pnl_percent': round(total_pnl_percent, 4),
                    'holding_count': holding_count,
                    'update_time': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
                }
            }
            
            for h in holdings:
                symbol, name, qty, cost, current, pnl, pnl_pct, weight, updated = h
                portfolio_data['holdings'].append({
                    'symbol': symbol,
                    'name': name,
                    'quantity': qty,
                    'cost_price': round(cost, 4),
                    'current_price': round(current, 4),
                    'pnl': round(pnl, 4),
                    'pnl_percent': round(pnl_pct, 4),
                    'weight_percent': round(weight, 4),
                    'last_updated': updated
                })
            
            return portfolio_data
            
        except Exception as e:
            print(f"获取持仓数据失败: {e}")
            return None
    
    def add_holding(self, symbol, name, quantity, cost_price):
        """新增持仓记录"""
        try:
            conn = sqlite3.connect(self.db_path)
            cursor = conn.cursor()
            
            # 检查是否已存在
            cursor.execute('SELECT id FROM holdings WHERE symbol = ?', (symbol,))
            if cursor.fetchone():
                conn.close()
                return {'status': 'error', 'error': f'股票{symbol}已存在'}
            
            # 获取实时价格
            price_data = self.get_stock_price(symbol)
            if price_data:
                current_price = price_data['price']
                name = price_data['name'] if not name else name
            else:
                current_price = cost_price
                if not name:
                    conn.close()
                    return {'status': 'error', 'error': '请输入股票名称'}
            
            # 插入新记录
            cursor.execute('''
            INSERT INTO holdings (symbol, name, quantity, cost_price, current_price)
            VALUES (?, ?, ?, ?, ?)
            ''', (symbol, name, quantity, cost_price, current_price))
            
            # 记录操作
            cursor.execute('''
            INSERT INTO operation_log (operation_type, symbol, details)
            VALUES (?, ?, ?)
            ''', ('add', symbol, f'新增持仓: {name} {quantity}股 成本价:{cost_price:.4f}'))
            
            conn.commit()
            conn.close()
            
            return {'status': 'success', 'message': f'成功添加持仓: {name}({symbol})'}
            
        except Exception as e:
            return {'status': 'error', 'error': str(e)}
    
    def delete_holding(self, symbol):
        """删除持仓记录"""
        try:
            conn = sqlite3.connect(self.db_path)
            cursor = conn.cursor()
            
            # 检查是否存在
            cursor.execute('SELECT name FROM holdings WHERE symbol = ?', (symbol,))
            result = cursor.fetchone()
            
            if not result:
                conn.close()
                return {'status': 'error', 'error': f'股票{symbol}不存在'}
            
            name = result[0]
            
            # 删除记录
            cursor.execute('DELETE FROM holdings WHERE symbol = ?', (symbol,))
            
            # 记录操作
            cursor.execute('''
            INSERT INTO operation_log (operation_type, symbol, details)
            VALUES (?, ?, ?)
            ''', ('delete', symbol, f'删除持仓: {name}'))
            
            conn.commit()
            conn.close()
            
            return {'status': 'success', 'message': f'成功删除持仓: {name}({symbol})'}
            
        except Exception as e:
            return {'status': 'error', 'error': str(e)}
    
    def edit_holding(self, symbol, field, value):
        """编辑持仓记录"""
        try:
            if field not in ['quantity', 'cost_price', 'current_price', 'symbol', 'name']:
                return {'status': 'error', 'error': '无效字段'}
            
            conn = sqlite3.connect(self.db_path)
            cursor = conn.cursor()
            
            # 获取旧值
            cursor.execute(f'SELECT {field} FROM holdings WHERE symbol = ?', (symbol,))
            old_value = cursor.fetchone()
            
            if not old_value:
                conn.close()
                return {'status': 'error', 'error': f'股票{symbol}不存在'}
            
            old_value = old_value[0]
            
            # 如果修改股票代码，检查新代码是否已存在
            if field == 'symbol':
                cursor.execute('SELECT id FROM holdings WHERE symbol = ? AND symbol != ?', (value, symbol))
                if cursor.fetchone():
                    conn.close()
                    return {'status': 'error', 'error': f'股票{value}已存在'}
            
            # 更新字段
            if field == 'symbol':
                # 如果修改股票代码，需要更新操作记录中的symbol
                cursor.execute(f'''
                UPDATE holdings 
                SET {field} = ?, last_updated = CURRENT_TIMESTAMP
                WHERE symbol = ?
                ''', (value, symbol))
                
                # 更新操作记录中的symbol
                cursor.execute('''
                UPDATE operation_log 
                SET symbol = ?
                WHERE symbol = ?
                ''', (value, symbol))
            else:
                cursor.execute(f'''
                UPDATE holdings 
                SET {field} = ?, last_updated = CURRENT_TIMESTAMP
                WHERE symbol = ?
                ''', (value, symbol))
            
            # 记录操作
            if field == 'cost_price':
                cursor.execute('''
                INSERT INTO operation_log (operation_type, symbol, details)
                VALUES (?, ?, ?)
                ''', ('edit', value if field == 'symbol' else symbol, f'修改{field}: {old_value:.4f} -> {value:.4f}'))
            else:
                cursor.execute('''
                INSERT INTO operation_log (operation_type, symbol, details)
                VALUES (?, ?, ?)
                ''', ('edit', value if field == 'symbol' else symbol, f'修改{field}: {old_value} -> {value}'))
            
            conn.commit()
            conn.close()
            
            return {'status': 'success', 'message': f'成功修改: {field}: {old_value} -> {value}'}
            
        except Exception as e:
            return {'status': 'error', 'error': str(e)}
    
    def edit_holding_multiple(self, old_symbol, data):
        """批量编辑持仓记录"""
        try:
            conn = sqlite3.connect(self.db_path)
            cursor = conn.cursor()
            
            # 获取旧数据
            cursor.execute('SELECT symbol, name, quantity, cost_price FROM holdings WHERE symbol = ?', (old_symbol,))
            old_data = cursor.fetchone()
            
            if not old_data:
                conn.close()
                return {'status': 'error', 'error': f'股票{old_symbol}不存在'}
            
            old_symbol_db, old_name, old_quantity, old_cost_price = old_data
            
            # 检查新股票代码是否已存在（如果有变化）
            new_symbol = data.get('symbol', old_symbol_db)
            if new_symbol != old_symbol_db:
                cursor.execute('SELECT id FROM holdings WHERE symbol = ? AND symbol != ?', (new_symbol, old_symbol_db))
                if cursor.fetchone():
                    conn.close()
                    return {'status': 'error', 'error': f'股票{new_symbol}已存在'}
            
            # 构建更新语句
            update_fields = []
            update_values = []
            changes = []
            
            # 检查各个字段的变化
            if new_symbol != old_symbol_db:
                update_fields.append('symbol = ?')
                update_values.append(new_symbol)
                changes.append(f'股票代码: {old_symbol_db} -> {new_symbol}')
            
            new_name = data.get('name', old_name)
            if new_name != old_name:
                update_fields.append('name = ?')
                update_values.append(new_name)
                changes.append(f'股票名称: {old_name} -> {new_name}')
            
            new_quantity = data.get('quantity', old_quantity)
            if new_quantity != old_quantity:
                update_fields.append('quantity = ?')
                update_values.append(new_quantity)
                changes.append(f'持仓数量: {old_quantity} -> {new_quantity}')
            
            new_cost_price = data.get('cost_price', old_cost_price)
            if new_cost_price != old_cost_price:
                update_fields.append('cost_price = ?')
                update_values.append(new_cost_price)
                changes.append(f'成本价: {old_cost_price:.4f} -> {new_cost_price:.4f}')
            
            # 如果有变化，执行更新
            if update_fields:
                update_fields.append('last_updated = CURRENT_TIMESTAMP')
                update_values.append(old_symbol_db)
                
                cursor.execute(f'''
                UPDATE holdings 
                SET {', '.join(update_fields)}
                WHERE symbol = ?
                ''', update_values)
                
                # 如果修改了股票代码，更新操作记录中的symbol
                if new_symbol != old_symbol_db:
                    cursor.execute('''
                    UPDATE operation_log 
                    SET symbol = ?
                    WHERE symbol = ?
                    ''', (new_symbol, old_symbol_db))
                
                # 记录一次操作
                if changes:
                    details = '; '.join(changes)
                    cursor.execute('''
                    INSERT INTO operation_log (operation_type, symbol, details)
                    VALUES (?, ?, ?)
                    ''', ('edit', new_symbol, f'批量修改: {details}'))
                
                conn.commit()
            
            conn.close()
            
            if changes:
                return {'status': 'success', 'message': '编辑成功'}
            else:
                return {'status': 'success', 'message': '无变化'}
            
        except Exception as e:
            return {'status': 'error', 'error': str(e)}
    
    def get_operation_logs(self, limit=50):
        """获取操作记录"""
        try:
            conn = sqlite3.connect(self.db_path)
            cursor = conn.cursor()
            
            cursor.execute('''
            SELECT operation_type, symbol, details, created_at 
            FROM operation_log 
            ORDER BY created_at DESC 
            LIMIT ?
            ''', (limit,))
            
            logs = cursor.fetchall()
            conn.close()
            
            log_list = []
            for op_type, symbol, details, timestamp in logs:
                log_list.append({
                    'operation_type': op_type,
                    'symbol': symbol,
                    'details': details,
                    'timestamp': timestamp
                })
            
            return {'status': 'success', 'logs': log_list}
            
        except Exception as e:
            return {'status': 'error', 'error': str(e)}
    
    def generate_report(self):
        """生成持仓报告"""
        try:
            portfolio_data = self.get_portfolio_data()
            
            if not portfolio_data:
                return {'status': 'error', 'error': '获取数据失败'}
            
            summary = portfolio_data['summary']
            holdings = portfolio_data['holdings']
            
            # 生成报告内容
            report_content = f"""# 持仓报告

**生成时间:** {datetime.now().strftime('%Y年%m月%d日 %H:%M')}

## 总体情况

- 持仓数量: {summary['holding_count']} 只
- 总市值: ¥{summary['total_value']:,.4f}
- 总成本: ¥{summary['total_cost']:,.4f}
- 总盈亏: ¥{summary['total_pnl']:+,.4f} ({summary['total_pnl_percent']:+.4f}%)

## 持仓明细

| 股票名称 | 股票代码 | 持仓数量 | 成本价 | 当前价 | 盈亏额 | 盈亏% | 仓位% |
|----------|----------|----------|--------|--------|--------|--------|--------|
"""
            
            for holding in holdings:
                cost = holding['cost_price']
                current = holding['current_price']
                pnl = holding['pnl']
                pnl_percent = holding['pnl_percent']
                weight = holding['weight_percent']
                
                report_content += f"| {holding['name']} | {holding['symbol']} | {holding['quantity']:,} | ¥{cost:.4f} | ¥{current:.4f} | ¥{pnl:+,.4f} | {pnl_percent:+.4f}% | {weight:.4f}% |\n"
            
            # 添加报告尾部
            report_content += f"""
## 报告生成信息

- 生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
- 持仓总数: {summary['holding_count']} 只
- 更新时间: {summary['update_time']}
---
**报告生成完成**
"""
            
            # 保存报告文件
            timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
            os.makedirs('reports', exist_ok=True)
            report_path = f'reports/portfolio_report_{timestamp}.md'
            
            with open(report_path, 'w', encoding='utf-8') as f:
                f.write(report_content)
            
            return {
                'status': 'success',
                'message': f'报告生成成功: {report_path}',
                'report_path': report_path
            }
            
        except Exception as e:
            return {'status': 'error', 'error': str(e)}
    
    def start_auto_update(self, interval_seconds=300):
        """启动自动更新线程"""
        def is_trading_time():
            """检查是否是交易时间"""
            now = datetime.now()
            
            # 检查是否是工作日（周一到周五）
            if now.weekday() >= 5:  # 0-4 是周一到周五
                return False
            
            # 检查时间是否在交易时段内
            hour = now.hour
            minute = now.minute
            
            # 上午交易时间：9:30 - 11:30
            morning_trading = (hour == 9 and minute >= 30) or (hour == 10) or (hour == 11 and minute <= 30)
            
            # 下午交易时间：13:00 - 15:00
            afternoon_trading = (hour == 13) or (hour == 14) or (hour == 15 and minute == 0)
            
            return morning_trading or afternoon_trading
        
        def update_worker():
            while self.auto_update_running:
                try:
                    if is_trading_time():
                        result = self.update_all_prices()
                        if result['status'] == 'success':
                            print(f"🔄 自动更新完成: {result['message']}")
                    time.sleep(interval_seconds)
                except Exception as e:
                    print(f"❌ 自动更新失败: {e}")
                    time.sleep(60)
        
        self.auto_update_running = True
        self.auto_update_thread = threading.Thread(target=update_worker, daemon=True)
        self.auto_update_thread.start()
        print(f"✅ 自动更新已启动，间隔 {interval_seconds} 秒")
    
    def stop_auto_update(self):
        """停止自动更新"""
        self.auto_update_running = False
        if self.auto_update_thread:
            self.auto_update_thread.join(timeout=5)
        print("⏹️  自动更新已停止")


path: llm_manager.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
大模型管理器 - 负责与大模型相关的所有功能
"""

import sqlite3
import requests
import json
import logging
import os
from datetime import datetime
import markdown

# 配置日志
log_dir = 'logs'
if not os.path.exists(log_dir):
    os.makedirs(log_dir)

logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
    handlers=[
        logging.FileHandler(os.path.join(log_dir, 'llm_manager.log'), encoding='utf-8'),
        logging.StreamHandler()
    ]
)

logger = logging.getLogger('LLMManager')

class LLMManager:
    """大模型管理器 - 处理所有与大模型相关的功能"""
    
    def __init__(self, db_path='portfolio.db', holding_manager=None):
        self.db_path = db_path
        self.holding_manager = holding_manager
        logger.info(f'初始化LLMManager，数据库路径：{db_path}')
        self.init_llm_database()
    
    def init_llm_database(self):
        """初始化大模型配置表"""
        logger.info(f'初始化大模型数据库，路径：{self.db_path}')
        conn = sqlite3.connect(self.db_path)
        cursor = conn.cursor()
        
        # 创建大模型配置表
        cursor.execute('''
        CREATE TABLE IF NOT EXISTS llm_config (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            model_type TEXT NOT NULL,  -- openai, baichuan, volcano
            model_name TEXT NOT NULL,
            api_url TEXT NOT NULL,
            api_key TEXT NOT NULL,
            api_id TEXT,
            enabled INTEGER DEFAULT 1,
            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
            updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        )
        ''')
        logger.info('创建大模型配置表完成')
        
        # 插入默认配置（如果不存在）
        cursor.execute('SELECT COUNT(*) FROM llm_config')
        if cursor.fetchone()[0] == 0:
            # 插入OpenAI默认配置
            cursor.execute('''
            INSERT INTO llm_config (model_type, model_name, api_url, api_key, api_id, enabled)
            VALUES (?, ?, ?, ?, ?, ?)
            ''', ('openai', 'gpt-3.5-turbo', 'https://api.openai.com/v1/chat/completions', '', '', 1))
            
            # 插入MiniMax默认配置
            cursor.execute('''
            INSERT INTO llm_config (model_type, model_name, api_url, api_key, api_id, enabled)
            VALUES (?, ?, ?, ?, ?, ?)
            ''', ('minimax', 'M2.7', 'https://api.minimaxi.com/v1/chat/completions', '', '', 0))
            
            logger.info('插入默认大模型配置完成')
        
        conn.commit()
        conn.close()
        logger.info('大模型数据库初始化完成')
    
    def get_llm_config(self):
        """获取大模型配置"""
        logger.info('获取大模型配置')
        try:
            conn = sqlite3.connect(self.db_path)
            cursor = conn.cursor()
            
            cursor.execute('''
            SELECT id, model_type, model_name, api_url, api_key, api_id, enabled
            FROM llm_config
            ORDER BY id
            ''')
            
            configs = []
            for row in cursor.fetchall():
                configs.append({
                    'id': row[0],
                    'model_type': row[1],
                    'model_name': row[2],
                    'api_url': row[3],
                    'api_key': row[4],
                    'api_id': row[5],
                    'enabled': bool(row[6])
                })
            
            conn.close()
            logger.info(f'获取到大模型配置 {len(configs)} 条')
            return {'status': 'success', 'data': configs}
            
        except Exception as e:
            logger.error(f'获取大模型配置失败: {str(e)}')
            return {'status': 'error', 'error': str(e)}
    
    def update_llm_config(self, config_id, model_type, model_name, api_url, api_key, api_id, enabled):
        """更新大模型配置"""
        logger.info(f'更新大模型配置，ID: {config_id}, 模型类型: {model_type}, 模型名称: {model_name}')
        try:
            conn = sqlite3.connect(self.db_path)
            cursor = conn.cursor()
            
            # 更新配置
            cursor.execute('''
            UPDATE llm_config 
            SET model_type = ?, model_name = ?, api_url = ?, api_key = ?, api_id = ?, enabled = ?,
                updated_at = CURRENT_TIMESTAMP
            WHERE id = ?
            ''', (model_type, model_name, api_url, api_key, api_id, 1 if enabled else 0, config_id))
            
            if cursor.rowcount == 0:
                # 如果没有找到记录，插入新记录
                cursor.execute('''
                INSERT INTO llm_config (model_type, model_name, api_url, api_key, api_id, enabled)
                VALUES (?, ?, ?, ?, ?, ?)
                ''', (model_type, model_name, api_url, api_key, api_id, 1 if enabled else 0))
                logger.info(f'插入新的大模型配置，模型类型: {model_type}, 模型名称: {model_name}')
            else:
                logger.info(f'更新大模型配置成功，ID: {config_id}')
            
            conn.commit()
            conn.close()
            return {'status': 'success', 'message': '大模型配置更新成功'}
            
        except Exception as e:
            logger.error(f'更新大模型配置失败: {str(e)}')
            return {'status': 'error', 'error': str(e)}
    
    def call_llm(self, prompt, stream=False):
        """调用大模型"""
        logger.info('开始调用大模型')
        try:
            # 获取启用的大模型配置
            conn = sqlite3.connect(self.db_path)
            cursor = conn.cursor()
            cursor.execute('''
            SELECT model_type, model_name, api_url, api_key, api_id
            FROM llm_config
            WHERE enabled = 1
            LIMIT 1
            ''')
            
            config = cursor.fetchone()
            conn.close()
            
            if not config:
                logger.error('未找到启用的大模型配置')
                return {'status': 'error', 'error': '未找到启用的大模型配置'}
            
            model_type, model_name, api_url, api_key, api_id = config
            logger.info(f'使用模型配置: 类型={model_type}, 名称={model_name}, API地址={api_url}')
            
            if model_type == 'openai':
                # 调用OpenAI API
                logger.info('调用OpenAI API')
                headers = {
                    'Content-Type': 'application/json',
                    'Authorization': f'Bearer {api_key}'
                }
                
                data = {
                    'model': model_name,
                    'messages': [
                        {'role': 'system', 'content': '你是一位专业的股票分析师，请基于提供的数据进行分析并给出操作建议。中文回答！'},
                        {'role': 'user', 'content': prompt}
                    ],
                    'temperature': 0.7,
                    'stream': stream
                }
                
                response = requests.post(api_url, headers=headers, json=data, timeout=300, stream=stream)
                
                if stream:
                    logger.info('OpenAI API 流式响应')
                    return response
                else:
                    if response.status_code == 200:
                        result = response.json()
                        # 将markdown转换为html
                        markdown_content = result['choices'][0]['message']['content']
                        html_content = markdown.markdown(markdown_content)
                        logger.info('OpenAI API 调用成功')
                        return {
                            'status': 'success',
                            'content': html_content,
                            'original_content': markdown_content
                        }
                    else:
                        logger.error(f'OpenAI API调用失败: {response.status_code}, {response.text}')
                        return {
                            'status': 'error',
                            'error': f'OpenAI API调用失败: {response.text}'
                        }
                    
            elif model_type == 'baichuan':
                # 调用百炼API
                logger.info('调用百炼API')
                headers = {
                    'Content-Type': 'application/json',
                    'Authorization': f'Bearer {api_key}'
                }
                
                data = {
                    'model': model_name,
                    'messages': [
                        {'role': 'system', 'content': '你是一位专业的股票分析师，请基于提供的数据进行分析并给出操作建议。'},
                        {'role': 'user', 'content': prompt}
                    ],
                    'temperature': 0.7,
                    'stream': stream
                }
                
                response = requests.post(api_url, headers=headers, json=data, timeout=30, stream=stream)
                
                if stream:
                    logger.info('百炼API 流式响应')
                    return response
                else:
                    if response.status_code == 200:
                        result = response.json()
                        # 将markdown转换为html
                        markdown_content = result['choices'][0]['message']['content']
                        html_content = markdown.markdown(markdown_content)
                        logger.info('百炼API 调用成功')
                        return {
                            'status': 'success',
                            'content': html_content,
                            'original_content': markdown_content
                        }
                    else:
                        logger.error(f'百炼API调用失败: {response.status_code}, {response.text}')
                        return {
                            'status': 'error',
                            'error': f'百炼API调用失败: {response.text}'
                        }
                    
            elif model_type == 'volcano':
                # 调用火山引擎API
                logger.info('调用火山引擎API')
                headers = {
                    'Content-Type': 'application/json',
                    'Authorization': f'Bearer {api_key}',
                    'X-Volc-Auth-AK': api_id if api_id else ''
                }
                
                data = {
                    'model': model_name,
                    'messages': [
                        {'role': 'system', 'content': '你是一位专业的股票分析师，请基于提供的数据进行分析并给出操作建议。'},
                        {'role': 'user', 'content': prompt}
                    ],
                    'temperature': 0.7,
                    'stream': stream
                }
                logger.info(f'火山引擎API调用请求: {api_url}')
                response = requests.post(api_url, headers=headers, json=data, timeout=30, stream=stream)
                logger.info(f'火山引擎API调用响应: {response}')
                
                if stream:
                    logger.info('火山引擎API 流式响应')
                    return response
                else:
                    if response.status_code == 200:
                        result = response.json()
                        # 将markdown转换为html
                        markdown_content = result['choices'][0]['message']['content']
                        html_content = markdown.markdown(markdown_content)
                        logger.info('火山引擎API 调用成功')
                        return {
                            'status': 'success',
                            'content': html_content,
                            'original_content': markdown_content
                        }
                    else:
                        logger.error(f'火山引擎API调用失败: {response.status_code}, {response.text}')
                        return {
                            'status': 'error',
                            'error': f'火山引擎API调用失败: {response.text}'
                        }
                    
            elif model_type == 'minimax':
                # 调用MiniMax API
                logger.info('调用MiniMax API')
                headers = {
                    'Content-Type': 'application/json',
                    'Authorization': f'Bearer {api_key}'
                }
                
                # 添加tokenplan支持，根据MiniMax API文档，格式与OpenAI类似
                data = {
                    'model': model_name,
                    'messages': [
                        {'role': 'system', 'content': '你是一位专业的股票分析师，请基于提供的数据进行分析并给出操作建议。中文回答！'},
                        {'role': 'user', 'content': prompt}
                    ],
                    'temperature': 0.7,
                    'stream': stream
                }
                
                # 如果api_id存在，可能用于tokenplan相关配置
                if api_id:
                    data['tokenplan'] = api_id
                
                logger.info(f'MiniMax API调用请求: {api_url}, stream:{stream}')
                logger.info(f'MiniMax API请求头: {headers}')
                logger.info(f'MiniMax API请求数据: {json.dumps(data, ensure_ascii=False)}')
                
                response = requests.post(api_url, headers=headers, json=data, timeout=300, stream=stream)
                logger.info(f'MiniMax API调用响应状态: {response.status_code}')
                logger.info(f'MiniMax API调用响应头: {dict(response.headers)}')
                
                # 对于流式响应，不尝试解析JSON
                if stream:
                    logger.info('MiniMax API 流式响应')
                    return response
                else:
                    # 非流式响应，尝试解析JSON
                    logger.info(f'MiniMax API调用响应内容: {response.text}')
                    
                    if response.status_code == 200:
                        try:
                            result = response.json()
                            logger.info(f'MiniMax API响应JSON: {json.dumps(result, ensure_ascii=False)}')
                            
                            # 将markdown转换为html
                            if result.get('choices') and len(result['choices']) > 0 and result['choices'][0].get('message'):
                                markdown_content = result['choices'][0]['message']['content']
                                html_content = markdown.markdown(markdown_content)
                                logger.info('MiniMax API 调用成功')
                                return {
                                    'status': 'success',
                                    'content': html_content,
                                    'original_content': markdown_content
                                }
                            else:
                                logger.error(f'MiniMax API响应格式不正确: {json.dumps(result, ensure_ascii=False)}')
                                return {
                                    'status': 'error',
                                    'error': f'MiniMax API响应格式不正确: 缺少必要字段'
                                }
                        except json.JSONDecodeError as e:
                            logger.error(f'MiniMax API响应JSON解析失败: {str(e)}, 响应内容: {response.text}')
                            return {
                                'status': 'error',
                                'error': f'MiniMax API响应JSON解析失败: {str(e)}'
                            }
                    else:
                        logger.error(f'MiniMax API调用失败: {response.status_code}, {response.text}')
                        return {
                            'status': 'error',
                            'error': f'MiniMax API调用失败: {response.status_code}, {response.text}'
                        }
                    
            else:
                logger.error(f'不支持的模型类型: {model_type}')
                return {'status': 'error', 'error': f'不支持的模型类型: {model_type}'}
                
        except Exception as e:
            logger.error(f'大模型调用异常: {str(e)}')
            return {'status': 'error', 'error': str(e)}
    
    def analyze_stock(self, symbol):
        """分析股票"""
        logger.info(f'开始分析股票: {symbol}')
        try:
            # 获取股票详细信息（需要调用持仓管理器的方法）
            if not self.holding_manager:
                logger.error('持仓管理器未初始化')
                return {'status': 'error', 'error': '持仓管理器未初始化'}
                
            stock_detail = self.holding_manager.get_stock_detail(symbol)
            if not stock_detail:
                logger.error(f'获取股票{symbol}信息失败')
                return {'status': 'error', 'error': f'获取股票{symbol}信息失败'}
            
            logger.info(f'获取股票{symbol}({stock_detail["name"]})信息成功')
            
            # 获取持仓信息
            holding_info = self.holding_manager.get_single_holding(symbol)
            holding_text = ""
            if holding_info:
                holding_text = f"""\n我的持仓情况：
持仓数量：{holding_info['quantity']}股
成本价：{holding_info['cost_price']}元
当前盈亏：{holding_info['pnl']}元 ({holding_info['pnl_percent']}%)"""
            else:
                holding_text = "\n（注：我目前没有持有该股）"
            
            # 构建prompt
            prompt = f"""请分析以下股票：

股票名称：{stock_detail['name']}
股票代码：{symbol}
当前价格：{stock_detail['current']}元
今日开盘：{stock_detail['open']}元
今日最高：{stock_detail['high']}元
今日最低：{stock_detail['low']}元
昨日收盘：{stock_detail['pre_close']}元
涨跌额：{stock_detail['change']}元
涨跌幅：{stock_detail['change_percent']}%
{holding_text}

近200个5分钟数据：
{json.dumps(stock_detail.get('min_data', {}), ensure_ascii=False)}

请基于以上数据，分析股票的走势情况，并结合我的持仓情况给出后续操作建议（持仓、买入、卖出、观望等）。"""
            
            # 调用大模型
            logger.info(f'调用大模型分析股票{symbol}')
            result = self.call_llm(prompt)
            logger.info(f'股票{symbol}分析完成')
            return result
            
        except Exception as e:
            logger.error(f'股票{symbol}分析异常: {str(e)}')
            return {'status': 'error', 'error': str(e)}


path: portfolio_system.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
持仓看盘系统 - 统一网页版
整合所有功能，提供完整的股票投资管理解决方案
"""

from flask import Flask, render_template, request, jsonify, send_file
from flask_cors import CORS
import sqlite3
import requests
import json
import os
import socket
import threading
import time
from datetime import datetime
import webbrowser
import markdown
from flask_sock import Sock
import logging

# 导入分离的管理器
from holding_manager import HoldingManager
from llm_manager import LLMManager

log_dir = 'logs'
if not os.path.exists(log_dir):
    os.makedirs(log_dir)

logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
    handlers=[
        logging.FileHandler(os.path.join(log_dir, 'llm_manager.log'), encoding='utf-8'),
        logging.StreamHandler()
    ]
)

logger = logging.getLogger('Manager')

app = Flask(__name__, static_folder='static', template_folder='templates')
CORS(app)

# 初始化WebSocket支持
sock = Sock(app)

# 初始化管理器
holding_manager = HoldingManager('portfolio.db')
llm_manager = LLMManager('portfolio.db', holding_manager)

# 组合管理器，保持原有接口兼容
class CombinedManager:
    def __init__(self, holding_manager, llm_manager):
        self.holding_manager = holding_manager
        self.llm_manager = llm_manager
        self.init_database()
        self.auto_update_thread = None
        self.auto_update_running = False
    
    # 持仓管理方法委托
    def init_database(self):
        # 同时初始化两个管理器的数据库
        self.holding_manager.init_database()
        self.llm_manager.init_llm_database()
    
    def get_stock_price(self, *args, **kwargs):
        return self.holding_manager.get_stock_price(*args, **kwargs)
    
    def get_stock_detail(self, *args, **kwargs):
        return self.holding_manager.get_stock_detail(*args, **kwargs)
    
    def update_all_prices(self, *args, **kwargs):
        return self.holding_manager.update_all_prices(*args, **kwargs)
    
    def get_portfolio_data(self, *args, **kwargs):
        return self.holding_manager.get_portfolio_data(*args, **kwargs)
    
    def add_holding(self, *args, **kwargs):
        return self.holding_manager.add_holding(*args, **kwargs)
    
    def delete_holding(self, *args, **kwargs):
        return self.holding_manager.delete_holding(*args, **kwargs)
    
    def edit_holding(self, *args, **kwargs):
        return self.holding_manager.edit_holding(*args, **kwargs)
    
    def edit_holding_multiple(self, *args, **kwargs):
        return self.holding_manager.edit_holding_multiple(*args, **kwargs)
    
    def get_operation_logs(self, *args, **kwargs):
        return self.holding_manager.get_operation_logs(*args, **kwargs)
    
    def generate_report(self, *args, **kwargs):
        return self.holding_manager.generate_report(*args, **kwargs)
    
    def start_auto_update(self, *args, **kwargs):
        return self.holding_manager.start_auto_update(*args, **kwargs)
    
    def stop_auto_update(self, *args, **kwargs):
        return self.holding_manager.stop_auto_update(*args, **kwargs)
    
    # 大模型管理方法委托
    def get_llm_config(self, *args, **kwargs):
        return self.llm_manager.get_llm_config(*args, **kwargs)
    
    def update_llm_config(self, *args, **kwargs):
        return self.llm_manager.update_llm_config(*args, **kwargs)
    
    def call_llm(self, *args, **kwargs):
        return self.llm_manager.call_llm(*args, **kwargs)
    
    def analyze_stock(self, *args, **kwargs):
        return self.llm_manager.analyze_stock(*args, **kwargs)

# 使用组合管理器保持原有接口兼容
manager = CombinedManager(holding_manager, llm_manager)

def get_local_ip():
    """获取本地IP地址"""
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        s.connect(("8.8.8.8", 80))
        ip = s.getsockname()[0]
        s.close()
        return ip
    except Exception:
        return "127.0.0.1"

@app.route('/')
def index():
    """首页"""
    return render_template('index.html')

@app.route('/api/portfolio')
def get_portfolio():
    """获取持仓数据"""
    try:
        portfolio_data = manager.get_portfolio_data()
        
        if portfolio_data:
            return jsonify({
                'status': 'success',
                **portfolio_data
            })
        else:
            return jsonify({
                'status': 'error',
                'error': '获取数据失败'
            })
            
    except Exception as e:
        return jsonify({
            'status': 'error',
            'error': str(e)
        })

@app.route('/api/portfolio/update', methods=['POST'])
def update_prices():
    """更新持仓价格"""
    result = manager.update_all_prices()
    return jsonify(result)

@app.route('/api/portfolio/add', methods=['POST'])
def add_holding():
    """新增持仓记录"""
    try:
        data = request.get_json()
        
        required_fields = ['symbol', 'quantity', 'cost_price']
        for field in required_fields:
            if field not in data:
                return jsonify({
                    'status': 'error',
                    'error': f'缺少字段: {field}'
                }), 400
        
        result = manager.add_holding(
            data['symbol'],
            data.get('name', ''),
            data['quantity'],
            data['cost_price']
        )
        
        return jsonify(result)
        
    except Exception as e:
        return jsonify({
            'status': 'error',
            'error': str(e)
        })

@app.route('/api/portfolio/delete/<symbol>', methods=['DELETE'])
def delete_holding(symbol):
    """删除持仓记录"""
    result = manager.delete_holding(symbol)
    return jsonify(result)

@app.route('/api/portfolio/edit/<symbol>', methods=['PUT'])
def edit_holding(symbol):
    """编辑持仓记录"""
    try:
        data = request.get_json()
        field = data.get('field', 'quantity')
        value = data.get('value', 0)
        
        result = manager.edit_holding(symbol, field, value)
        return jsonify(result)
        
    except Exception as e:
        return jsonify({
            'status': 'error',
            'error': str(e)
        })

@app.route('/api/portfolio/edit-multiple/<symbol>', methods=['PUT'])
def edit_holding_multiple(symbol):
    """批量编辑持仓记录"""
    try:
        data = request.get_json()
        
        result = manager.edit_holding_multiple(symbol, data)
        return jsonify(result)
        
    except Exception as e:
        return jsonify({
            'status': 'error',
            'error': str(e)
        })

@app.route('/api/portfolio/logs')
def get_logs():
    """获取操作记录"""
    result = manager.get_operation_logs()
    return jsonify(result)

@app.route('/api/portfolio/report')
def generate_report():
    """生成持仓报告"""
    result = manager.generate_report()
    return jsonify(result)

@app.route('/api/portfolio/export')
def export_report():
    """导出持仓报告"""
    result = manager.generate_report()
    
    if result['status'] == 'success':
        report_path = result['report_path']
        return send_file(report_path, as_attachment=True)
    else:
        return jsonify(result)

@app.route('/api/stock/price/<symbol>')
def get_stock_price_api(symbol):
    """查询股票价格"""
    try:
        result = manager.get_stock_price(symbol)
        if result:
            return jsonify({
                'status': 'success',
                'symbol': result['symbol'] if 'symbol' in result else symbol,
                'name': result['name'],
                'price': result['price']
            })
        else:
            return jsonify({
                'status': 'error',
                'error': '未找到股票信息'
            })
    except Exception as e:
        return jsonify({
            'status': 'error',
            'error': str(e)
        })

@app.route('/api/stock/detail/<symbol>')
def get_stock_detail_api(symbol):
    """查询股票详细信息"""
    try:
        result = manager.get_stock_detail(symbol)
        if result:
            return jsonify({
                'status': 'success',
                'data': result
            })
        else:
            return jsonify({
                'status': 'error',
                'error': '未找到股票信息'
            })
    except Exception as e:
        return jsonify({
            'status': 'error',
            'error': str(e)
        })

# ------------------- 大模型相关API -------------------
@app.route('/api/llm/config', methods=['GET'])
def get_llm_config_api():
    """获取大模型配置"""
    result = manager.get_llm_config()
    return jsonify(result)

@app.route('/api/llm/config', methods=['POST'])
def update_llm_config_api():
    """更新大模型配置"""
    try:
        data = request.get_json()
        config_id = data.get('id', 1)
        model_type = data.get('model_type', 'openai')
        model_name = data.get('model_name', 'gpt-3.5-turbo')
        api_url = data.get('api_url', 'https://api.openai.com/v1/chat/completions')
        [REDACTED_SECRET]'api_key', '')
        api_id = data.get('api_id', '')
        enabled = data.get('enabled', True)
        
        result = manager.update_llm_config(config_id, model_type, model_name, api_url, api_key, api_id, enabled)
        return jsonify(result)
    except Exception as e:
        return jsonify({
            'status': 'error',
            'error': str(e)
        })

@app.route('/api/stock/analyze/<symbol>')
def analyze_stock_api(symbol):
    """分析股票"""
    try:
        result = manager.analyze_stock(symbol)
        return jsonify(result)
    except Exception as e:
        return jsonify({
            'status': 'error',
            'error': str(e)
        })

@sock.route('/api/stock/analyze_websocket/<symbol>')
def analyze_stock_websocket(sock, symbol):
    """WebSocket流式分析股票"""
    try:
        # 获取股票详细信息
        stock_detail = manager.get_stock_detail(symbol)
        if not stock_detail:
            sock.send(json.dumps({'status': 'error', 'error': f'获取股票{symbol}信息失败'}))
            sock.close()
            return
        
        # 获取持仓信息
        holding_info = manager.holding_manager.get_single_holding(symbol)
        holding_text = ""
        if holding_info:
            holding_text = f"""\n我的持仓情况：
持仓数量：{holding_info['quantity']}股
成本价：{holding_info['cost_price']}元
当前盈亏：{holding_info['pnl']}元 ({holding_info['pnl_percent']}%)"""
        else:
            holding_text = "\n（注：我目前没有持有该股）"
        
        # 构建prompt
        prompt = f"""请分析以下股票：

股票名称：{stock_detail['name']}
股票代码：{symbol}
当前价格：{stock_detail['current']}元
今日开盘：{stock_detail['open']}元
今日最高：{stock_detail['high']}元
今日最低：{stock_detail['low']}元
昨日收盘：{stock_detail['pre_close']}元
涨跌额：{stock_detail['change']}元
涨跌幅：{stock_detail['change_percent']}%
{holding_text}

近200个5分钟数据：
{json.dumps(stock_detail.get('min_data', {}), ensure_ascii=False)}

请基于以上数据，分析股票的走势情况，并结合我的持仓情况给出后续操作建议（持仓、买入、卖出、观望等）。"""
        
        # 调用大模型的流式接口
        response = manager.call_llm(prompt, stream=True)
        
        # 检查是否是错误响应
        if isinstance(response, dict) and response.get('status') == 'error':
            sock.send(json.dumps(response))
            sock.close()
            return
        
        # 累积原始内容以便完整转换Markdown
        accumulated_content = ""
        
        # 流式处理响应
        for chunk in response.iter_content(chunk_size=1024):
            if chunk:
                try:
                    # 解析大模型返回的SSE格式数据
                    chunk_str = chunk.decode('utf-8')
                    logger.info(f'接收到流式响应块: {chunk_str}')
                    lines = chunk_str.split('\n')
                    
                    for line in lines:
                        line = line.strip()
                        if not line:
                            continue
                            
                        if line.startswith('data: '):
                            data_str = line[6:]
                            logger.info(f'解析到数据行: {data_str}')
                            if data_str == '[DONE]':
                                logger.info('接收到流结束信号')
                                break
                            
                            try:
                                # 检查数据是否为空
                                if not data_str.strip():
                                    logger.warning('接收到空数据')
                                    continue
                                    
                                data = json.loads(data_str)
                                logger.info(f'解析JSON数据: {json.dumps(data, ensure_ascii=False)}')
                                # 获取内容片段
                                if data.get('choices') and len(data['choices']) > 0:
                                    if data['choices'][0].get('delta'):
                                        content = data['choices'][0]['delta'].get('content', '')
                                    elif data['choices'][0].get('message'):
                                        # 处理非流式格式的响应
                                        content = data['choices'][0]['message'].get('content', '')
                                    else:
                                        content = ''
                                        logger.warning(f'未知的响应格式: {json.dumps(data, ensure_ascii=False)}')
                                    
                                    if content:
                                        # 累积内容
                                        accumulated_content += content
                                        # 使用完整累积内容重新转换为HTML，确保Markdown格式正确
                                        html_content = markdown.markdown(accumulated_content)
                                        # 通过WebSocket发送消息
                                        sock.send(json.dumps({
                                            'status': 'success',
                                            'content': html_content,
                                            'original': accumulated_content,
                                            'chunk': content
                                        }))
                            except json.JSONDecodeError as e:
                                logger.error(f'JSON解析失败: {str(e)}, 数据: {data_str}')
                                # 发送详细的错误信息给前端
                                sock.send(json.dumps({
                                    'status': 'error', 
                                    'error': f'JSON解析失败: {str(e)}, 数据: {data_str}'
                                }))
                                continue
                except Exception as e:
                    logger.error(f'流式响应处理异常: {str(e)}')
                    sock.send(json.dumps({'status': 'error', 'error': f'解析数据失败: {str(e)}'}))
                    sock.close()
                    return
        
        # 发送完成信号
        sock.send(json.dumps({'status': 'done'}))
        # 不再等待，由前端控制连接关闭
        
    except Exception as e:
        # 确保在异常时关闭连接
        try:
            sock.send(json.dumps({'status': 'error', 'error': str(e)}))
        except:
            pass
        sock.close()

def start_server(host='0.0.0.0', port=5000, debug=False, auto_update=True):
    """启动Web服务器"""
    print("="*60)
    print("🚀 持仓看盘系统 - 统一网页版")
    print("="*60)
    
    local_ip = get_local_ip()
    
    print(f"📡 访问地址:")
    print(f"   本地: http://localhost:{port}")
    print(f"   局域网: http://{local_ip}:{port}")
    print(f"   移动端: http://{local_ip}:{port}")
    print()
    print("🎯 功能特性:")
    print("   📊 持仓管理 - 增删改查")
    print("   🔄 实时价格 - 自动更新")
    print("   📈 数据分析 - 盈亏统计")
    print("   📋 操作记录 - 完整追溯")
    print("   📄 报告导出 - Markdown格式")
    print()
    print("🔧 按 Ctrl+C 停止服务")
    print("="*60)
    
    # 启动自动更新
    if auto_update:
        manager.start_auto_update(interval_seconds=300)
    
    try:
        app.run(host=host, port=port, debug=debug, threaded=True)
    except KeyboardInterrupt:
        print("\n👋 服务已停止")
        manager.stop_auto_update()
    except Exception as e:
        print(f"❌ 服务启动失败: {e}")

def main():
    """主函数"""
    print("🔍 系统初始化...")
    
    # 检查数据库
    if not os.path.exists('portfolio.db'):
        print("⚠️  数据库不存在，正在创建...")
        manager.init_database()
    
    print("✅ 系统就绪")
    print()
    
    # 启动服务器
    start_server(host='0.0.0.0', port=5000, debug=False, auto_update=True)

if __name__ == '__main__':
    main()


path: README.md
content: # 持仓看盘系统 - 统一网页版

一个功能完整、易于使用的股票投资管理工具，提供网页界面，支持持仓管理、实时价格更新、盈亏分析和报告生成。

## 功能特性

- 📊 **持仓管理** - 增删改查持仓记录
- 🔄 **实时价格** - 自动更新股票价格（支持多个数据源）
- 📈 **数据分析** - 详细的盈亏统计和仓位分析
- 📋 **操作记录** - 完整的操作历史追溯
- 📄 **报告导出** - 生成Markdown格式的持仓报告
- 🌐 **局域网访问** - 支持多设备同时访问
- 📱 **响应式设计** - 适配PC、平板和手机

## 系统架构

```
portfolio-analysis/
├── portfolio_system.py      # 统一主程序
├── templates/
│   └── index.html          # 网页界面
├── portfolio.db            # SQLite数据库（自动创建）
├── reports/               # 报告输出目录（自动创建）
└── static/                # 静态资源目录（自动创建）
```

## 快速开始

### 1. 安装依赖

```bash
pip install flask requests flask-cors
```

### 2. 启动系统

```bash
python portfolio_system.py
```

### 3. 访问系统

启动后会显示访问地址：
- 本地访问：http://localhost:5000
- 局域网访问：http://<你的IP地址>:5000

## 使用说明

### 添加持仓

1. 点击右上角的"➕"按钮
2. 输入股票代码（如：600519）
3. 输入股票名称（可选，不填则自动获取）
4. 输入持仓数量和成本价
5. 点击"确认添加"

### 更新价格

- **手动更新**：点击左侧"更新价格"按钮
- **自动更新**：系统每5分钟自动更新一次

### 编辑持仓

1. 在持仓列表中点击"编辑"按钮
2. 选择要修改的字段（数量或成本价）
3. 输入新值
4. 点击"确认修改"

### 删除持仓

1. 在持仓列表中点击"删除"按钮
2. 确认删除操作

### 搜索持仓

在搜索框中输入股票名称或代码，实时过滤持仓列表。

## API接口

### 获取持仓数据
```
GET /api/portfolio
```

### 更新价格
```
POST /api/portfolio/update
```

### 新增持仓
```
POST /api/portfolio/add
Content-Type: application/json

{
  "symbol": "600519",
  "name": "贵州茅台",
  "quantity": 100,
  "cost_price": 1400.00
}
```

### 编辑持仓
```
PUT /api/portfolio/edit/<symbol>
Content-Type: application/json

{
  "field": "quantity",
  "value": 200
}
```

### 删除持仓
```
DELETE /api/portfolio/delete/<symbol>
```

### 获取操作记录
```
GET /api/portfolio/logs
```

### 生成报告
```
GET /api/portfolio/report
```

### 导出报告
```
GET /api/portfolio/export
```

## 数据库结构

### holdings 表
| 字段 | 类型 | 说明 |
|------|------|------|
| id | INTEGER | 主键 |
| symbol | TEXT | 股票代码（唯一） |
| name | TEXT | 股票名称 |
| quantity | INTEGER | 持仓数量 |
| cost_price | REAL | 成本价 |
| current_price | REAL | 当前价格 |
| last_updated | TIMESTAMP | 最后更新时间 |
| created_at | TIMESTAMP | 创建时间 |

### operation_log 表
| 字段 | 类型 | 说明 |
|------|------|------|
| id | INTEGER | 主键 |
| operation_type | TEXT | 操作类型 |
| symbol | TEXT | 股票代码 |
| details | TEXT | 操作详情 |
| created_at | TIMESTAMP | 创建时间 |

## 价格数据源

系统支持多个数据源，自动切换：
1. 新浪财经（优先）
2. 腾讯财经（备用）

## 配置选项

### 修改端口
在 `portfolio_system.py` 最后修改：
```python
start_server(host='0.0.0.0', port=5000, debug=False, auto_update=True)
```

### 修改自动更新间隔
在 `start_server` 函数中修改：
```python
manager.start_auto_update(interval_seconds=300)  # 300秒 = 5分钟
```

### 修改数据库路径
在 `PortfolioManager` 初始化时修改：
```python
manager = PortfolioManager('portfolio.db')  # 修改为你想要的路径
```

## 注意事项

1. **网络要求**：需要网络连接才能获取实时股票价格
2. **数据备份**：定期备份 `portfolio.db` 文件
3. **访问限制**：默认允许局域网访问，如需限制请修改 `host` 参数
4. **数据源限制**：部分股票可能无法获取实时价格

## 故障排除

### 无法获取股票价格
- 检查网络连接
- 确认股票代码格式正确
- 尝试手动更新

### 数据库错误
- 删除 `portfolio.db` 文件，重新启动系统
- 检查文件权限

### 端口被占用
- 修改启动端口
- 关闭占用5000端口的其他程序

## 技术栈

- **后端**：Python 3.6+
- **Web框架**：Flask
- **数据库**：SQLite
- **前端**：HTML5, CSS3, JavaScript
- **UI框架**：Bootstrap 5
- **图标**：Bootstrap Icons

## 许可证

MIT License

## 贡献

欢迎提交问题和改进建议！

## 更新日志

### v1.0.0 (2026-03-25)
- 整合多个版本，创建统一系统
- 优化界面设计
- 支持多数据源价格获取
- 添加自动更新功能
- 完善操作记录
- 支持报告导出

path: requirements.txt
content: flask>=2.0.0
flask-cors>=3.0.0
requests>=2.25.0
flask-sock>=0.4.0
markdown>=3.3.0


path: 项目介绍文档.md
content: # 持仓分析系统 (Portfolio Analysis System)

## 项目概述

持仓分析系统是一个功能完整的股票投资管理工具，提供交互式命令行界面和网页界面，支持持仓管理、实时价格更新、盈亏分析和报告生成等核心功能。

## 项目结构

```
portfolio-analysis/
├── templates/
│   └── index.html        # 网页版前端模板
├── portfolio.db          # SQLite数据库文件
├── portfolio_ui.py       # 交互式命令行界面
├── portfolio_web.py      # 基于Flask的网页版
├── update_all_prices.py  # 批量更新价格工具
├── update_portfolio.py   # 持仓更新和分析脚本
├── simple_app.py         # 简化版应用
├── simple_web_app.py     # 简化版网页应用
└── web_dashboard.py      # 网页仪表盘
```

## 核心功能

### 1. 持仓管理
- **添加持仓**：录入股票代码、名称、持仓数量和成本价
- **编辑持仓**：修改持仓数量、成本价或当前价格
- **删除持仓**：移除不需要的持仓记录
- **持仓列表**：查看所有持仓的详细信息

### 2. 实时价格更新
- **手动更新**：按需更新单个或所有持仓价格
- **自动更新**：网页版支持定时自动更新（默认5分钟）
- **多源获取**：支持从新浪财经和东方财富获取实时价格

### 3. 数据分析
- **持仓总览**：显示总市值、总成本、总盈亏和盈亏比例
- **个股分析**：计算每只股票的盈亏额、盈亏比例和仓位占比
- **盈亏统计**：统计盈利和亏损股票数量
- **操作建议**：基于盈亏情况提供简单的操作建议

### 4. 操作记录
- **自动记录**：所有操作（添加、编辑、删除、更新）都会被记录
- **历史查询**：查看最近的操作记录
- **操作详情**：记录详细的操作内容和时间

### 5. 报告导出
- **生成报告**：导出持仓报告到Markdown文件
- **报告内容**：包含持仓总览、个股详情和盈亏分析

### 6. 网页界面
- **响应式设计**：适配PC和移动设备
- **实时数据**：通过API获取和显示数据
- **交互式操作**：支持添加、编辑、删除持仓
- **数据可视化**：提供图表展示持仓分布

## 技术实现

### 1. 数据存储
- **SQLite数据库**：本地轻量级数据库，存储持仓和操作记录
- **数据表结构**：
  - `holdings`：存储持仓信息（代码、名称、数量、成本价、当前价等）
  - `operation_log`：存储操作记录（操作类型、股票代码、详情、时间）

### 2. 价格获取
- **API调用**：使用requests库调用第三方金融API
- **多源备份**：支持多个数据源，提高数据获取成功率
- **错误处理**：网络异常时的容错处理

### 3. 界面实现
- **命令行界面**：基于Python标准库的交互式界面
- **网页界面**：基于Flask框架和Bootstrap 5
- **API接口**：提供RESTful API供前端调用

### 4. 核心类
- **PortfolioDashboardUI**：命令行界面主类
- **PortfolioWebDashboard**：网页界面主类
- **StockPriceAPI**：股票价格获取类

## 系统特点

1. **轻量级**：基于Python和SQLite，无需复杂的环境配置
2. **多界面**：同时提供命令行和网页界面，满足不同使用场景
3. **实时数据**：自动获取并更新股票实时价格
4. **数据分析**：提供详细的盈亏分析和操作建议
5. **操作记录**：完整记录所有操作历史
6. **局域网访问**：网页版支持局域网内多设备访问
7. **响应式设计**：网页界面适配不同屏幕尺寸

## 使用方法

### 命令行界面
```bash
python portfolio_ui.py
```

### 网页界面
```bash
python portfolio_web.py
```
访问地址：http://localhost:5000 或 http://<本地IP>:5000

### 价格更新
```bash
python update_all_prices.py
```

### 每日汇总分析
```bash
python update_portfolio.py
```

## 系统要求

- Python 3.6+
- 依赖库：
  - requests
  - flask
  - akshare (用于update_portfolio.py)

## 数据安全

- **本地存储**：所有数据存储在本地SQLite数据库
- **备份功能**：支持数据库备份
- **操作记录**：完整记录所有操作，便于追溯

## 未来扩展

1. **多账户支持**：添加多账户管理功能
2. **历史数据**：记录股票历史价格，提供趋势分析
3. **交易记录**：添加买入卖出交易记录
4. **数据导出**：支持导出到Excel等格式
5. **预警功能**：设置价格预警和盈亏预警
6. **更多数据源**：支持更多金融数据API

## 总结

持仓分析系统是一个功能全面、易于使用的股票投资管理工具，适合个人投资者用于跟踪和分析自己的股票持仓。系统提供了直观的界面和强大的功能，帮助投资者更好地管理投资组合，做出更明智的投资决策。

path: templates/index.html
content: <!DOCTYPE html>
<html lang="zh-CN">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>📊 持仓看盘系统</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css">
    <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
    <style>
        :root {
            --primary-color: #2c3e50;
            --secondary-color: #3498db;
            --profit-color: #e74c3c;
            --loss-color: #2ecc71;
            --warning-color: #f39c12;
        }

        body {
            background-color: #f8f9fa;
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
        }

        .navbar {
            background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
        }

        .card {
            border: none;
            border-radius: 10px;
            box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
            margin-bottom: 1rem;
        }

        .profit {
            color: var(--profit-color) !important;
            font-weight: bold;
        }

        .loss {
            color: var(--loss-color) !important;
            font-weight: bold;
        }

        .table th {
            background-color: #f8f9fa;
            font-weight: 600;
            border-top: none;
        }

        .summary-item {
            display: flex;
            justify-content: space-between;
            align-items: center;
            padding: 0.75rem 1rem;
            border-bottom: 1px solid #eee;
        }

        .summary-item:last-child {
            border-bottom: none;
        }

        .summary-label {
            color: #666;
            font-size: 0.95rem;
        }

        .summary-value {
            font-weight: bold;
            font-size: 1.15rem;
        }

        .status-badge {
            display: inline-block;
            padding: 0.25rem 0.5rem;
            border-radius: 4px;
            font-size: 0.75rem;
            font-weight: bold;
        }

        .status-profit {
            background-color: rgba(46, 204, 113, 0.15);
            color: var(--profit-color);
        }

        .status-loss {
            background-color: rgba(231, 76, 60, 0.15);
            color: var(--loss-color);
        }

        .log-item {
            padding: 0.5rem 0.75rem;
            border-bottom: 1px solid #eee;
            font-size: 0.85rem;
        }

        .log-item:last-child {
            border-bottom: none;
        }

        .btn-group-sm .btn {
            padding: 0.25rem 0.5rem;
            font-size: 0.85rem;
        }

        .refresh-spinner {
            display: none;
        }

        .refresh-spinner.active {
            display: inline-block;
        }

        @media (max-width: 768px) {
            .summary-value {
                font-size: 1rem;
            }

            .table-responsive {
                font-size: 0.85rem;
            }
        }
    </style>
</head>

<body>
    <!-- 导航栏 -->
    <nav class="navbar navbar-expand-lg navbar-dark shadow-sm sticky-top">
        <div class="container-fluid">
            <a class="navbar-brand fw-bold" href="/">
                <i class="bi bi-graph-up me-2"></i>
                持仓看盘系统
            </a>

            <div class="d-flex align-items-center">
                <span class="text-light me-3 small" id="lastUpdated">
                    <i class="bi bi-clock me-1"></i>
                    加载中...
                </span>
                <button class="btn btn-light btn-sm me-2" onclick="loadData()" title="刷新">
                    <i class="bi bi-arrow-clockwise"></i>
                </button>
                <button class="btn btn-outline-light btn-sm" data-bs-toggle="modal" data-bs-target="#addModal"
                    title="新增持仓">
                    <i class="bi bi-plus-circle"></i>
                </button>
            </div>
        </div>
    </nav>

    <!-- 主内容区 -->
    <div class="container-fluid mt-3">
        <div class="row">
            <!-- 左侧总览 -->
            <div class="col-lg-4 col-md-5">
                <div class="card">
                    <div class="card-header d-flex justify-content-between align-items-center">
                        <span><i class="bi bi-pie-chart me-2"></i>持仓总览</span>
                        <div class="d-flex">
                            <button class="btn btn-sm btn-outline-primary me-2" onclick="updatePrices()">
                                <i class="bi bi-arrow-clockwise me-1"></i>更新价格
                            </button>
                            <button class="btn btn-sm btn-outline-primary" onclick="showLlmConfigModal()">
                                <i class="bi bi-gear me-1"></i>AI配置
                            </button>
                        </div>
                    </div>
                    <div class="card-body p-0">
                        <div id="summaryContainer">
                            <div class="text-center p-4">
                                <div class="spinner-border spinner-border-sm text-primary me-2"></div>
                                加载中...
                            </div>
                        </div>
                    </div>
                </div>

                <!-- 操作记录 -->
                <div class="card">
                    <div class="card-header">
                        <i class="bi bi-clock-history me-2"></i>最近操作
                    </div>
                    <div class="card-body p-0" style="max-height: 300px; overflow-y: auto;">
                        <div id="logsContainer">
                            <div class="text-center p-4">
                                <div class="spinner-border spinner-border-sm text-primary me-2"></div>
                                加载中...
                            </div>
                        </div>
                    </div>
                </div>
            </div>

            <!-- 右侧持仓列表 -->
            <div class="col-lg-8 col-md-7">
                <div class="card">
                    <div class="card-header d-flex justify-content-between align-items-center">
                        <span><i class="bi bi-list me-2"></i>持仓列表</span>
                        <div class="input-group input-group-sm" style="width: 250px;">
                            <span class="input-group-text"><i class="bi bi-search"></i></span>
                            <input type="text" class="form-control" placeholder="搜索股票..." id="searchInput"
                                onkeyup="filterHoldings()">
                        </div>
                    </div>
                    <div class="card-body p-0">
                        <div class="table-responsive">
                            <table class="table table-hover mb-0">
                                <thead>
                                    <tr>
                                        <th onclick="sortTable(0)" id="th-name">股票名称 <i class="bi bi-sort"></i></th>
                                        <th onclick="sortTable(1)" id="th-symbol">代码 <i class="bi bi-sort"></i></th>
                                        <th>持仓</th>
                                        <th>成本价</th>
                                        <th>当前价</th>
                                        <th onclick="sortTable(5)" id="th-market">市值 <i class="bi bi-sort"></i></th>
                                        <th onclick="sortTable(6)" id="th-pnl">盈亏额 <i class="bi bi-sort"></i></th>
                                        <th onclick="sortTable(7)" id="th-pnl-percent">盈亏% <i class="bi bi-sort"></i>
                                        </th>
                                        <th>操作</th>
                                    </tr>
                                </thead>
                                <tbody id="holdingsBody">
                                    <tr>
                                        <td colspan="8" class="text-center p-4">
                                            <div class="spinner-border spinner-border-sm text-primary me-2"></div>
                                            加载持仓数据...
                                        </td>
                                    </tr>
                                </tbody>
                            </table>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>

    <!-- 新增持仓模态框 -->
    <div class="modal fade" id="addModal" tabindex="-1">
        <div class="modal-dialog">
            <div class="modal-content">
                <div class="modal-header">
                    <h5 class="modal-title"><i class="bi bi-plus-circle me-2"></i>新增持仓记录</h5>
                    <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
                </div>
                <div class="modal-body">
                    <form id="addForm">
                        <div class="mb-3">
                            <label class="form-label">股票代码</label>
                            <input type="text" class="form-control" id="addSymbol" required>
                            <small class="form-text text-muted">例如：600519（贵州茅台）</small>
                        </div>
                        <div class="mb-3">
                            <label class="form-label">股票名称（可选）</label>
                            <input type="text" class="form-control" id="addName">
                            <small class="form-text text-muted">不填则自动获取</small>
                        </div>
                        <div class="row">
                            <div class="col-md-6 mb-3">
                                <label class="form-label">持仓数量</label>
                                <input type="number" class="form-control" id="addQuantity" required min="1">
                            </div>
                            <div class="col-md-6 mb-3">
                                <label class="form-label">成本价（元）</label>
                                <input type="number" class="form-control" id="addCostPrice" required step="0.0001"
                                    min="0">
                            </div>
                        </div>
                    </form>
                </div>
                <div class="modal-footer">
                    <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
                    <button type="button" class="btn btn-primary" onclick="addHolding()">
                        <i class="bi bi-check-circle me-1"></i>确认添加
                    </button>
                </div>
            </div>
        </div>
    </div>

    <!-- 编辑持仓模态框 -->
    <div class="modal fade" id="editModal" tabindex="-1">
        <div class="modal-dialog">
            <div class="modal-content">
                <div class="modal-header">
                    <h5 class="modal-title"><i class="bi bi-pencil me-2"></i>编辑持仓记录</h5>
                    <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
                </div>
                <div class="modal-body">
                    <form id="editForm">
                        <div class="mb-3">
                            <label class="form-label">股票代码</label>
                            <input type="text" class="form-control" id="editSymbol" required>
                        </div>
                        <div class="mb-3">
                            <label class="form-label">股票名称</label>
                            <input type="text" class="form-control" id="editName" required>
                        </div>
                        <div class="mb-3">
                            <label class="form-label">持仓数量</label>
                            <input type="number" class="form-control" id="editQuantity" required min="1">
                        </div>
                        <div class="mb-3">
                            <label class="form-label">成本价（元）</label>
                            <input type="number" class="form-control" id="editCostPrice" required step="0.0001" min="0">
                        </div>
                    </form>
                </div>
                <div class="modal-footer">
                    <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
                    <button type="button" class="btn btn-primary" onclick="submitEdit()">
                        <i class="bi bi-check-circle me-1"></i>确认修改
                    </button>
                </div>
            </div>
        </div>
    </div>

    <!-- 删除确认模态框 -->
    <div class="modal fade" id="deleteModal" tabindex="-1">
        <div class="modal-dialog">
            <div class="modal-content">
                <div class="modal-header bg-danger text-white">
                    <h5 class="modal-title"><i class="bi bi-exclamation-triangle me-2"></i>确认删除</h5>
                    <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
                </div>
                <div class="modal-body">
                    <p>确定要删除持仓 <strong id="deleteStockName"></strong> 吗？</p>
                    <p class="text-danger"><i class="bi bi-exclamation-circle me-1"></i>此操作不可恢复！</p>
                </div>
                <div class="modal-footer">
                    <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
                    <button type="button" class="btn btn-danger" onclick="confirmDelete()">
                        <i class="bi bi-trash me-1"></i>确认删除
                    </button>
                </div>
            </div>
        </div>
    </div>

    <!-- 股票详情模态框 -->
    <div class="modal fade" id="stockDetailModal" tabindex="-1">
        <div class="modal-dialog modal-xl">
            <div class="modal-content">
                <div class="modal-header bg-primary text-white py-2">
                    <h6 class="modal-title"><i class="bi bi-graph-up-arrow me-2"></i><span id="detailStockName"></span>
                        (<span id="detailStockSymbol"></span>)</h6>
                    <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
                </div>
                <div class="modal-body">
                    <div class="row">
                        <div class="col-md-8">
                            <div class="mb-3">
                                <label class="form-label fw-bold small">今日分时图</label>
                                <div class="text-center" id="detailChartContainer" style="height: 200px;">
                                    <div class="spinner-border text-primary" role="status">
                                        <span class="visually-hidden">加载中...</span>
                                    </div>
                                </div>
                            </div>
                            <div class="mb-3">
                                <label class="form-label fw-bold small">成交量</label>
                                <div class="text-center" id="detailVolumeContainer" style="height: 100px;">
                                    <div class="spinner-border text-secondary" role="status">
                                        <span class="visually-hidden">加载中...</span>
                                    </div>
                                </div>
                            </div>
                            <div class="row">
                                <div class="col-md-4">
                                    <div class="mb-2">
                                        <small class="text-muted fw-bold">当前价格</small>
                                        <div class="fs-5 fw-bold" id="detailCurrentPrice">-</div>
                                    </div>
                                    <div class="mb-2">
                                        <small class="text-muted fw-bold">涨跌额</small>
                                        <div class="fs-6" id="detailChange">-</div>
                                    </div>
                                    <div class="mb-2">
                                        <small class="text-muted fw-bold">涨跌幅</small>
                                        <div class="fs-6" id="detailChangePercent">-</div>
                                    </div>
                                </div>
                                <div class="col-md-4">
                                    <div class="mb-2">
                                        <small class="text-muted fw-bold">今开</small>
                                        <div class="fs-6" id="detailOpen">-</div>
                                    </div>
                                    <div class="mb-2">
                                        <small class="text-muted fw-bold">最高</small>
                                        <div class="fs-6 text-danger" id="detailHigh">-</div>
                                    </div>
                                    <div class="mb-2">
                                        <small class="text-muted fw-bold">最低</small>
                                        <div class="fs-6 text-success" id="detailLow">-</div>
                                    </div>
                                </div>
                                <div class="col-md-4">
                                    <div id="detailAnalysis">
                                        <small class="text-muted">加载中...</small>
                                    </div>
                                </div>
                            </div>

                        </div>
                        <div class="col-md-4">
                            <div class="card bg-light">
                                <div class="card-body p-3">
                                    <div class="d-flex justify-content-between align-items-center mb-3">
                                        <h6 class="card-title fw-bold mb-0"><i class="bi bi-robot me-1"></i>AI分析建议</h6>
                                        <button class="btn btn-sm btn-primary" onclick="analyzeStock(currentSymbol)"
                                            id="analyzeBtn">
                                            <i class="bi bi-rocket-takeoff me-1"></i>AI分析
                                        </button>
                                    </div>
                                    <div id="llmAnalysisResult"
                                        style="max-height: 500px; min-height:500px;overflow-y: auto; padding-right: 10px;">
                                        <small class="text-muted">点击"AI分析"按钮获取分析结果</small>
                                    </div>
                                </div>
                            </div>
                        </div>
                    </div>
                </div>
                <div class="modal-footer py-2 d-flex justify-content-between">
                    <div>
                        <button class="btn btn-sm btn-outline-primary me-2" id="prevStockBtn" title="上一个股票">
                            <i class="bi bi-chevron-left me-1"></i>上一个
                        </button>
                        <button class="btn btn-sm btn-outline-primary" id="nextStockBtn" title="下一个股票">
                            下一个<i class="bi bi-chevron-right ms-1"></i>
                        </button>
                    </div>
                    <button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">关闭</button>
                </div>
            </div>
        </div>
    </div>

    <!-- 大模型配置模态框 -->
    <div class="modal fade" id="llmConfigModal" tabindex="-1">
        <div class="modal-dialog">
            <div class="modal-content">
                <div class="modal-header bg-primary text-white">
                    <h5 class="modal-title"><i class="bi bi-robot me-2"></i>大模型配置</h5>
                    <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
                </div>
                <div class="modal-body">
                    <form id="llmConfigForm">
                        <input type="hidden" id="llmConfigId" value="1">

                        <div class="mb-3">
                            <label class="form-label">模型类型</label>
                            <select class="form-select" id="modelType" required>
                                <option value="openai">OpenAI</option>
                                <option value="baichuan">百炼</option>
                                <option value="volcano">火山引擎</option>
                                <option value="minimax">MiniMax</option>
                            </select>
                        </div>

                        <div class="mb-3">
                            <label class="form-label">模型名称</label>
                            <input type="text" class="form-control" id="modelName" placeholder="如: gpt-3.5-turbo"
                                required>
                        </div>

                        <div class="mb-3">
                            <label class="form-label">API地址</label>
                            <input type="url" class="form-control" id="apiUrl"
                                placeholder="如: https://api.openai.com/v1/chat/completions" required>
                        </div>

                        <div class="mb-3">
                            <label class="form-label">Access Key</label>
                            <input type="password" class="form-control" id="apiKey" placeholder="输入API密钥">
                        </div>

                        <div class="mb-3">
                            <label class="form-label">API ID (可选)</label>
                            <input type="text" class="form-control" id="apiId" placeholder="如: 火山引擎AK">
                        </div>

                        <div class="mb-3 form-check">
                            <input type="checkbox" class="form-check-input" id="enabled" checked>
                            <label class="form-check-label" for="enabled">启用此配置</label>
                        </div>
                    </form>
                </div>
                <div class="modal-footer">
                    <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
                    <button type="button" class="btn btn-primary" onclick="saveLlmConfig()">
                        <i class="bi bi-check-circle me-1"></i>保存配置
                    </button>
                </div>
            </div>
        </div>
    </div>

    <!-- Bootstrap JS -->
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>

    <script>
        let currentEditSymbol = '';
        let currentDeleteSymbol = '';

        // 页面加载时初始化
        document.addEventListener('DOMContentLoaded', function () {
            loadData();
            loadLogs();
            setInterval(loadData, 30000); // 每30秒自动刷新
        });

        // 显示大模型配置模态框
        function showLlmConfigModal() {
            fetch('/api/llm/config')
                .then(response => response.json())
                .then(data => {
                    if (data.status === 'success' && data.data.length > 0) {
                        const config = data.data[0];
                        document.getElementById('llmConfigId').value = config.id;
                        document.getElementById('modelType').value = config.model_type;
                        document.getElementById('modelName').value = config.model_name;
                        document.getElementById('apiUrl').value = config.api_url;
                        document.getElementById('apiKey').value = config.api_key;
                        document.getElementById('apiId').value = config.api_id || '';
                        document.getElementById('enabled').checked = config.enabled;
                    }

                    const modal = new bootstrap.Modal(document.getElementById('llmConfigModal'));
                    modal.show();
                })
                .catch(error => {
                    alert('加载配置失败: ' + error.message);
                });
        }

        // 保存大模型配置
        function saveLlmConfig() {
            const config = {
                id: parseInt(document.getElementById('llmConfigId').value),
                model_type: document.getElementById('modelType').value,
                model_name: document.getElementById('modelName').value,
                api_url: document.getElementById('apiUrl').value,
                [REDACTED_SECRET]'apiKey').value,
                api_id: document.getElementById('apiId').value,
                enabled: document.getElementById('enabled').checked
            };

            fetch('/api/llm/config', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json'
                },
                body: JSON.stringify(config)
            })
                .then(response => response.json())
                .then(data => {
                    if (data.status === 'success') {
                        alert('配置保存成功');
                        const modal = bootstrap.Modal.getInstance(document.getElementById('llmConfigModal'));
                        modal.hide();
                    } else {
                        alert('保存失败: ' + data.error);
                    }
                })
                .catch(error => {
                    alert('保存失败: ' + error.message);
                });
        }

        // 加载数据
        async function loadData() {
            try {
                const response = await fetch('/api/portfolio');
                const data = await response.json();

                if (data.status === 'success') {
                    updateSummary(data.summary);

                    // 检查是否有排序状态
                    if (sortColumn !== -1) {
                        // 有排序状态，使用排序后的数据
                        loadDataWithSort();
                    } else {
                        // 没有排序状态，直接显示原始数据
                        updateHoldingsTable(data.holdings);
                        updateSortIcons();
                    }
                } else {
                    alert('❌ 加载数据失败: ' + data.error);
                }
            } catch (error) {
                alert('❌ 网络错误: ' + error.message);
            }
        }

        // 更新总览
        function updateSummary(summary) {
            const now = new Date();
            document.getElementById('lastUpdated').innerHTML =
                `<i class="bi bi-clock me-1"></i>更新: ${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`;

            const container = document.getElementById('summaryContainer');
            container.innerHTML = `
                <div class="summary-item">
                    <span class="summary-label"><i class="bi bi-collection me-2"></i>持仓数量</span>
                    <span class="summary-value">${summary.holding_count} 只</span>
                </div>
                <div class="summary-item">
                    <span class="summary-label"><i class="bi bi-cash-stack me-2"></i>总市值</span>
                    <span class="summary-value">¥${formatNumber(summary.total_value)}</span>
                </div>
                <div class="summary-item">
                    <span class="summary-label"><i class="bi bi-wallet2 me-2"></i>总成本</span>
                    <span class="summary-value">¥${formatNumber(summary.total_cost)}</span>
                </div>
                <div class="summary-item">
                    <span class="summary-label"><i class="bi bi-graph-up-arrow me-2"></i>总盈亏</span>
                    <span class="summary-value ${summary.total_pnl >= 0 ? 'profit' : 'loss'}">
                        ¥${formatNumber(summary.total_pnl)} (${summary.total_pnl_percent.toFixed(4)}%)
                    </span>
                </div>
            `;
        }

        // 更新持仓表格
        function updateHoldingsTable(holdings) {
            const tbody = document.getElementById('holdingsBody');

            // 更新全局持仓列表
            holdingsList = holdings.map(h => ({ symbol: h.symbol, name: h.name }));

            if (holdings.length === 0) {
                tbody.innerHTML = `
                    <tr>
                        <td colspan="9" class="text-center py-4 text-muted">
                            <i class="bi bi-inbox fs-1 d-block mb-2"></i>
                            暂无持仓记录
                        </td>
                    </tr>
                `;
                return;
            }

            let html = '';
            holdings.forEach(holding => {
                const pnlClass = holding.pnl >= 0 ? 'profit' : 'loss';
                const statusClass = holding.pnl >= 0 ? 'status-profit' : 'status-loss';
                const statusText = holding.pnl >= 0 ? '盈利' : '亏损';
                const marketValue = holding.quantity * holding.current_price;

                html += `
                    <tr ondblclick="showStockDetail('${holding.symbol}', '${holding.name}')">
                        <td>
                            <span class="${statusClass} status-badge me-2">${statusText}</span>
                            ${holding.name}
                        </td>
                        <td>${holding.symbol}</td>
                        <td>${formatNumber(holding.quantity)}</td>
                        <td>¥${holding.cost_price.toFixed(4)}</td>
                        <td>¥${holding.current_price.toFixed(4)}</td>
                        <td>¥${formatNumber(marketValue)}</td>
                        <td class="${pnlClass}">¥${formatNumber(holding.pnl)}</td>
                        <td class="${pnlClass}">${holding.pnl_percent.toFixed(4)}%</td>
                        <td>
                            <div class="btn-group btn-group-sm">
                                <button class="btn btn-outline-primary" onclick="showEditModal('${holding.symbol}', '${holding.name}')">
                                    <i class="bi bi-pencil"></i>
                                </button>
                                <button class="btn btn-outline-danger" onclick="showDeleteModal('${holding.symbol}', '${holding.name}')">
                                    <i class="bi bi-trash"></i>
                                </button>
                            </div>
                        </td>
                    </tr>
                `;
            });

            tbody.innerHTML = html;
        }

        // 加载操作记录
        async function loadLogs() {
            try {
                const response = await fetch('/api/portfolio/logs');
                const data = await response.json();

                if (data.status === 'success') {
                    updateLogs(data.logs);
                }
            } catch (error) {
                console.error('加载日志失败:', error);
            }
        }

        // 更新操作记录
        function updateLogs(logs) {
            const container = document.getElementById('logsContainer');

            if (logs.length === 0) {
                container.innerHTML = '<div class="p-3 text-muted text-center">暂无操作记录</div>';
                return;
            }

            let html = '';
            logs.slice(0, 10).forEach(log => {
                const time = new Date(log.timestamp).toLocaleTimeString('zh-CN', { hour12: false });
                html += `
                    <div class="log-item">
                        <div class="d-flex justify-content-between mb-1">
                            <small class="text-muted"><i class="bi bi-clock me-1"></i>${time}</small>
                            <small class="text-muted">${log.symbol}</small>
                        </div>
                        <div>${log.details}</div>
                    </div>
                `;
            });

            container.innerHTML = html;
        }

        // 显示编辑模态框
        function showEditModal(symbol, name) {
            currentEditSymbol = symbol;
            document.getElementById('editSymbol').value = symbol;
            document.getElementById('editName').value = name;

            // 获取持仓详情并填充表单
            fetch('/api/portfolio')
                .then(response => response.json())
                .then(data => {
                    if (data.status === 'success') {
                        const holding = data.holdings.find(h => h.symbol === symbol);
                        if (holding) {
                            document.getElementById('editQuantity').value = holding.quantity;
                            document.getElementById('editCostPrice').value = holding.cost_price;
                        }
                    }
                });

            const modal = new bootstrap.Modal(document.getElementById('editModal'));
            modal.show();
        }

        // 提交编辑
        async function submitEdit() {
            const oldSymbol = currentEditSymbol;
            const newSymbol = document.getElementById('editSymbol').value;
            const newName = document.getElementById('editName').value;
            const quantity = document.getElementById('editQuantity').value;
            const costPrice = document.getElementById('editCostPrice').value;

            if (!newSymbol || !newName || !quantity || !costPrice) {
                alert('请填写完整信息');
                return;
            }

            try {
                const response = await fetch(`/api/portfolio/edit-multiple/${oldSymbol}`, {
                    method: 'PUT',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({
                        symbol: newSymbol,
                        name: newName,
                        quantity: parseInt(quantity),
                        cost_price: parseFloat(costPrice)
                    })
                });

                const data = await response.json();

                if (data.status === 'success') {
                    alert('✅ 编辑成功');
                    bootstrap.Modal.getInstance(document.getElementById('editModal')).hide();
                    loadData();
                    loadLogs();
                } else {
                    alert('❌ ' + data.error);
                }
            } catch (error) {
                alert('❌ 编辑失败: ' + error.message);
            }
        }

        // 显示删除模态框
        function showDeleteModal(symbol, name) {
            currentDeleteSymbol = symbol;
            document.getElementById('deleteStockName').textContent = `${name}(${symbol})`;

            const modal = new bootstrap.Modal(document.getElementById('deleteModal'));
            modal.show();
        }

        // 确认删除
        async function confirmDelete() {
            try {
                const response = await fetch(`/api/portfolio/delete/${currentDeleteSymbol}`, {
                    method: 'DELETE'
                });

                const data = await response.json();

                if (data.status === 'success') {
                    alert('✅ ' + data.message);
                    bootstrap.Modal.getInstance(document.getElementById('deleteModal')).hide();
                    loadData();
                    loadLogs();
                } else {
                    alert('❌ ' + data.error);
                }
            } catch (error) {
                alert('❌ 删除失败: ' + error.message);
            }
        }

        // 新增持仓
        async function addHolding() {
            const symbol = document.getElementById('addSymbol').value.trim();
            const name = document.getElementById('addName').value.trim();
            const quantity = document.getElementById('addQuantity').value;
            const costPrice = document.getElementById('addCostPrice').value;

            if (!symbol || !quantity || !costPrice) {
                alert('请填写完整信息');
                return;
            }

            try {
                const response = await fetch('/api/portfolio/add', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({
                        symbol,
                        name,
                        quantity: parseInt(quantity),
                        cost_price: parseFloat(costPrice)
                    })
                });

                const data = await response.json();

                if (data.status === 'success') {
                    alert('✅ ' + data.message);
                    bootstrap.Modal.getInstance(document.getElementById('addModal')).hide();
                    document.getElementById('addForm').reset();
                    loadData();
                    loadLogs();
                } else {
                    alert('❌ ' + data.error);
                }
            } catch (error) {
                alert('❌ 添加失败: ' + error.message);
            }
        }

        // 更新价格
        async function updatePrices() {
            try {
                const response = await fetch('/api/portfolio/update', {
                    method: 'POST'
                });

                const data = await response.json();

                if (data.status === 'success') {
                    alert('✅ ' + data.message);
                    loadData();
                } else {
                    alert('❌ ' + data.error);
                }
            } catch (error) {
                alert('❌ 更新失败: ' + error.message);
            }
        }

        // 过滤持仓
        function filterHoldings() {
            const keyword = document.getElementById('searchInput').value.toLowerCase();
            const rows = document.querySelectorAll('#holdingsBody tr');

            rows.forEach(row => {
                const text = row.textContent.toLowerCase();
                row.style.display = text.includes(keyword) ? '' : 'none';
            });
        }

        // 格式化数字
        function formatNumber(num) {
            return num.toLocaleString('zh-CN', {
                minimumFractionDigits: 4,
                maximumFractionDigits: 4
            });
        }

        // 排序状态
        let sortColumn = -1;
        let sortDirection = 1; // 1: 升序, -1: 降序

        // 排序表格
        function sortTable(column) {
            // 切换排序方向
            if (sortColumn === column) {
                sortDirection *= -1;
            } else {
                sortColumn = column;
                sortDirection = 1;
            }

            // 更新箭头图标
            updateSortIcons();

            // 重新加载数据并排序
            loadDataWithSort();
        }

        // 更新排序箭头图标
        function updateSortIcons() {
            // 重置所有表头的图标
            document.getElementById('th-name').innerHTML = '股票名称 <i class="bi bi-sort"></i>';
            document.getElementById('th-symbol').innerHTML = '代码 <i class="bi bi-sort"></i>';
            document.getElementById('th-market').innerHTML = '市值 <i class="bi bi-sort"></i>';
            document.getElementById('th-pnl').innerHTML = '盈亏额 <i class="bi bi-sort"></i>';
            document.getElementById('th-pnl-percent').innerHTML = '盈亏% <i class="bi bi-sort"></i>';

            // 根据当前排序状态更新对应表头的图标
            if (sortColumn === 0) {
                document.getElementById('th-name').innerHTML = '股票名称 <i class="bi bi-sort-' + (sortDirection === 1 ? 'down' : 'up') + '"></i>';
            } else if (sortColumn === 1) {
                document.getElementById('th-symbol').innerHTML = '代码 <i class="bi bi-sort-' + (sortDirection === 1 ? 'down' : 'up') + '"></i>';
            } else if (sortColumn === 5) {
                document.getElementById('th-market').innerHTML = '市值 <i class="bi bi-sort-' + (sortDirection === 1 ? 'down' : 'up') + '"></i>';
            } else if (sortColumn === 6) {
                document.getElementById('th-pnl').innerHTML = '盈亏额 <i class="bi bi-sort-' + (sortDirection === 1 ? 'down' : 'up') + '"></i>';
            } else if (sortColumn === 7) {
                document.getElementById('th-pnl-percent').innerHTML = '盈亏% <i class="bi bi-sort-' + (sortDirection === 1 ? 'down' : 'up') + '"></i>';
            }
        }

        // 加载数据并排序
        async function loadDataWithSort() {
            try {
                const response = await fetch('/api/portfolio');
                const data = await response.json();

                if (data.status === 'success') {
                    updateSummary(data.summary);

                    // 对持仓数据进行排序
                    let sortedHoldings = [...data.holdings];

                    sortedHoldings.sort((a, b) => {
                        let valueA, valueB;

                        switch (sortColumn) {
                            case 0: // 股票名称
                                valueA = a.name;
                                valueB = b.name;
                                break;
                            case 1: // 股票代码
                                valueA = a.symbol;
                                valueB = b.symbol;
                                break;
                            case 5: // 市值
                                valueA = a.quantity * a.current_price;
                                valueB = b.quantity * b.current_price;
                                break;
                            case 6: // 盈亏额
                                valueA = a.pnl;
                                valueB = b.pnl;
                                break;
                            case 7: // 盈亏%
                                valueA = a.pnl_percent;
                                valueB = b.pnl_percent;
                                break;
                            default:
                                return 0;
                        }

                        if (typeof valueA === 'string' && typeof valueB === 'string') {
                            return valueA.localeCompare(valueB) * sortDirection;
                        } else {
                            return (valueA - valueB) * sortDirection;
                        }
                    });

                    updateHoldingsTable(sortedHoldings);
                    updateSortIcons();
                } else {
                    alert('❌ 加载数据失败: ' + data.error);
                }
            } catch (error) {
                alert('❌ 网络错误: ' + error.message);
            }
        }

        // 全局Chart实例
        let stockChart = null;
        let volumeChart = null;
        let hoverIndex = -1;

        // 导航相关全局变量
        let holdingsList = [];
        let currentStockIndex = -1;
        let currentSymbol = '';
        let currentName = '';
        let stockDetailModal = null; // 全局模态框实例

        // 初始化导航按钮事件
        document.addEventListener('DOMContentLoaded', function () {
            const prevBtn = document.getElementById('prevStockBtn');
            const nextBtn = document.getElementById('nextStockBtn');

            // 创建模态框实例
            stockDetailModal = new bootstrap.Modal(document.getElementById('stockDetailModal'));

            if (prevBtn) {
                prevBtn.addEventListener('click', showPrevStock);
            }

            if (nextBtn) {
                nextBtn.addEventListener('click', showNextStock);
            }
        });

        // 显示上一个股票
        function showPrevStock() {
            if (currentStockIndex > 0) {
                currentStockIndex--;
                const stock = holdingsList[currentStockIndex];
                showStockDetail(stock.symbol, stock.name);
            } else {
                alert('已经是第一个股票了');
            }
        }

        // 显示下一个股票
        function showNextStock() {
            if (currentStockIndex < holdingsList.length - 1) {
                currentStockIndex++;
                const stock = holdingsList[currentStockIndex];
                showStockDetail(stock.symbol, stock.name);
            } else {
                alert('已经是最后一个股票了');
            }
        }

        // 更新导航按钮状态
        function updateNavButtons() {
            const prevBtn = document.getElementById('prevStockBtn');
            const nextBtn = document.getElementById('nextStockBtn');

            if (prevBtn) {
                prevBtn.disabled = currentStockIndex <= 0;
                prevBtn.classList.toggle('disabled', currentStockIndex <= 0);
            }

            if (nextBtn) {
                nextBtn.disabled = currentStockIndex >= holdingsList.length - 1;
                nextBtn.classList.toggle('disabled', currentStockIndex >= holdingsList.length - 1);
            }
        }

        // 显示股票详情
        async function showStockDetail(symbol, name) {
            // 更新全局变量
            currentSymbol = symbol;
            currentName = name;

            // 如果是从持仓列表打开的，更新索引
            if (holdingsList.length > 0) {
                const index = holdingsList.findIndex(s => s.symbol === symbol);
                if (index !== -1) {
                    currentStockIndex = index;
                }
                updateNavButtons();
            }

            // 更新模态框标题
            document.getElementById('detailStockName').textContent = name;
            document.getElementById('detailStockSymbol').textContent = symbol;

            // 重置显示
            document.getElementById('detailCurrentPrice').textContent = '-';
            document.getElementById('detailChange').textContent = '-';
            document.getElementById('detailChangePercent').textContent = '-';
            document.getElementById('detailOpen').textContent = '-';
            document.getElementById('detailHigh').textContent = '-';
            document.getElementById('detailLow').textContent = '-';
            document.getElementById('detailAnalysis').innerHTML = '<small class="text-muted">加载中...</small>';
            document.getElementById('detailChartContainer').innerHTML = '<div class="spinner-border text-primary" role="status"><span class="visually-hidden">加载中...</span></div>';

            // 显示模态框
            if (!stockDetailModal) {
                stockDetailModal = new bootstrap.Modal(document.getElementById('stockDetailModal'));
            }
            stockDetailModal.show();

            try {
                const response = await fetch(`/api/stock/detail/${symbol}`);
                const data = await response.json();

                if (data.status === 'success') {
                    const detail = data.data;

                    // 更新价格信息
                    document.getElementById('detailCurrentPrice').textContent = `¥${detail.current.toFixed(4)}`;

                    const changeText = detail.change >= 0 ? `+¥${detail.change.toFixed(4)}` : `¥${detail.change.toFixed(4)}`;
                    const changePercentText = detail.change_percent >= 0 ? `+${detail.change_percent.toFixed(4)}%` : `${detail.change_percent.toFixed(4)}%`;

                    document.getElementById('detailChange').textContent = changeText;
                    document.getElementById('detailChange').className = `fs-6 ${detail.change >= 0 ? 'profit' : 'loss'}`;

                    document.getElementById('detailChangePercent').textContent = changePercentText;
                    document.getElementById('detailChangePercent').className = `fs-6 ${detail.change_percent >= 0 ? 'profit' : 'loss'}`;

                    document.getElementById('detailOpen').textContent = `¥${detail.open.toFixed(4)}`;
                    document.getElementById('detailHigh').textContent = `¥${detail.high.toFixed(4)}`;
                    document.getElementById('detailLow').textContent = `¥${detail.low.toFixed(4)}`;

                    // 更新分析
                    updateAnalysis(detail);

                    // 创建分时图
                    createMinChart(detail);
                } else {
                    document.getElementById('detailChartContainer').innerHTML = '<div class="text-muted">获取数据失败</div>';
                }
            } catch (error) {
                console.error('获取股票详情失败:', error);
                document.getElementById('detailChartContainer').innerHTML = '<div class="text-muted">网络错误</div>';
            }
        }

        // 更新分析
        function updateAnalysis(detail) {
            const container = document.getElementById('detailAnalysis');
            const isProfit = detail.change >= 0;

            let analysisHTML = `
                <div class="mb-2">
                    <small class="text-muted">走势判断</small>
                    <div class="fw-bold ${isProfit ? 'profit' : 'loss'}">${isProfit ? '📈 上涨' : '📉 下跌'}</div>
                </div>
                <div class="mb-2">
                    <small class="text-muted">波动幅度</small>
                    <div class="fw-bold">${Math.abs(detail.change_percent).toFixed(4)}%</div>
                </div>
                <div class="mb-2">
                    <small class="text-muted">相对开盘</small>
                    <div class="fw-bold ${detail.current >= detail.open ? 'profit' : 'loss'}">
                        ${detail.current >= detail.open ? '高于' : '低于'}开盘价
                    </div>
                </div>
                <div class="mb-2">
                    <small class="text-muted">日内位置</small>
                    <div class="fw-bold">
                        ${detail.current === detail.high ? '🏆 最高价' :
                    detail.current === detail.low ? '🎯 最低价' :
                        '中间位置'}
                    </div>
                </div>
            `;

            container.innerHTML = analysisHTML;
        }

        // AI分析股票 - WebSocket流式版本
        function analyzeStock(symbol) {
            const btn = document.getElementById('analyzeBtn');
            const resultDiv = document.getElementById('llmAnalysisResult');
            
            if (!symbol) {
                resultDiv.innerHTML = '<div class="text-warning">请选择股票后再进行分析</div>';
                return;
            }
            
            btn.disabled = true;
            resultDiv.innerHTML = '<div class="text-left">分析中...</div>';
            
            // 构建WebSocket URL
            const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
            const wsUrl = `${wsProtocol}//${window.location.host}/api/stock/analyze_websocket/${symbol}`;
            
            try {
                // 创建WebSocket连接
                const ws = new WebSocket(wsUrl);
                
                // 添加一个标志，用于标记连接是否已经正常完成
                let isConnectionCompleted = false;
                
                // 处理连接打开事件
                ws.onopen = function() {
                    console.log('WebSocket连接已建立');
                };
                
                // 处理消息事件
                ws.onmessage = function(event) {
                    try {
                        // 确保收到的是字符串数据
                        if (typeof event.data === 'string') {
                            const data = JSON.parse(event.data);
                            
                            if (data.status === 'success') {
                                // 使用完整转换后的HTML内容，确保Markdown格式正确
                                resultDiv.innerHTML = `<div class="text-left">${data.content}</div>`;
                                
                                // 滚动到底部
                                resultDiv.scrollTop = resultDiv.scrollHeight;
                            } else if (data.status === 'done') {
                                // 分析完成，标记连接已正常完成
                                isConnectionCompleted = true;
                                btn.disabled = false;
                                console.log('分析完成');
                                // 收到完成信号后，前端主动关闭连接
                                if (ws.readyState === WebSocket.OPEN) {
                                    ws.close(1000, '分析完成，正常关闭连接');
                                }
                            } else if (data.status === 'error') {
                                console.error('分析失败:', data.error);
                                // 处理错误
                                // resultDiv.innerHTML += `<div class="text-danger">分析失败: ${data.error}</div>`;
                                btn.disabled = false;
                                // 不要在这里关闭连接，让后端处理关闭
                            }
                        }
                    } catch (error) {
                        console.error('解析WebSocket数据失败:', error);
                    }
                };
                
                // 处理错误事件
                ws.onerror = function(error) {
                    console.error('WebSocket错误:', error);
                    btn.disabled = false;
                    // 只有在连接未正常完成时才显示错误信息
                    if (!isConnectionCompleted) {
                        // resultDiv.innerHTML += '<div class="text-danger">分析失败: WebSocket连接错误</div>';
                    }
                    // 避免重复关闭
                    if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
                        ws.close();
                    }
                };
                
                // 处理连接关闭事件
                ws.onclose = function(event) {
                    console.log('WebSocket连接已关闭');
                    btn.disabled = false;
                    // 如果连接已正常完成，不做任何处理
                    // 否则，如果关闭事件的code不是正常关闭码，则显示错误信息
                    if (!isConnectionCompleted && event.code !== 1000 && event.code !== 1001) {
                        resultDiv.innerHTML = '<div class="text-danger">分析失败: WebSocket连接意外关闭</div>';
                    }
                };
            } catch (error) {
                console.error('创建WebSocket连接失败:', error);
                btn.disabled = false;
                resultDiv.innerHTML = '<div class="text-danger">分析失败: 无法创建WebSocket连接</div>';
            }
        }

        // 创建分时图
        function createMinChart(detail) {
            const container = document.getElementById('detailChartContainer');
            const volumeContainer = document.getElementById('detailVolumeContainer');

            // 清理旧的Chart
            if (stockChart) {
                stockChart.destroy();
            }
            if (volumeChart) {
                volumeChart.destroy();
            }

            // 使用真实数据或生成示例数据
            let times = ['09:30', '10:00', '10:30', '11:00', '11:30', '13:00', '13:30', '14:00', '14:30', '15:00'];
            let prices = [];
            let volumes = [];
            const basePrice = detail.pre_close || detail.current;
            const openPrice = detail.open || basePrice;

            // 尝试使用API返回的真实数据
            if (detail.min_data && detail.min_data.times && detail.min_data.times.length > 0) {
                times = detail.min_data.times;
                prices = detail.min_data.prices;
                volumes = detail.min_data.volumes || [];
            } else {
                // 如果没有真实数据，生成示例数据
                for (let i = 0; i < times.length; i++) {
                    const randomChange = (Math.random() - 0.5) * 0.02 * basePrice;
                    prices.push(basePrice + randomChange);
                    volumes.push(Math.floor(Math.random() * 1000000));
                }
            }

            const chartColor = detail.change >= 0 ? '#e74c3c' : '#2ecc71';
            const volumeColors = prices.map((price, i) => {
                if (i === 0) return chartColor;
                return price >= prices[i - 1] ? '#e74c3c' : '#2ecc71';
            });

            // 创建价格图表
            container.innerHTML = '<canvas id="minChart"></canvas>';
            const ctx = document.getElementById('minChart').getContext('2d');

            stockChart = new Chart(ctx, {
                type: 'line',
                data: {
                    labels: times,
                    datasets: [{
                        label: '价格',
                        data: prices,
                        borderColor: chartColor,
                        backgroundColor: chartColor + '20',
                        fill: true,
                        tension: 0.4,
                        pointRadius: 0,
                        pointHoverRadius: 6
                    }]
                },
                options: {
                    responsive: true,
                    maintainAspectRatio: false,
                    interaction: {
                        mode: 'index',
                        intersect: false
                    },
                    onHover: (event, elements) => {
                        if (elements.length > 0) {
                            hoverIndex = elements[0].index;
                            stockChart.update('none');
                            volumeChart.update('none');
                        }
                    },
                    plugins: {
                        legend: {
                            display: false
                        },
                        tooltip: {
                            mode: 'index',
                            intersect: false,
                            callbacks: {
                                label: function (context) {
                                    const label = context.dataset.label || '';
                                    const value = context.parsed.y;
                                    const volume = volumes[context.dataIndex] || 0;
                                    return [
                                        `价格: ¥${value.toFixed(4)}`,
                                        `成交量: ${volume.toLocaleString()}`
                                    ];
                                }
                            }
                        }
                    },
                    scales: {
                        x: {
                            grid: {
                                display: false
                            }
                        },
                        y: {
                            grid: {
                                color: 'rgba(0,0,0,0.1)'
                            }
                        }
                    }
                },
                plugins: [{
                    id: 'openPriceLine',
                    beforeDraw: (chart) => {
                        const { ctx, chartArea } = chart;
                        if (!chartArea) return;

                        const scale = chart.scales.y;
                        const y = scale.getPixelForValue(openPrice);

                        ctx.save();
                        ctx.beginPath();
                        ctx.moveTo(chartArea.left, y);
                        ctx.lineTo(chartArea.right, y);
                        ctx.strokeStyle = '#f39c12';
                        ctx.lineWidth = 2;
                        ctx.setLineDash([5, 5]);
                        ctx.stroke();
                        ctx.restore();

                        // 标注开盘价
                        ctx.save();
                        ctx.fillStyle = '#f39c12';
                        ctx.font = '12px sans-serif';
                        ctx.fillText(`开:¥${openPrice.toFixed(2)}`, chartArea.left + 5, y - 5);
                        ctx.restore();
                    }
                }, {
                    id: 'verticalLine',
                    afterDraw: (chart) => {
                        if (hoverIndex < 0) return;

                        const { ctx, chartArea } = chart;
                        if (!chartArea) return;

                        const scale = chart.scales.x;
                        const x = scale.getPixelForValue(hoverIndex);

                        if (x >= chartArea.left && x <= chartArea.right) {
                            ctx.save();
                            ctx.beginPath();
                            ctx.moveTo(x, chartArea.top);
                            ctx.lineTo(x, chartArea.bottom);
                            ctx.strokeStyle = 'rgba(0, 0, 0, 0.3)';
                            ctx.lineWidth = 1;
                            ctx.setLineDash([]);
                            ctx.stroke();
                            ctx.restore();
                        }
                    }
                }]
            });

            // 创建成交量图表
            volumeContainer.innerHTML = '<canvas id="volumeChart"></canvas>';
            const ctx2 = document.getElementById('volumeChart').getContext('2d');

            volumeChart = new Chart(ctx2, {
                type: 'bar',
                data: {
                    labels: times,
                    datasets: [{
                        label: '成交量',
                        data: volumes,
                        backgroundColor: volumeColors,
                        borderWidth: 0
                    }]
                },
                options: {
                    responsive: true,
                    maintainAspectRatio: false,
                    interaction: {
                        mode: 'index',
                        intersect: false
                    },
                    onHover: (event, elements) => {
                        if (elements.length > 0) {
                            hoverIndex = elements[0].index;
                            stockChart.update('none');
                            volumeChart.update('none');
                        }
                    },
                    plugins: {
                        legend: {
                            display: false
                        },
                        tooltip: {
                            mode: 'index',
                            intersect: false,
                            callbacks: {
                                label: function (context) {
                                    const volume = context.parsed.y;
                                    return `成交量: ${volume.toLocaleString()}`;
                                }
                            }
                        }
                    },
                    scales: {
                        x: {
                            grid: {
                                display: false
                            },
                            ticks: {
                                display: false
                            }
                        },
                        y: {
                            grid: {
                                color: 'rgba(0,0,0,0.1)'
                            },
                            ticks: {
                                callback: function (value) {
                                    if (value >= 1000000) {
                                        return (value / 1000000).toFixed(1) + 'M';
                                    } else if (value >= 1000) {
                                        return (value / 1000).toFixed(0) + 'K';
                                    }
                                    return value;
                                }
                            }
                        }
                    }
                },
                plugins: [{
                    id: 'verticalLineVolume',
                    afterDraw: (chart) => {
                        if (hoverIndex < 0) return;

                        const { ctx, chartArea } = chart;
                        if (!chartArea) return;

                        const scale = chart.scales.x;
                        const x = scale.getPixelForValue(hoverIndex);

                        if (x >= chartArea.left && x <= chartArea.right) {
                            ctx.save();
                            ctx.beginPath();
                            ctx.moveTo(x, chartArea.top);
                            ctx.lineTo(x, chartArea.bottom);
                            ctx.strokeStyle = 'rgba(0, 0, 0, 0.3)';
                            ctx.lineWidth = 1;
                            ctx.setLineDash([]);
                            ctx.stroke();
                            ctx.restore();
                        }
                    }
                }]
            });
        }
    </script>
</body>

</html>

Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)