Files
Eikwang 91d32c6ffa 增加更多的历史记录,修正部份API数据获取错误,增加备用API (#5)
* 增加更多的历史记录,修正部份数据获取错误

* 增加更多的历史记录,修正部份API数据获取错误,增加备用API

---------

Co-authored-by: bathfire <>
2025-10-29 16:22:18 +08:00

773 lines
28 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
持仓管理UI模块
提供持仓股票的增删改查、批量分析、定时任务管理界面
"""
import streamlit as st
import pandas as pd
from datetime import datetime
from typing import List, Dict
import time
from portfolio_manager import portfolio_manager
from portfolio_scheduler import portfolio_scheduler
def display_portfolio_manager():
"""显示持仓管理主界面"""
st.markdown("## 📊 持仓定时分析")
st.markdown("---")
# 创建标签页
tab1, tab2, tab3, tab4 = st.tabs([
"📝 持仓管理",
"🔄 批量分析",
"⏰ 定时任务",
"📈 分析历史"
])
with tab1:
display_portfolio_stocks()
with tab2:
display_batch_analysis()
with tab3:
display_scheduler_management()
with tab4:
display_analysis_history()
def display_portfolio_stocks():
"""显示持仓股票列表和管理"""
st.markdown("### 📝 持仓股票管理")
# 添加新股票表单
with st.expander(" 添加持仓股票", expanded=False):
display_add_stock_form()
# 获取所有持仓股票
stocks = portfolio_manager.get_all_stocks()
if not stocks:
st.info("暂无持仓股票,请添加股票代码开始管理。")
return
# 显示统计
col1, col2, col3 = st.columns(3)
with col1:
st.metric("持仓股票数", len(stocks))
with col2:
auto_monitor_count = sum(1 for s in stocks if s.get("auto_monitor"))
st.metric("启用自动监测", auto_monitor_count)
with col3:
total_cost = sum(
s.get("cost_price", 0) * s.get("quantity", 0)
for s in stocks
if s.get("cost_price") and s.get("quantity")
)
st.metric("总持仓成本", f{total_cost:,.2f}")
st.markdown("---")
# 显示股票列表(卡片式布局)
for stock in stocks:
display_stock_card(stock)
def display_stock_card(stock: Dict):
"""显示单个股票卡片"""
stock_id = stock.get("id") # 获取股票ID
code = stock.get("code", "")
name = stock.get("name", "")
cost_price = stock.get("cost_price")
quantity = stock.get("quantity")
note = stock.get("note", "")
auto_monitor = stock.get("auto_monitor", True)
created_at = stock.get("created_at", "")
# 创建卡片
with st.container():
col1, col2, col3, col4 = st.columns([3, 2, 2, 2])
with col1:
st.markdown(f"**{code}** {name}")
if note:
st.caption(f"备注: {note}")
with col2:
if cost_price and quantity:
st.write(f"成本: ¥{cost_price:.2f}")
st.caption(f"数量: {quantity}股")
else:
st.caption("未设置持仓")
with col3:
if auto_monitor:
st.success("🔔 自动监测")
else:
st.info("🔕 不监测")
with col4:
col_edit, col_del = st.columns(2)
with col_edit:
if st.button("✏️", key=f"edit_{code}", help="编辑"):
st.session_state[f"editing_{code}"] = True
st.rerun()
with col_del:
if st.button("🗑️", key=f"del_{code}", help="删除"):
portfolio_manager.delete_stock(stock_id) # 使用stock_id而不是code
st.success(f"已删除 {code}")
time.sleep(0.5)
st.rerun()
# 编辑表单(如果处于编辑状态)
if st.session_state.get(f"editing_{code}"):
with st.form(key=f"edit_form_{code}"):
st.markdown(f"#### 编辑 {code}")
col_a, col_b = st.columns(2)
with col_a:
new_cost = st.number_input(
"成本价",
value=cost_price if cost_price else 0.0,
min_value=0.0,
step=0.01
)
new_quantity = st.number_input(
"持仓数量",
value=quantity if quantity else 0,
min_value=0,
step=100
)
with col_b:
new_note = st.text_area("备注", value=note, height=80)
new_auto_monitor = st.checkbox("自动同步到监测", value=auto_monitor)
col_submit, col_cancel = st.columns(2)
with col_submit:
if st.form_submit_button("保存", type="primary"):
portfolio_manager.update_stock(
stock_id, # 使用stock_id而不是code
cost_price=new_cost if new_cost > 0 else None,
quantity=new_quantity if new_quantity > 0 else None,
note=new_note,
auto_monitor=new_auto_monitor
)
del st.session_state[f"editing_{code}"]
st.success("更新成功!")
time.sleep(0.5)
st.rerun()
with col_cancel:
if st.form_submit_button("取消"):
del st.session_state[f"editing_{code}"]
st.rerun()
st.markdown("---")
def display_add_stock_form():
"""显示添加股票表单"""
with st.form(key="add_stock_form"):
col1, col2 = st.columns(2)
with col1:
code = st.text_input(
"股票代码*",
placeholder="例如: 600519.SH 或 000001.SZ",
help="必填,格式:代码.市场(SH/SZ/HK/US"
)
name = st.text_input(
"股票名称",
placeholder="例如: 贵州茅台",
help="可选,留空将自动获取"
)
with col2:
cost_price = st.number_input(
"成本价",
min_value=0.0,
step=0.01,
help="可选,用于计算收益"
)
quantity = st.number_input(
"持仓数量",
min_value=0,
step=100,
help="可选,单位:股"
)
note = st.text_area("备注", height=80, placeholder="可选,记录买入理由等信息")
auto_monitor = st.checkbox("分析后自动同步到监测", value=True)
if st.form_submit_button(" 添加股票", type="primary"):
if not code:
st.error("请输入股票代码")
else:
try:
portfolio_manager.add_stock(
code=code.strip().upper(),
name=name.strip() if name else None,
cost_price=cost_price if cost_price > 0 else None,
quantity=quantity if quantity > 0 else None,
note=note.strip() if note else None,
auto_monitor=auto_monitor
)
st.success(f"✅ 已添加 {code} 到持仓列表")
time.sleep(0.5)
st.rerun()
except Exception as e:
st.error(f"添加失败: {str(e)}")
def display_batch_analysis():
"""显示批量分析功能"""
st.markdown("### 🔄 批量分析持仓股票")
stocks = portfolio_manager.get_all_stocks()
if not stocks:
st.warning("暂无持仓股票,请先添加股票。")
return
# 分析选项
col1, col2, col3 = st.columns(3)
with col1:
st.metric("持仓股票数", len(stocks))
with col2:
analysis_mode = st.selectbox(
"分析模式",
options=["sequential", "parallel"],
format_func=lambda x: "顺序分析" if x == "sequential" else "并行分析",
help="顺序分析较慢但稳定,并行分析更快但消耗更多资源"
)
with col3:
if analysis_mode == "parallel":
max_workers = st.number_input(
"并行线程数",
min_value=2,
max_value=10,
value=3,
help="同时分析的股票数量"
)
else:
max_workers = 1
st.markdown("---")
# 同步和通知选项
col_a, col_b = st.columns(2)
with col_a:
auto_sync = st.checkbox(
"自动同步到监测",
value=True,
help="分析完成后自动将评级结果同步到实时监测列表"
)
with col_b:
send_notification = st.checkbox(
"发送完成通知",
value=True,
help="通过邮件或Webhook发送分析完成通知"
)
# 立即分析按钮
if st.button("🚀 立即开始分析", type="primary", width='content'):
with st.spinner("正在批量分析持仓股票..."):
# 显示进度
progress_bar = st.progress(0)
status_text = st.empty()
# 执行批量分析
try:
# 定义进度回调函数
def update_progress(current, total, code, status):
progress_bar.progress(current / total)
status_map = {
"analyzing": "正在分析",
"success": "✅ 完成",
"failed": "❌ 失败",
"error": "⚠️ 错误"
}
status_text.text(f"{status_map.get(status, '处理中')} {code} ({current}/{total})")
result = portfolio_manager.batch_analyze_portfolio(
mode=analysis_mode,
max_workers=max_workers,
progress_callback=update_progress
)
# 清除进度显示
progress_bar.empty()
status_text.empty()
# 显示结果
st.success(f"✅ 批量分析完成!")
col_r1, col_r2, col_r3, col_r4 = st.columns(4)
with col_r1:
st.metric("总计", result.get("total", 0))
with col_r2:
st.metric("成功", result.get("succeeded", 0))
with col_r3:
st.metric("失败", result.get("failed", 0))
with col_r4:
st.metric("耗时", f"{result.get('elapsed_time', 0):.1f}秒")
# 保存分析结果到数据库
saved_ids = portfolio_manager.save_analysis_results(result)
st.info(f"💾 已保存 {len(saved_ids)} 条分析记录到数据库")
# 同步到监测
sync_result = None # 初始化同步结果
if auto_sync:
with st.spinner("正在同步到监测列表..."):
from monitor_db import monitor_db
# 准备同步数据
monitors_to_sync = []
for item in result.get("results", []):
# 检查分析是否成功
if not item.get("result", {}).get("success"):
continue
code = item["code"]
stock = portfolio_manager.db.get_stock_by_code(code)
# 只同步启用了自动监测的股票
if not stock or not stock.get("auto_monitor"):
continue
analysis_result = item["result"]
stock_info = analysis_result.get("stock_info", {})
final_decision = analysis_result.get("final_decision", {})
# 从final_decision中提取数据
rating = final_decision.get("rating", "持有")
entry_range = final_decision.get("entry_range", "")
take_profit_str = final_decision.get("take_profit", "")
stop_loss_str = final_decision.get("stop_loss", "")
# 解析进场区间(格式如"10.5-12.3"
entry_min, entry_max = None, None
if entry_range and isinstance(entry_range, str) and "-" in entry_range:
try:
parts = entry_range.split("-")
entry_min = float(parts[0].strip())
entry_max = float(parts[1].strip())
except:
pass
# 解析止盈止损(提取数字)
import re
take_profit, stop_loss = None, None
if take_profit_str:
try:
numbers = re.findall(r'\d+\.?\d*', str(take_profit_str))
if numbers:
take_profit = float(numbers[0])
except:
pass
if stop_loss_str:
try:
numbers = re.findall(r'\d+\.?\d*', str(stop_loss_str))
if numbers:
stop_loss = float(numbers[0])
except:
pass
# 只有当所有必需字段都有效时才添加
if entry_min and entry_max and take_profit and stop_loss:
monitors_to_sync.append({
"code": code,
"name": stock_info.get("name", stock.get("name", "")),
"rating": rating,
"entry_min": entry_min,
"entry_max": entry_max,
"take_profit": take_profit,
"stop_loss": stop_loss
})
if monitors_to_sync:
sync_result = monitor_db.batch_add_or_update_monitors(monitors_to_sync)
st.info(f"📊 监测同步: 新增 {sync_result.get('added', 0)} 只, 更新 {sync_result.get('updated', 0)} 只")
else:
sync_result = {"added": 0, "updated": 0, "failed": 0, "total": 0}
st.info("📊 无需同步监测列表(无启用自动监测的股票)")
# 发送通知
if send_notification:
from notification_service import notification_service
notification_service.send_portfolio_analysis_notification(
result,
sync_result if auto_sync else None
)
st.info("✉️ 已发送完成通知")
# 显示详细结果
st.markdown("### 分析结果详情")
for item in result.get("results", []):
display_analysis_result_card(item)
except Exception as e:
st.error(f"批量分析失败: {str(e)}")
import traceback
st.code(traceback.format_exc())
def display_analysis_result_card(item: Dict):
"""显示单个分析结果卡片"""
code = item.get("code", "")
result = item.get("result", {})
# 检查分析是否成功
if result.get("success"):
final_decision = result.get("final_decision", {})
stock_info = result.get("stock_info", {})
# 使用正确的字段名
rating = final_decision.get("rating", "未知")
confidence = final_decision.get("confidence_level", "N/A")
target_price = final_decision.get("target_price", "N/A")
entry_range = final_decision.get("entry_range", "N/A")
take_profit = final_decision.get("take_profit", "N/A")
stop_loss = final_decision.get("stop_loss", "N/A")
# 评级颜色
if "强烈买入" in rating or "买入" in rating:
rating_color = "🟢"
elif "卖出" in rating:
rating_color = "🔴"
else:
rating_color = "🟡"
with st.expander(f"{rating_color} {code} {stock_info.get('name', '')} - {rating} (信心度: {confidence})"):
col1, col2 = st.columns(2)
with col1:
st.markdown("**进出场位置**")
st.write(f"进场区间: {entry_range}")
st.write(f"目标价: {target_price}")
with col2:
st.markdown("**风控位置**")
st.write(f"止盈位: {take_profit}")
st.write(f"止损位: {stop_loss}")
# 投资建议
advice = final_decision.get("advice", "")
if advice:
st.markdown("**投资建议**")
st.info(advice)
else:
# 分析失败
error = result.get("error", "未知错误")
with st.expander(f"🔴 {code} - 分析失败"):
st.error(f"错误: {error}")
def display_scheduler_management():
"""显示定时任务管理"""
st.markdown("### ⏰ 定时任务管理")
# 调度器状态
is_running = portfolio_scheduler.is_running()
schedule_times = portfolio_scheduler.get_schedule_times()
col1, col2, col3 = st.columns(3)
with col1:
if is_running:
st.success("🟢 调度器运行中")
else:
st.error("🔴 调度器已停止")
with col2:
st.info(f"⏰ 定时数量: {len(schedule_times)}个")
with col3:
next_run = portfolio_scheduler.get_next_run_time()
if next_run:
st.info(f"⏭️ 下次运行: {next_run}")
else:
st.info("⏭️ 下次运行: 未设置")
st.markdown("---")
# 显示所有定时时间点
st.markdown("#### 📋 已配置的定时时间")
if schedule_times:
cols_per_row = 4
for i in range(0, len(schedule_times), cols_per_row):
cols = st.columns(cols_per_row)
for j, col in enumerate(cols):
idx = i + j
if idx < len(schedule_times):
time_str = schedule_times[idx]
with col:
col_time, col_del = st.columns([3, 1])
with col_time:
st.info(f"⏰ {time_str}")
with col_del:
if st.button("🗑️", key=f"del_time_{idx}", help="删除"):
if len(schedule_times) > 1:
portfolio_scheduler.remove_schedule_time(time_str)
st.success(f"已删除 {time_str}")
time.sleep(0.3)
st.rerun()
else:
st.error("至少保留一个定时时间")
else:
st.warning("暂无定时配置")
# 添加新的定时时间
with st.expander(" 添加定时时间", expanded=False):
col_input, col_add = st.columns([3, 1])
with col_input:
new_time = st.time_input(
"选择时间",
value=datetime.strptime("15:05", "%H:%M").time(),
help="添加新的每日分析时间"
)
with col_add:
st.write("") # 占位,对齐按钮
st.write("")
if st.button(" 添加", type="primary", width='content'):
time_str = new_time.strftime("%H:%M")
if portfolio_scheduler.add_schedule_time(time_str):
st.success(f"已添加 {time_str}")
time.sleep(0.3)
st.rerun()
else:
st.warning(f"{time_str} 已存在")
st.markdown("---")
# 任务配置
with st.form(key="scheduler_config_form"):
st.markdown("#### 分析配置")
col_a, col_b = st.columns(2)
with col_a:
analysis_mode = st.selectbox(
"分析模式",
options=["sequential", "parallel"],
format_func=lambda x: "顺序分析" if x == "sequential" else "并行分析",
index=0 if portfolio_scheduler.analysis_mode == "sequential" else 1
)
with col_b:
max_workers = st.number_input(
"并行线程数",
min_value=2,
max_value=10,
value=portfolio_scheduler.max_workers,
disabled=(analysis_mode == "sequential"),
help="仅在并行模式下生效"
)
auto_sync_monitor = st.checkbox(
"自动同步到监测",
value=portfolio_scheduler.auto_monitor_sync,
help="分析完成后自动将结果同步到实时监测列表"
)
send_notification = st.checkbox(
"发送完成通知",
value=portfolio_scheduler.notification_enabled,
help="通过邮件或Webhook发送分析结果"
)
col_update, col_reset = st.columns(2)
with col_update:
if st.form_submit_button("💾 更新配置", type="primary"):
portfolio_scheduler.update_config(
analysis_mode=analysis_mode,
max_workers=max_workers if analysis_mode == "parallel" else 1,
auto_sync_monitor=auto_sync_monitor,
send_notification=send_notification
)
st.success("配置已更新!")
time.sleep(0.5)
st.rerun()
with col_reset:
if st.form_submit_button("🔄 恢复默认"):
portfolio_scheduler.set_schedule_times(["09:30"])
portfolio_scheduler.update_config(
analysis_mode="sequential",
max_workers=1,
auto_sync_monitor=True,
send_notification=True
)
st.success("已恢复默认配置!")
time.sleep(0.5)
st.rerun()
st.markdown("---")
# 控制按钮
col_btn1, col_btn2, col_btn3 = st.columns(3)
with col_btn1:
if is_running:
if st.button("⏹️ 停止调度器", type="secondary", width='content'):
portfolio_scheduler.stop_scheduler()
st.success("调度器已停止")
time.sleep(0.5)
st.rerun()
else:
if st.button("▶️ 启动调度器", type="primary", width='content'):
portfolio_scheduler.start_scheduler()
st.success("调度器已启动")
time.sleep(0.5)
st.rerun()
with col_btn2:
if st.button("🚀 立即执行一次", type="primary", width='content'):
with st.spinner("正在执行持仓分析..."):
try:
portfolio_scheduler.run_analysis_now()
st.success("执行完成!请查看分析历史。")
except Exception as e:
st.error(f"执行失败: {str(e)}")
with col_btn3:
if st.button("🔄 刷新状态", width='content'):
st.rerun()
def display_analysis_history():
"""显示分析历史"""
st.markdown("### 📈 分析历史记录")
stocks = portfolio_manager.get_all_stocks()
if not stocks:
st.info("暂无持仓股票")
return
# 选择股票
stock_codes = [s["code"] for s in stocks]
selected_code = st.selectbox(
"选择股票",
options=["全部"] + stock_codes,
help="查看特定股票的分析历史"
)
# 获取历史记录
if selected_code == "全部":
# 获取所有股票的最新历史
all_history = []
for stock in stocks:
stock_id = stock["id"]
history = portfolio_manager.db.get_latest_analysis_history(stock_id, limit=5)
for h in history:
h["code"] = stock["code"]
h["name"] = stock["name"]
all_history.extend(history)
# 按时间排序
all_history.sort(key=lambda x: x.get("analysis_time", ""), reverse=True)
history_list = all_history[:20] # 只显示最近20条
else:
# 获取指定股票的历史
stock = next((s for s in stocks if s["code"] == selected_code), None)
if stock:
history_list = portfolio_manager.db.get_latest_analysis_history(
stock["id"], limit=20
)
for h in history_list:
h["code"] = stock["code"]
h["name"] = stock["name"]
else:
history_list = []
if not history_list:
st.info(f"暂无分析历史记录")
return
# 显示历史记录
st.markdown(f"共 {len(history_list)} 条记录")
for record in history_list:
display_history_record(record)
def display_history_record(record: Dict):
"""显示单条历史记录"""
code = record.get("code", "")
name = record.get("name", "")
analysis_time = record.get("analysis_time", "")
rating = record.get("rating", "未知")
confidence = record.get("confidence", 0)
current_price = record.get("current_price")
target_price = record.get("target_price")
entry_min = record.get("entry_min")
entry_max = record.get("entry_max")
take_profit = record.get("take_profit")
stop_loss = record.get("stop_loss")
summary = record.get("summary", "")
# 评级颜色
if "强烈买入" in rating or "买入" in rating:
rating_icon = "🟢"
elif "卖出" in rating:
rating_icon = "🔴"
else:
rating_icon = "🟡"
with st.expander(
f"{rating_icon} {code} {name} - {rating} | {analysis_time}",
expanded=False
):
col1, col2, col3 = st.columns(3)
with col1:
st.markdown("**价格信息**")
if current_price:
st.write(f"当时价格: ¥{current_price:.2f}")
if target_price:
st.write(f"目标价: ¥{target_price:.2f}")
with col2:
st.markdown("**进场区间**")
if entry_min and entry_max:
st.write(f{entry_min:.2f} ~ ¥{entry_max:.2f}")
with col3:
st.markdown("**风控位置**")
if take_profit:
st.write(f"止盈: ¥{take_profit:.2f}")
if stop_loss:
st.write(f"止损: ¥{stop_loss:.2f}")
if summary:
st.markdown("**分析摘要**")
st.info(summary)
st.caption(f"置信度: {confidence}%")