diff --git a/Plugin/GuanLan/.gitignore b/Plugin/GuanLan/.gitignore new file mode 100644 index 0000000..391d1f8 --- /dev/null +++ b/Plugin/GuanLan/.gitignore @@ -0,0 +1,15 @@ +# 敏感配置(用户私有,勿提交) +config.env + +# Python缓存 +__pycache__/ +*.pyc + +# 运行时数据文件(用户私有) +account.json +positions.json +trades.json +watchlist.json + +# 文件锁 +guanlan.lock \ No newline at end of file diff --git a/Plugin/GuanLan/GuanLan.txt b/Plugin/GuanLan/GuanLan.txt new file mode 100644 index 0000000..76eb5fe --- /dev/null +++ b/Plugin/GuanLan/GuanLan.txt @@ -0,0 +1,101 @@ +## 工具调用规则 +通过 GuanLan 插件调用 A股数据引擎(v3.2),支持的查询类型: + +### 快速查询 +- `realtime_quote`:个股实时行情 +- `batch_quotes`:批量行情 +- `stock_info`:基本信息(PE/PB/总市值/流通市值/换手率/量比/股息率,Tushare数据源) +- `sector_ranking`:行业板块涨跌排名(申万一级31个行业) + +### 技术分析 +- `kline_indicators`:K线 + 技术指标(MA5/10/20/60、MACD、RSI、布林带) + - pandas-ta可用时附加:KDJ、OBV、ATR、WR、CCI + - 自动计算均线排列、MACD金叉死叉、RSI超买超卖 + - 返回技术面综合判断 + +### 资金分析 +- `capital_flow`:个股/ETF资金流向(主力/超大单/大单/中单/小单,近5日趋势) + - 个股数据源:Tushare moneyflow + - ETF数据源:AKShare fund_etf_spot_em(自动识别5开头/159开头) + +### 综合分析 +- `full_analysis`:一次性拉取行情+技术面+基本面+资金面+daily_basic(PE/PB/换手率/量比) + - Agent整合所有数据后给出明确建议 + +### 自选股管理 +- `watchlist_add`:添加自选股(symbol + name) +- `watchlist_remove`:移除自选股 +- `watchlist_show`:查看当前自选股列表 + +### 持仓管理(v2.6+) +- `position_add`:买入建仓(symbol + cost + shares + stop_loss + target + reason) + - 自动扣减可用资金(成本×数量+佣金5元) + - 自动写入交易记录 +- `position_close`:卖出平仓(symbol + sell_price + shares + reason) + - 自动计算盈亏、持有天数 + - 自动返还可用资金(卖出价×数量-佣金) + - 自动写入交易记录 +- `position_show`:查看当前活跃持仓 +- `position_update`:更新活跃持仓的风控参数(symbol + stop_loss + target) + - 秒级更新,不走文件修改,无安全审批拦截 + +### 交易记录(v2.6+) +- `trade_history`:查看完整交易历史(可按symbol筛选) +- `trade_stats`:胜率统计(胜率/总盈亏/平均持有天数/最佳最差交易) + +### 账户资金(v2.6+) +- `account_set`:设置总资本和可用资金 +- `account_show`:查看账户资金(总资本/可用资金) + +### 组合概览(v2.6+) +- `portfolio_summary`:一次性拉取持仓+实时价格+盈亏+距止损/目标距离+账户资金+总资产+总盈亏+ATR动态止损参考线 + - **当用户问"我的持仓"或"账户情况"时,直接用这个命令** + +### 盯盘扫描(v2.0) +- `scan_anomalies`:7类异动检测 + 持仓监控(止损/目标触发提醒) + - 自动读取watchlist + positions,返回severity分级结果(normal/medium/high/critical) + - 检测类型:涨跌停逼近、放量突破/跌破MA20/MA60、主力资金异动、独立走势、高换手率、量价背离 +- `daily_report`:盘后日报,自选股当日表现+次日关注要点 + +### 选股框架(v3.0) +- `market_check`:大盘环境判断(沪深300趋势:多头/空头/缠绕 → 对应操作建议) +- `stock_screen`:个股三问筛选 + 禁买清单 + - 禁买:市值<100亿、RSI>70、价格>布林上轨、均线空头排列 + - 三问:Q1趋势向上?Q2公司能赚钱(ROE/PE/PB)?Q3价格合理(PE/RSI/MACD)? + - 返回 PASS / REJECT + 具体原因 + +### 回测引擎(v3.1) +- `backtest`:策略历史回测(symbol + params: start_date/end_date/strategy/initial_capital) + - 4种策略:ma_cross(MA5/MA20金叉死叉) / macd(MACD柱翻转) / rsi(超买超卖) / boll(布林通道) + - 输出:年化收益率/最大回撤/夏普比率/胜率/交易明细/净值曲线采样 + - 回测允许零股交易,纯粹反映信号质量 + +### 事件型异动扫描(v3.1) +- `scan_events`:4类事件异动聚合扫描(symbols + params: days) + - 龙虎榜异动 / 大宗交易折溢价 / 限售解禁预警 / 业绩预告触发 +- `lhb_detail`:个股龙虎榜明细(symbol + params: days) +- `block_trade`:个股大宗交易明细(symbol + params: days) +- `share_unlock`:全市场限售解禁排名 +- `earnings_forecast`:业绩预告(params: date,自动推断报告期) + +### 舆情分析(v3.1) +- `sentiment_scan`:个股/批量舆情聚合(symbol 或 symbols) + - 5维度:机构参与度/综合评价/关注度/买入欲望(参与意愿)/热门概念 +- `sentiment_rank`:全市场人气排名TOP100 + +### 组合压力测试(v3.1) +- `stress_test`:历史极端行情模拟(params: scenario 或 custom_drop) + - 5预设场景:crash_2015(股灾-35%) / crash_2024(1月雪崩-12%) / trade_war(贸易战-25%) / covid_2020(疫情-16%) / 924_surge(+25%) + - 用Beta模型估算个股压力(个股跌幅=大盘跌幅×行业Beta) + - 自定义:custom_drop参数指定任意涨跌幅 + +### 板块轮动追踪(v3.1) +- `sector_rotation`:31个申万一级行业动量分析 + - 计算5日/10日/20日累计涨跌幅 + - 6类信号:accelerating(加速流入) / decelerating(加速流出) / uptrend(持续上行) / downtrend(持续下行) / rebounding(超跌反弹) / pulling_back(高位回调) + - 返回Top5强势/弱势 + 全行业明细 + +### 市场温度计与增强(v3.2) +- `market_temperature`:市场情绪综合指标(涨跌停+涨跌比+换手率+成交额→0-100评分) +- `trade_stats_monthly`:月度复盘(按月/胜率/盈亏比统计) +- `update_trailing_stops`:批量更新持仓的ATR动态跟踪止损线 \ No newline at end of file diff --git a/Plugin/GuanLan/README.md b/Plugin/GuanLan/README.md new file mode 100644 index 0000000..01555c9 --- /dev/null +++ b/Plugin/GuanLan/README.md @@ -0,0 +1,304 @@ +# 📊 GuanLan - A股智能数据引擎 + +> **为 AI-Agent 打造的 A股量化感知器官。** 让你的 Agent 在对话流中一句话完成查行情、扫异动、跑回测、看持仓、测压力——不需要打开 VNpy 终端,不需要写 Backtrader 脚本,不需要登录同花顺。 + +**版本** v3.2.2 | **作者** 观澜 & 冬竹子(翔) | **协议** CC BY-NC-SA 4.0 + +--- + +## 🎯 这是什么? + +GuanLan 是一个 [VCPToolBox](https://github.com/lioensky/VCPToolBox) 插件,赋予 AI-Agent 专业级 A股投研分析能力。 + +它不是一个量化交易框架,不接实盘——这是**安全设计而非缺陷**。它的定位是**Agent 的量化感知层**:在对话中实时获取行情、技术面、基本面、资金面、事件异动、市场情绪等全维度数据,辅助用户做出更理性的投资决策。 + +### 与市面产品的区别 + +| 维度 | VNpy / QMT | QuantConnect / Backtrader | 同花顺 / 东方财富 | **GuanLan** | +|------|-----------|--------------------------|-----------------|-------------| +| 定位 | 实盘量化交易框架 | 专业回测平台 | 行情终端 | **Agent量化感知器官** | +| 交互方式 | Python API / CTP | Python 脚本 | GUI 点击 | **自然语言对话** | +| 实盘交易 | ✅ 支持 | ❌ | 仅信号 | ❌(安全设计) | +| 回测引擎 | ✅ 专业级 | ✅ Walk-Forward | ❌ | ✅ 轻量级(4策略) | +| AI 集成 | 需自行开发 | 需自行开发 | ❌ | **原生 Agent 工具** | +| 数据容灾 | 单源 | 单源 | 单源 | **双引擎自动切换** | +| 部署成本 | 高(C++底层) | 中(云端) | 安装即用 | **pip install 即用** | + +**核心差异**:VNpy 是给人写的,GuanLan 是给 Agent 用的。用户只需要对 Agent 说"分析一下紫金矿业",Agent 自动调用 `full_analysis` 拉取四维度数据并给出明确建议。 + +--- + +## ✨ 核心特性 + +### 1. DataHub 双引擎容灾(无单点故障) + +三层数据架构,每个关键函数都有 fallback: + +``` +新浪财经API(实时行情/K线,秒级响应) + ↓ 失败时 +Tushare HTTP API(基本面/资金流向/板块/指数) + ↓ 失败时 +AKShare 容灾层(自动接管,5个降级函数覆盖全部关键维度) +``` + +这不是简单的 try/except——每个容灾函数**重新解析数据格式**,确保降级后字段语义一致。主流框架通常只支持单一数据源,GuanLan 在 A股数据源频繁变更的环境下实现了真正的生产级高可用。 + +### 2. 真实A股费率引擎(v3.2.2) + +内置与真实券商对齐的费率计算模型,盈亏复盘精准到分: +- **佣金**:支持配置(股票/ETF分别设置,默认万2.5,最低5元) +- **印花税**:千1(仅卖出收取,ETF免收) +- **过户费**:万0.1(仅沪市收取) +买入时手续费自动摊入持仓成本价,卖出时自动计算净到手金额,胜率和盈亏比统计与实盘完全一致。 + +### 3. filelock 跨进程并发安全 + +`@synchronized_data` 装饰器 + FileLock,保护状态读写函数。当多个 Agent 并发调用 `position_add` 和 `position_close` 时,文件锁自动串行化,彻底杜绝 JSON 读写竞态(Race Condition)。同时支持 `position_update` 秒级更新止损/目标价(插件原生命令,不触发系统文件审批拦截)。 + +### 3. 38 个专业命令(覆盖投研全链路) + +| 分类 | 命令 | 核心能力 | +|------|------|---------| +| **基础行情** | `realtime_quote` / `batch_quotes` / `stock_info` | 秒级行情、批量查询、PE/PB/市值 | +| **技术分析** | `kline_indicators` / `full_analysis` | MA/MACD/RSI/布林带/KDJ/OBV/ATR/WR/CCI | +| **资金分析** | `capital_flow` | 主力/超大单/大单/中单/小单 5层资金流向 | +| **ETF 适配** | `capital_flow`(ETF分支) | 自动识别 5/159 开头,含换手率/折价率/净份额 | +| **持仓管理** | `position_add` / `position_close` / `position_update` / `portfolio_summary` | 建仓/平仓/部分减仓/更新止损目标、真实费率算盈亏、ATR动态止损 | +| **交易记录** | `trade_history` / `trade_stats` / `trade_stats_monthly` | 完整流水、胜率统计、按月盈亏比 | +| **异动扫描** | `scan_anomalies` / `scan_events` | 7类技术异动 + 4类事件异动(龙虎榜/大宗/解禁/业绩预告) | +| **舆情分析** | `sentiment_scan` / `sentiment_rank` | 5维度情绪聚合:机构参与度/评价/关注/买入欲望/概念热度 | +| **压力测试** | `stress_test` | 5历史极端场景(2015股灾/2024雪崩等)+ 自定义跌幅 | +| **板块轮动** | `sector_rotation` | 31行业 5/10/20日动量,6类信号分类 | +| **市场温度计** | `market_temperature` | 涨跌停+涨跌比+换手率+成交额 → 0-100评分 | +| **ATR止损** | `update_trailing_stops` | 基于ATR(14)×2.5的跟踪止损,只上移不下移 | +| **策略回测** | `backtest` | MA交叉/MACD/RSI/布林 4策略,输出年化/回撤/夏普/胜率 | +| **选股框架** | `stock_screen` / `market_check` | 大盘环境判断 + 三问筛选 + 禁买清单 | + +### 4. A股特性深度适配 + +- **龙虎榜/大宗交易/限售解禁/业绩预告**:A股信息驱动市场的核心事件类型,主流海外平台无直接竞品 +- **ETF 专项数据流**:自动识别 ETF 代码,切换至东财实时快照(含折价率/IOPV/净份额) +- **申万一级行业分类**:板块轮动追踪基于申万 31 个一级行业,非东财概念板块 + +--- + +## 📦 安装与配置 + +### 1. 依赖安装 + +```bash +pip install -r requirements.txt +``` + +建议额外安装 `pandas-ta`(KDJ/OBV/ATR 等扩展指标): +```bash +pip install pandas-ta +``` +> 未安装 pandas-ta 时自动降级为纯 numpy 计算,不影响基础功能。 + +### 2. 数据源配置(可选) + +**开箱即用**:新浪行情 API + AKShare 完全免费,无需任何配置即可使用大部分功能。 + +**启用高级功能**(精确 PE/PB、主力资金流向、板块轮动历史数据): +1. 复制 `config.env.example` 为 `config.env` +2. 在 [Tushare](https://tushare.pro/) 注册(免费)获取 Token +3. 填入配置: + ```env + TUSHARE_TOKEN=你的Token + + # 佣金费率(用于精确计算盈亏,默认万2.5) + BROKER_COMMISSION_STOCK=0.00025 + BROKER_COMMISSION_ETF=0.00025 + ``` +> 未配置 Token 时,相关功能自动降级到 AKShare 容灾层。 +> 未配置佣金费率时,默认使用万2.5,印花税和过户费按国家统一标准自动计算。 + +### 3. 在 VCPToolBox 中配置与使用 + +为了让 Agent 知道如何调用插件,需将压缩包内的 `GuanLan.txt`(工具说明文件)注入到 Agent 的系统提示词中: + +1. **放置工具说明**:将 `GuanLan.txt` 放入 VCPToolBox 的 `TVStxt/` 目录(或您存放全局工具说明的目录)。 +2. **注册全局变量**:在 VCPToolBox 的 `config.env` 中添加变量指向该文件:`VarGuanLan=GuanLan.txt`。 +3. **Agent 设定注入**:在您的 Agent 设定文件(如 `Agent/YourAgent.txt`)的“————工具箱————”区域,添加占位符:`{{VarGuanLan}}`。 + +完成上述配置后,Agent 即可在对话中根据用户的自然语言意图自动调用相关指令。 + +#### 💬 使用示例 + +**场景1:个股综合分析** +> 用户:"分析一下紫金矿业" +> Agent 自动调用 `full_analysis` → 整合行情+技术面+基本面+资金面 → 输出明确建议(偏多/偏空/观望 + 入场价 + 止损线 + 信心度) + +**场景2:盘中异动监控** +> 用户:"今天自选股有什么异动?" +> Agent 自动调用 `scan_anomalies` → 扫描7类技术异动 + 持仓止损/目标触发 → 返回severity分级警报 + +**场景3:策略回测验证** +> 用户:"茅台用RSI策略回测一下去年表现" +> Agent 自动调用 `backtest`(strategy=rsi, 2024全年)→ 输出年化收益率/最大回撤/夏普比率/胜率/交易明细 + +**场景4:风控压力测试** +> 用户:"如果再来一次2015股灾,我的持仓会亏多少?" +> Agent 自动调用 `stress_test`(scenario=crash_2015)→ 用行业Beta模型估算每只持仓的压力损失 + +**场景5:板块轮动追踪** +> 用户:"最近哪些行业资金在流入?" +> Agent 自动调用 `sector_rotation` → 31个申万行业5/10/20日动量分析 → 返回加速流入/持续上行/超跌反弹等信号分类 + +**场景6:持仓管理与复盘** +> 用户:"买入200股紫金矿业,成本28.9,止损27,目标31" +> Agent 自动调用 `position_add` → 扣减可用资金 + 写入交易记录 +> 用户:"我的持仓情况怎么样?" +> Agent 自动调用 `portfolio_summary` → 实时盈亏 + 距止损/目标距离 + ATR动态止损参考线 + 总资产 +> 用户:"这个月交易复盘一下" +> Agent 自动调用 `trade_stats_monthly` → 按月统计胜率/盈亏比/总盈亏 + +**场景7:市场全景感知** +> 用户:"今天市场情绪怎么样?" +> Agent 自动调用 `market_temperature` → 涨跌停+涨跌比+换手率+成交额 → 0-100评分(极热/偏热/中性/偏冷/极冷) + +**场景8:事件驱动扫描** +> 用户:"我的自选股最近有龙虎榜或大宗交易吗?" +> Agent 自动调用 `scan_events` → 扫描龙虎榜/大宗交易/限售解禁/业绩预告4类事件异动 + +**场景9:市场情绪感知** +> 用户:"紫金矿业现在市场情绪怎么样?散户和机构怎么看?" +> Agent 自动调用 `sentiment_scan` → 5维度情绪聚合(机构参与度/综合评价/关注度/买入欲望/热门概念)→ 输出情绪温度和题材热度分布 +> 进阶用法:买入欲望>80警惕散户接盘;关注度低+评价高=左侧逆向机会;题材热度变化揭示市场交易逻辑切换 + +--- + +## 🤝 推荐搭配:TradingAgents-CN 深度投研 + +GuanLan 是**快速感知层**(秒级行情+技术面+异动扫描),但投资决策有时需要更深度的多维度研判。强烈推荐搭配 [TradingAgents-CN](https://github.com/hsliuping/TradingAgents-CN) 使用,形成**"快速感知 → 深度研判"两段式投研闭环**。 + +### 两者定位差异 + +| 维度 | GuanLan | TradingAgents-CN | +|------|---------|------------------| +| **定位** | Agent的量化感知器官 | 多Agent深度辩论投研引擎 | +| **响应速度** | 秒级(1-8秒) | 分钟级(8-10分钟) | +| **分析深度** | 技术面+基本面+资金面+事件面 | 4分析师多维度辩论+多空对弈+风控三方辩论 | +| **架构** | 单Agent工具调用 | 多Agent图(Market/Fundamental/News/Social → Bull/Bear → 投委会 → 风控) | +| **适合场景** | 日常盯盘、快速筛选、持仓管理 | 关键标的深度研判、买卖决策最终裁决 | + +### 配合使用流程 + +``` +日常盯盘:GuanLan scan_anomalies → 无异动 → 继续监控 + ↓ 发现异动/关键决策点 +深度研判:TradingAgents-CN 多Agent辩论(8-10分钟) + → 4分析师汇报 → 多空对弈 → 投委会裁决 → 风控三方辩论 → 最终决策 + ↓ 得出明确方向 +执行层: GuanLan position_add/close → 仓位管理 + ATR动态止损 + 盘后复盘 +``` + +**实战示例**: +1. GuanLan `scan_anomalies` 发现紫金矿业主力资金连续流入 + 放量突破MA20 +2. 触发 TradingAgents-CN 深度分析 → 4维度分析师辩论 → 裁决"买入,信心度0.75" +3. 回到 GuanLan `position_add` 建仓,设置止损/目标 +4. 持续用 GuanLan `portfolio_summary` 监控,ATR跟踪止损自动上移锁利润 +5. 月末用 `trade_stats_monthly` 复盘胜率和盈亏比 + +### TradingAgents-CN 部署要点 + +TradingAgents-CN 基于 Docker 部署(5容器:MongoDB + Redis + FastAPI + Vue3 + Nginx),通过 PowerShellExecutor 调用容器内 `ta_run4.py` 执行。Agent 在对话中自然语言触发即可("深度分析紫金矿业"),无需用户手动操作。详见 [TradingAgents-CN](https://github.com/hsliuping/TradingAgents-CN)。 + +--- + +## ⏰ 推荐搭配:VCP 定时任务实现全自动盯盘 + +GuanLan 的命令可以结合 VCPToolBox 的**定时任务系统(VCPTaskAssistant / AgentAssistant)**实现自动执行和主动推送。不需要用户盯着屏幕,Agent 会在关键时刻主动找你。 + +### 三种自动化场景 + +**场景1:盘中异动自动扫描(每30分钟)** +``` +定时任务配置:工作日 9:30-15:00 每30分钟唤醒Agent +Agent自动执行: + → scan_anomalies(7类技术异动检测) + → scan_events(4类事件异动检测) + → 有异动 → 通过 SmtpMailer 邮件推送 / AgentAssistant / 企业微信 等通知 + → 无异动 → 静默不打扰 +``` + +**场景2:持仓风控自动监控(实时)** +``` +定时任务配置:工作日盘中每15分钟唤醒Agent +Agent自动执行: + → portfolio_summary(持仓盈亏 + 距止损/目标距离) + → update_trailing_stops(ATR动态止损自动上移) + → 止损/目标触发 → 立即推送告警:"紫金矿业已跌破止损线27元" +``` + +**场景3:盘后自动复盘(每日收盘)** +``` +定时任务配置:工作日 15:30 自动唤醒Agent +Agent自动执行: + → market_temperature(当日市场情绪评分) + → daily_report(自选股表现 + 次日关注要点) + → 整合为日报邮件推送给用户 +``` + +### 配置要点 + +通过 VCPTaskAssistant 的 `custom_prompt + cron` 实现周期性任务。Agent 被唤醒后自动调用 GuanLan 命令,并根据结果判断是否需要通知用户(**有异动才打扰,无异动不打扰**)。 + +> **安全铁律**:定时任务唤醒的 Agent **严禁修改源码或文件**,只能执行数据查询和通知推送。任务模板中必须包含行为边界约束。 + +--- + +## 🏗️ 架构设计 + +### 数据流 + +``` +用户自然语言 → Agent 理解意图 → 选择命令 → main.py 路由 → 数据源适配层 → 结果JSON → Agent 整合 → 自然语言回复 +``` + +### 并发安全 + +```python +@synchronized_data # filelock 跨进程锁 +def position_close(symbol, sell_price, shares=None, ...): + with DATA_LOCK: # 自动获取/释放 + positions = read_positions() + # ... 安全读写 ... +``` + +### 双源降级示例 + +```python +def _get_daily_basic(symbol): + data = _tushare_api('daily_basic', ...) # 主源 + if not data: + return _ak_daily_basic(symbol) # 容灾接管 + return data +``` + +--- + +## ⚠️ 已知限制 + +| 限制 | 说明 | 影响 | +|------|------|------| +| **回测引擎为轻量级** | 全仓买卖模型、固定佣金、无 Walk-Forward 参数寻优。定位是"快速验证信号质量",非专业回测 | 过拟合风险,建议结果仅作参考 | +| **压力测试 Beta 为静态近似值** | 行业 Beta 为经验估值(31个行业硬编码),行业归属通过股票名称关键词猜测 | 压力测试结果为近似值 | +| **ETF 异动扫描部分失效** | AKShare 全市场行情对 ETF 代码覆盖不全 | 盘中异动扫描对 ETF 可能返回空 | +| **事件接口非交易时段超时** | AKShare 事件类接口底层爬东财/同花顺网页,周末响应缓慢 | 已加 try/except 降级,交易时段稳定 | +| **无实盘交易接口** | 设计上不接 CTP/券商 API | 安全设计,防止 Agent 自主下单 | + +--- + +## 📄 License + +CC BY-NC-SA 4.0 (Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International) - 遵循 VCPDistributedServer 仓库协议,欢迎社区贡献与二次开发。 + +## 🙏 致谢 + +- [VCPToolBox](https://github.com/lioensky/VCPToolBox) - 插件宿主框架 +- [AKShare](https://github.com/akfamily/akshare) - 开源金融数据接口 +- [Tushare](https://tushare.pro/) - 金融数据社区 +- [pandas-ta](https://github.com/twopirllc/pandas-ta) - 技术指标计算库 \ No newline at end of file diff --git a/Plugin/GuanLan/account.json b/Plugin/GuanLan/account.json new file mode 100644 index 0000000..dcb34a2 --- /dev/null +++ b/Plugin/GuanLan/account.json @@ -0,0 +1,5 @@ +{ + "total_capital": 0, + "available_cash": 0, + "updated": "" +} \ No newline at end of file diff --git a/Plugin/GuanLan/config.env.example b/Plugin/GuanLan/config.env.example new file mode 100644 index 0000000..8f2c1ed --- /dev/null +++ b/Plugin/GuanLan/config.env.example @@ -0,0 +1,17 @@ +# GuanLan 配置文件 +# 复制本文件为 config.env 并填入你自己的配置 + +# Tushare API Token(必需,用于基本面/资金流向/板块数据) +# 注册地址:https://tushare.pro +# 免费注册,部分高级接口需要积分(可通过完善个人信息等方式免费获取) +TUSHARE_TOKEN=你的Tushare-Token + +# 券商佣金费率配置(用于精确计算盈亏和可用资金) +# 默认万2.5(0.00025)。请修改为您自己券商的真实佣金费率(不足5元按5元计算) +# 印花税(千1)和过户费(万0.1)为国家统一标准,无需配置 +BROKER_COMMISSION_STOCK=0.00025 +BROKER_COMMISSION_ETF=0.00025 + +# 无需配置的数据源(开箱即用): +# - 新浪行情API:实时行情、K线数据(免费,无需注册) +# - AKShare:容灾降级层(免费,pip install akshare即用) \ No newline at end of file diff --git a/Plugin/GuanLan/main.py b/Plugin/GuanLan/main.py new file mode 100644 index 0000000..e547dce --- /dev/null +++ b/Plugin/GuanLan/main.py @@ -0,0 +1,3126 @@ +# -*- coding: utf-8 -*- +""" +观澜 Plugin A - A股数据引擎 (v3.2) +v3.2: 市场温度计+月度复盘+ATR跟踪止损+ETF适配+filelock跨进程锁 +v3.1: DataHub双源容灾+回测引擎+事件异动+舆情分析+压力测试+板块轮动 +v2.x: 新浪源+Tushare API+异动扫描+自选股+持仓管理+选股框架 +""" + +_PTA_AVAILABLE = False +try: + import pandas_ta as ta + _PTA_AVAILABLE = True +except ImportError: + pass + +import sys +import json +import time +import os +import traceback +from datetime import datetime, timedelta +from filelock import FileLock + +import akshare as ak +import pandas as pd +import numpy as np + +sys.stdout.reconfigure(encoding='utf-8') + +PLUGIN_DIR = os.path.dirname(os.path.abspath(__file__)) +DATA_LOCK = FileLock(os.path.join(PLUGIN_DIR, "guanlan.lock")) + +def synchronized_data(func): + """跨进程文件锁装饰器:防止并发调用导致的JSON读写竞态""" + def wrapper(*args, **kwargs): + with DATA_LOCK: + return func(*args, **kwargs) + return wrapper + +WATCHLIST_PATH = os.path.join(PLUGIN_DIR, "watchlist.json") +POSITIONS_PATH = os.path.join(PLUGIN_DIR, "positions.json") +TRADES_PATH = os.path.join(PLUGIN_DIR, "trades.json") +ACCOUNT_PATH = os.path.join(PLUGIN_DIR, "account.json") + + +def log(msg): + print(f"[GuanLan] {msg}", file=sys.stderr, flush=True) + + +def safe_request(func, *args, retries=1, delay=0.5, **kwargs): + for attempt in range(retries + 1): + try: + time.sleep(0.5) + result = func(*args, **kwargs) + return result + except Exception as e: + if attempt < retries: + log(f"重试{attempt+1}: {e}") + time.sleep(delay) + else: + raise e + + +# ========== 新浪数据源适配层 ========== + +import requests as _req + +def _sina_prefix(symbol): + """根据股票代码返回新浪前缀""" + if symbol.startswith(('sh', 'sz', 'bj')): + return symbol + if symbol.startswith(('6', '9', '5')): + return f"sh{symbol}" + elif symbol.startswith(('0', '3')): + return f"sz{symbol}" + elif symbol.startswith(('4', '8')): + return f"bj{symbol}" + else: + return f"sh{symbol}" + + +def _sina_realtime(symbol): + """新浪API获取单只股票实时行情""" + headers = {'Referer': 'https://finance.sina.com.cn', 'User-Agent': 'Mozilla/5.0'} + code = _sina_prefix(symbol) + r = _req.get(f'https://hq.sinajs.cn/list={code}', headers=headers, timeout=10) + r.encoding = 'gbk' + data = r.text.split('"')[1].split(',') + if len(data) < 10: + return None + name = data[0] + open_p = float(data[1]) if data[1] else 0 + prev_close = float(data[2]) if data[2] else 0 + price = float(data[3]) if data[3] else 0 + high = float(data[4]) if data[4] else 0 + low = float(data[5]) if data[5] else 0 + volume = float(data[8]) if data[8] else 0 + amount = float(data[9]) if data[9] else 0 + pct_chg = round((price - prev_close) / prev_close * 100, 2) if prev_close > 0 else 0 + amplitude = round((high - low) / prev_close * 100, 2) if prev_close > 0 else 0 + return { + "代码": symbol, + "名称": name, + "最新价": price, + "涨跌幅": pct_chg, + "成交额": amount, + "振幅": amplitude, + "最高": high, + "最低": low, + "昨收": prev_close, + "今开": open_p, + "成交量": volume, + } + + +def _sina_batch_quotes(symbols): + """新浪API批量获取实时行情""" + headers = {'Referer': 'https://finance.sina.com.cn', 'User-Agent': 'Mozilla/5.0'} + results = [] + batch_size = 80 + for i in range(0, len(symbols), batch_size): + batch_syms = symbols[i:i+batch_size] + codes = [_sina_prefix(s) for s in batch_syms] + query = ','.join(codes) + try: + r = _req.get(f'https://hq.sinajs.cn/list={query}', headers=headers, timeout=15) + r.encoding = 'gbk' + lines = r.text.strip().split('\n') + for j, line in enumerate(lines): + try: + if j >= len(batch_syms): + break + sym = batch_syms[j] + parts = line.split('"') + if len(parts) < 2: + continue + data = parts[1].split(',') + if len(data) < 10: + continue + name = data[0] + prev_close = float(data[2]) if data[2] else 0 + price = float(data[3]) if data[3] else 0 + pct_chg = round((price - prev_close) / prev_close * 100, 2) if prev_close > 0 else 0 + results.append({ + "代码": sym, + "名称": name, + "最新价": round(price, 2), + "涨跌幅": pct_chg, + }) + except: + continue + except: + continue + time.sleep(0.3) + return results + + +def _sina_kline(symbol, datalen=120): + """新浪API获取日K线数据(秒级响应,替代AKShare盘中卡死问题)""" + headers = {'Referer': 'https://finance.sina.com.cn', 'User-Agent': 'Mozilla/5.0'} + code = _sina_prefix(symbol) + url = f'https://money.finance.sina.com.cn/quotes_service/api/json_v2.php/CN_MarketData.getKLineData?symbol={code}&scale=240&ma=no&datalen={datalen}' + r = _req.get(url, headers=headers, timeout=10) + data = json.loads(r.text) + if not data: + return None + df = pd.DataFrame(data) + for col in ['open', 'high', 'low', 'close', 'volume']: + df[col] = pd.to_numeric(df[col], errors='coerce') + df.rename(columns={'day': 'date'}, inplace=True) + return df + + +def _get_spot_data(): + """获取全市场实时行情(新浪源,兼容东财字段格式)""" + try: + df = safe_request(ak.stock_zh_a_spot) + if df is None or df.empty: + return None + df = df.copy() + df['代码'] = df['代码'].astype(str).str.strip() + for col in ['最高', '最低', '昨收', '最新价']: + if col in df.columns: + df[col] = pd.to_numeric(df[col], errors='coerce').fillna(0) + prev_close = df['昨收'].replace(0, float('nan')) + df['振幅'] = ((df['最高'] - df['最低']) / prev_close * 100).round(2).fillna(0) + if '量比' not in df.columns: + df['量比'] = 0 + if '换手率' not in df.columns: + df['换手率'] = 0 + if '流通市值' not in df.columns: + df['流通市值'] = 0 + return df + except Exception as e: + log(f"全市场行情获取失败(新浪源): {e}") + return None + + +# ========== Tushare HTTP API适配层 ========== + +TUSHARE_TOKEN = os.environ.get('TUSHARE_TOKEN', '') + +# 佣金费率配置(支持用户在 config.env 中自定义) +# 默认万2.5(0.00025),最低5元。印花税和过户费为硬编码(全国统一) +COMMISSION_RATE_STOCK = float(os.environ.get('BROKER_COMMISSION_STOCK', 0.00025)) +COMMISSION_RATE_ETF = float(os.environ.get('BROKER_COMMISSION_ETF', 0.00025)) + + +def _tushare_api(api_name, params, fields=''): + """Tushare HTTP API封装(不依赖tushare包)""" + try: + r = _req.post('http://api.tushare.pro', json={ + 'api_name': api_name, + 'token': TUSHARE_TOKEN, + 'params': params, + 'fields': fields + }, timeout=15) + d = r.json() + if d.get('code') == 0: + return d.get('data', {}) + log(f"Tushare {api_name}: {d.get('msg', 'unknown error')}") + return None + except Exception as e: + log(f"Tushare {api_name} exception: {e}") + return None + + +def _ts_code(symbol): + """股票代码转Tushare格式""" + if symbol.startswith(('sh', 'sz')): + return symbol.upper() + if symbol.startswith(('6', '9', '5')): + return f"{symbol}.SH" + else: + return f"{symbol}.SZ" + + +def _get_daily_basic(symbol): + """获取最新daily_basic数据(PE/PB/市值/换手率/量比)""" + ts_code = _ts_code(symbol) + data = _tushare_api('daily_basic', + {'ts_code': ts_code, 'start_date': '20260101', 'end_date': '20261231'}, + 'ts_code,trade_date,close,pe,pe_ttm,pb,ps,ps_ttm,dv_ratio,total_mv,circ_mv,turnover_rate,volume_ratio') + if not data or not data.get('items'): + log(f"Tushare daily_basic无数据({symbol}),尝试AKShare容灾") + return _ak_daily_basic(symbol) + fields = data.get('fields', []) + latest = data['items'][0] + result = dict(zip(fields, latest)) + return result + + +# ========== AKShare 容灾数据层 (DataHub v3.1) ========== + +def _ak_daily_basic(symbol): + """AKShare容灾: 获取PE/PB/市值等基本面数据(替代Tushare daily_basic)""" + try: + result = {} + # 方法1: 东财个股指标 + try: + df_ind = safe_request(ak.stock_a_indicator_lg, symbol=symbol) + if df_ind is not None and not df_ind.empty: + latest = df_ind.iloc[-1] + result['pe_ttm'] = round(float(latest.get('pe_ttm', 0) or 0), 1) + result['pb'] = round(float(latest.get('pb', 0) or 0), 2) + result['dv_ratio'] = round(float(latest.get('dv_ratio', 0) or 0), 2) + result['turnover_rate'] = round(float(latest.get('turnover_rate', 0) or 0), 2) + except Exception: + pass + + # 方法2: 东财个股信息(市值) + try: + df_info = safe_request(ak.stock_individual_info_em, symbol=symbol) + if df_info is not None and not df_info.empty: + for _, row in df_info.iterrows(): + item = str(row.iloc[0]).strip() if len(row) > 0 else '' + value = row.iloc[1] if len(row) > 1 else '' + if '总市值' in item: + try: + mv = float(str(value).replace(',', '').replace('亿', '')) + result['total_mv'] = mv * 10000 # 转万元 + except: + pass + elif '流通市值' in item: + try: + mv = float(str(value).replace(',', '').replace('亿', '')) + result['circ_mv'] = mv * 10000 + except: + pass + elif '量比' in item: + try: + result['volume_ratio'] = round(float(value), 2) + except: + pass + except Exception: + pass + + if result: + result['trade_date'] = datetime.now().strftime('%Y%m%d') + result['close'] = 0 + return result + except Exception as e: + log(f'AKShare daily_basic容灾失败: {e}') + return None + + +def _ak_capital_flow(symbol): + """AKShare容灾: 获取个股资金流向(替代Tushare moneyflow)""" + try: + df = safe_request(ak.stock_individual_fund_flow, stock=symbol, market='sh' if symbol.startswith('6') else 'sz') + if df is None or df.empty: + return None + + df = df.tail(5).copy() + recent_flow = [] + for _, row in df.iterrows(): + main_net = round(float(row.get('主力净流入-净额', 0) or 0) / 10000, 2) + sm_net = round(float(row.get('小单净流入-净额', 0) or 0) / 10000, 2) + md_net = round(float(row.get('中单净流入-净额', 0) or 0) / 10000, 2) + lg_net = round(float(row.get('大单净流入-净额', 0) or 0) / 10000, 2) + elg_net = round(float(row.get('超大单净流入-净额', 0) or 0) / 10000, 2) + trade_date = str(row.get('日期', '')).replace('-', '') + + recent_flow.append({ + '日期': f'{trade_date[:4]}-{trade_date[4:6]}-{trade_date[6:]}' if len(trade_date) == 8 else str(row.get('日期', '')), + '主力净流入': main_net, + '小单净流入': sm_net, + '中单净流入': md_net, + '大单净流入': lg_net, + '超大单净流入': elg_net, + }) + + recent_flow.reverse() # 最新在前 + + if not recent_flow: + return None + + latest = recent_flow[0] + return { + 'symbol': symbol, + 'latest_flow': latest, + 'recent_flow': recent_flow, + 'summary': { + '主力净流入万': latest.get('主力净流入', 0), + '超大单净流入万': latest.get('超大单净流入', 0), + '大单净流入万': latest.get('大单净流入', 0), + '方向': '主力流入' if latest.get('主力净流入', 0) > 0 else '主力流出' + }, + '_source': 'AKShare' + } + except Exception as e: + log(f'AKShare capital_flow容灾失败: {e}') + return None + + +def _ak_sector_ranking(): + """AKShare容灾: 获取行业板块涨跌排名(替代Tushare申万行业)""" + try: + df = safe_request(ak.stock_board_industry_name_em) + if df is None or df.empty: + return None + + results = [] + for _, row in df.iterrows(): + name = str(row.get('板块名称', '')).strip() + pct = float(row.get('涨跌幅', 0) or 0) + if name: + results.append({'板块名称': name, '涨跌幅': round(pct, 2)}) + + if not results: + return None + + results.sort(key=lambda x: x['涨跌幅'], reverse=True) + return { + '涨幅前3': results[:3], + '跌幅前3': results[-3:], + '数据源': 'AKShare(东财行业)' + } + except Exception as e: + log(f'AKShare sector_ranking容灾失败: {e}') + return None + + +def _ak_index_daily(ts_code='000300.SH', limit=1): + """AKShare容灾: 获取指数数据(替代Tushare index_daily)""" + try: + symbol_map = { + '000300.SH': 'sh000300', + '000001.SH': 'sh000001', + '399001.SZ': 'sz399001', + '399006.SZ': 'sz399006', + } + ak_symbol = symbol_map.get(ts_code, f'sh{ts_code[:6]}') + df = safe_request(ak.stock_zh_index_daily, symbol=ak_symbol) + if df is None or df.empty: + return None + + df = df.tail(int(limit)).copy() + if limit == 1: + latest = df.iloc[-1] + return { + 'close': round(float(latest.get('close', 0) or 0), 2), + 'pct_chg': 0, # 需要计算 + 'items': [[latest.get('date', ''), round(float(latest.get('close', 0) or 0), 2)]] + } + + # 多条数据 + closes = [round(float(x), 2) for x in df['close'].tolist()] + return {'closes': closes, 'items': df.values.tolist()} + except Exception as e: + log(f'AKShare index_daily容灾失败: {e}') + return None + + +def _ak_lhb_detail(symbol, days=5): + """AKShare: 获取个股龙虎榜明细(先拉全市场再过滤个股)""" + try: + start_date = (datetime.now() - timedelta(days=int(days))).strftime('%Y%m%d') + end_date = datetime.now().strftime('%Y%m%d') + + df = safe_request(ak.stock_lhb_detail_em, + start_date=start_date, + end_date=end_date) + if df is None or df.empty: + return {'symbol': symbol, 'message': f'近{days}天全市场无龙虎榜数据'} + + # 过滤目标个股(__all__ 标记跳过过滤返回全量) + if symbol == '__all__': + df_filtered = df + elif '代码' in df.columns: + df_filtered = df[df['代码'].astype(str) == str(symbol)] + elif '股票代码' in df.columns: + df_filtered = df[df['股票代码'].astype(str) == str(symbol)] + else: + return {'symbol': symbol, 'message': '龙虎榜列名未识别', 'columns': list(df.columns)} + + if df_filtered.empty: + return {'symbol': symbol, 'message': f'近{days}天该股无龙虎榜记录'} + + results = [] + for _, row in df_filtered.iterrows(): + entry = {} + for col in df_filtered.columns: + val = row[col] + if pd.api.types.is_numeric_dtype(df_filtered[col]): + entry[col] = round(float(val), 2) if pd.notna(val) else 0 + else: + entry[col] = str(val) if pd.notna(val) else '' + results.append(entry) + + return { + 'symbol': symbol, + 'count': len(results), + 'records': results, + 'data_source': 'AKShare' + } + except Exception as e: + return {'error': f'龙虎榜数据获取失败: {str(e)[:80]}'} + + +def _ak_block_trade(symbol, days=5): + """AKShare: 获取个股大宗交易明细""" + try: + start_date = (datetime.now() - timedelta(days=int(days))).strftime('%Y%m%d') + end_date = datetime.now().strftime('%Y%m%d') + + try: + df = safe_request(ak.stock_dzjy_mrmx, symbol='股票', start_date=start_date, end_date=end_date) + except Exception: + df = None + + if df is None or df.empty: + return {'symbol': symbol, 'message': f'近{days}天无大宗交易或数据源解析异常'} + + if symbol == '__all__': + df_filtered = df + else: + code_col = None + for col in ['股票代码', '代码']: + if col in df.columns: + code_col = col + break + if not code_col: + return {'symbol': symbol, 'message': '大宗交易列名未识别', 'columns': list(df.columns)} + df_filtered = df[df[code_col].astype(str) == str(symbol)] + + if df_filtered.empty: + return {'symbol': symbol, 'message': f'近{days}天该股无大宗交易'} + + results = [] + for _, row in df_filtered.iterrows(): + entry = {} + for col in df_filtered.columns: + val = row[col] + if pd.api.types.is_numeric_dtype(df_filtered[col]): + entry[col] = round(float(val), 2) if pd.notna(val) else 0 + else: + entry[col] = str(val) if pd.notna(val) else '' + results.append(entry) + + return { + 'symbol': symbol, + 'count': len(results), + 'records': results, + 'data_source': 'AKShare' + } + except Exception as e: + return {'error': f'大宗交易数据获取失败: {str(e)[:80]}'} + + +def _ak_share_unlock(): + """AKShare: 获取限售解禁排名(全市场,返回近30天)""" + try: + df = safe_request(ak.stock_rank_cxsl_ths) + if df is None or df.empty: + return [] + + results = [] + for _, row in df.iterrows(): + entry = {} + for col in df.columns: + val = row[col] + if pd.api.types.is_numeric_dtype(df[col]): + entry[col] = round(float(val), 2) if pd.notna(val) else 0 + else: + entry[col] = str(val) if pd.notna(val) else '' + results.append(entry) + return results + except Exception as e: + log(f'限售解禁数据获取失败: {e}') + return [] + + +def _ak_earnings_forecast(date=None): + """AKShare: 获取业绩预告(按报告期)""" + try: + if not date: + now = datetime.now() + y = now.year + m = now.month + if m <= 4: + date = f'{y-1}0930' # 去年三季报 + elif m <= 7: + date = f'{y-1}1231' # 去年年报 + elif m <= 10: + date = f'{y}0331' # 一季报 + else: + date = f'{y}0630' # 中报 + + df = safe_request(ak.stock_yjyg_em, date=date) + if df is None or df.empty: + return [], date + + results = [] + for _, row in df.iterrows(): + entry = {} + for col in df.columns: + val = row[col] + if pd.api.types.is_numeric_dtype(df[col]): + entry[col] = round(float(val), 2) if pd.notna(val) else 0 + else: + entry[col] = str(val) if pd.notna(val) else '' + results.append(entry) + return results, date + except Exception as e: + log(f'业绩预告数据获取失败: {e}') + return [], date if date else '' + + +def scan_events(symbols=None, days=5): + """事件型异动扫描 - 4类检测(盘后/非实时)""" + if not symbols: + wl = read_watchlist() + symbols = [s['code'] for s in wl] + + if not symbols: + return {'scan_time': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), 'message': '自选股为空'} + + results = {} + symbols_str = [str(s) for s in symbols] + + # 1. 龙虎榜异动 + try: + lhb_data = _ak_lhb_detail('__all__', days) + if 'records' in lhb_data: + lhb_filtered = [r for r in lhb_data['records'] if str(r.get('代码', r.get('股票代码', ''))) in symbols_str] + results['龙虎榜'] = {'count': len(lhb_filtered), 'records': lhb_filtered} + else: + results['龙虎榜'] = {'count': 0, 'message': f'近{days}天无龙虎榜记录'} + except Exception as e: + results['龙虎榜'] = {'error': str(e)[:80]} + + # 2. 大宗交易 + try: + bt_data = _ak_block_trade('__all__', days) + if 'records' in bt_data: + bt_filtered = [r for r in bt_data['records'] if str(r.get('股票代码', r.get('代码', ''))) in symbols_str] + results['大宗交易'] = {'count': len(bt_filtered), 'records': bt_filtered} + else: + results['大宗交易'] = {'count': 0, 'message': f'近{days}天无大宗交易'} + except Exception as e: + results['大宗交易'] = {'error': str(e)[:80]} + + # 3. 限售解禁 + try: + unlock_all = _ak_share_unlock() + unlock_filtered = [r for r in unlock_all if str(r.get('代码', r.get('股票代码', ''))) in symbols_str] + results['限售解禁'] = {'count': len(unlock_filtered), 'records': unlock_filtered} + except Exception as e: + results['限售解禁'] = {'error': str(e)[:80]} + + # 4. 业绩预告 + try: + forecast_all, report_date = _ak_earnings_forecast() + forecast_filtered = [r for r in forecast_all if str(r.get('代码', r.get('股票代码', ''))) in symbols_str] + results['业绩预告'] = {'count': len(forecast_filtered), 'report_date': report_date, 'records': forecast_filtered} + except Exception as e: + results['业绩预告'] = {'error': str(e)[:80]} + + return { + 'scan_time': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + 'symbols': symbols, + 'days': days, + 'results': results + } + + +# ========== 舆情分析 v3.1 ========== + +def _ak_sentiment_detail(symbol): + """AKShare: 个股舆情聚合(机构参与度+综合评分+关注度+买入欲望+关键词)""" + result = {'symbol': symbol, 'data_source': 'AKShare(东财)'} + + # 1. 机构参与度 (1-5分) + try: + df = safe_request(ak.stock_comment_detail_zlkp_jgcyd_em, symbol=symbol) + if df is not None and not df.empty: + latest = df.iloc[-1] + result['机构参与度'] = { + '评分': str(latest.iloc[1]) if pd.notna(latest.iloc[1]) else '', + '日期': str(latest.iloc[0]) if pd.notna(latest.iloc[0]) else '', + 'raw': str(latest.to_dict()) + } + except Exception: + pass + + # 2. 综合评价历史评分 + try: + df = safe_request(ak.stock_comment_detail_zhpj_lspf_em, symbol=symbol) + if df is not None and not df.empty: + latest = df.iloc[-1] + result['综合评价'] = { + '评分': round(float(latest.iloc[1]), 2) if pd.notna(latest.iloc[1]) and str(latest.iloc[1]).replace('.','',1).replace('-','',1).isdigit() else str(latest.iloc[1]), + '日期': str(latest.iloc[0]) if pd.notna(latest.iloc[0]) else '', + } + except Exception: + pass + + # 3. 关注度 + try: + df = safe_request(ak.stock_comment_detail_scrd_focus_em, symbol=symbol) + if df is not None and not df.empty: + latest = df.iloc[-1] + result['关注度'] = { + '评分': round(float(latest.iloc[1]), 2) if pd.notna(latest.iloc[1]) and str(latest.iloc[1]).replace('.','',1).replace('-','',1).isdigit() else str(latest.iloc[1]), + '日期': str(latest.iloc[0]) if pd.notna(latest.iloc[0]) else '', + } + except Exception: + pass + + # 4. 买入欲望 + try: + df = safe_request(ak.stock_comment_detail_scrd_desire_em, symbol=symbol) + if df is not None and not df.empty: + latest = df.iloc[-1] + desire = latest.get('参与意愿', 0) + result['买入欲望'] = { + '参与意愿': round(float(desire), 2) if pd.notna(desire) else 0, + '5日均值': round(float(latest.get('5日平均参与意愿', 0) or 0), 2), + '变化': round(float(latest.get('参与意愿变化', 0) or 0), 2), + '日期': str(latest.get('交易日期', '')), + } + except Exception: + pass + + # 5. 热门概念(东财概念热度,替代关键词) + try: + ak_symbol = f"{'SH' if symbol.startswith('6') else 'SZ'}{symbol}" + df = safe_request(ak.stock_hot_keyword_em, symbol=ak_symbol) + if df is not None and not df.empty: + concepts = [] + for _, row in df.head(8).iterrows(): + concepts.append({ + '概念': str(row.get('概念名称', '')), + '热度': int(row.get('热度', 0)) if pd.notna(row.get('热度')) else 0 + }) + result['热门概念'] = concepts + except Exception: + pass + + return result + + +def _ak_sentiment_market_rank(): + """AKShare: 全市场人气排名TOP100""" + try: + df = safe_request(ak.stock_hot_rank_em) + if df is None or df.empty: + return [] + + results = [] + for _, row in df.head(100).iterrows(): + entry = {} + for col in df.columns: + val = row[col] + if pd.api.types.is_numeric_dtype(df[col]): + entry[col] = round(float(val), 2) if pd.notna(val) else 0 + else: + entry[col] = str(val) if pd.notna(val) else '' + results.append(entry) + return results + except Exception as e: + log(f'全市场人气排名获取失败: {e}') + return [] + + +def sentiment_scan(symbol=None, symbols=None): + """舆情分析主入口 — 个股或批量""" + if not symbol and not symbols: + return {'error': '请提供symbol(单个)或symbols(批量)'} + + target_symbols = [symbol] if symbol else symbols + results = [] + + for sym in target_symbols: + detail = _ak_sentiment_detail(str(sym)) + results.append(detail) + + # 如果是批量,额外附加人气排名交叉匹配 + if len(target_symbols) > 1: + try: + rank_all = _ak_sentiment_market_rank() + if rank_all: + rank_map = {} + for item in rank_all: + code = str(item.get('股票代码', '')) + if code: + rank_map[code] = item + + for r in results: + sym = r.get('symbol', '') + if sym in rank_map: + r['市场人气排名'] = rank_map[sym] + except Exception: + pass + + return { + 'scan_time': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + 'count': len(results), + 'results': results + } + + +# ========== 组合压力测试 v3.1 ========== + +def _get_sector_betas(): + """获取申万一级行业相对沪深300的近似Beta值""" + return { + '银行': 0.85, '非银金融': 1.35, '食品饮料': 0.95, '医药生物': 0.90, + '电子': 1.25, '计算机': 1.30, '通信': 1.15, '传媒': 1.20, + '汽车': 1.15, '机械设备': 1.20, '电力设备': 1.25, '国防军工': 1.30, + '化工': 1.10, '钢铁': 1.15, '有色金属': 1.40, '建筑材料': 1.05, + '建筑装饰': 0.95, '农林牧渔': 1.05, '家用电器': 0.90, '纺织服饰': 0.95, + '轻工制造': 1.00, '房地产': 1.35, '商贸零售': 1.05, '社会服务': 1.10, + '综合': 1.00, '公用事业': 0.70, '交通运输': 0.95, '环保': 1.05, + '美容护理': 0.95, '石油石化': 1.10, '煤炭': 1.20 + } + + +def _guess_sector(symbol): + """根据代码简单猜测行业(简化版,准确行业需调Tushare API)""" + # 先从已有信息猜 + name = _get_name(symbol) + hints = { + '银行': ['银行', '商行'], '证券': ['证券', '中信'], '保险': ['保险', '人寿'], + '白酒': ['酒', '茅台', '五粮液'], '医药': ['药', '医疗', '生物', '健康'], + '电子': ['半导体', '芯片', '电子', '科技'], '汽车': ['汽车', '长安', '比亚迪'], + '地产': ['地产', '万科', '保利'], '能源': ['石油', '石化', '煤炭', '电力'], + } + for sector, keywords in hints.items(): + if any(kw in name for kw in keywords): + return sector + return '综合' + + +def stress_test(scenario='crash_2015', drop_pct=None, custom_drop=None): + """组合压力测试 — 历史极端行情模拟 + + 场景: + - crash_2015: 2015股灾(3周跌35%) + - crash_2024: 2024年1月雪崩(单周跌12%) + - trade_war: 2018贸易战(6个月跌25%) + - covid_2020: 2020年3月疫情(2周跌16%) + - 924_surge: 2024年924行情(单周涨25%)← 压力反向测试 + - custom: 自定义跌幅 + """ + scenarios = { + 'crash_2015': {'name': '2015股灾', 'hs300_drop': -35, 'days': 21, 'desc': '3周暴跌35%'}, + 'crash_2024': {'name': '2024年1月', 'hs300_drop': -12, 'days': 7, 'desc': '单周跌12%'}, + 'trade_war': {'name': '2018贸易战', 'hs300_drop': -25, 'days': 120, 'desc': '6个月跌25%'}, + 'covid_2020': {'name': '2020疫情', 'hs300_drop': -16, 'days': 14, 'desc': '2周跌16%'}, + '924_surge': {'name': '924行情', 'hs300_drop': 25, 'days': 5, 'desc': '单周暴涨25%'}, + } + + if scenario not in scenarios and not custom_drop: + return {'error': f'未知场景: {scenario}。支持: {", ".join(scenarios.keys())}'} + + # 自定义场景 + if custom_drop is not None: + scenarios['custom'] = {'name': '自定义', 'hs300_drop': float(custom_drop), 'days': 30, 'desc': f'沪深300跌/涨{custom_drop}%'} + scenario = 'custom' + + sc = scenarios[scenario] + hs300_drop = sc['hs300_drop'] + + positions = read_positions() + active = [p for p in positions if p.get('status') == 'active'] + if not active: + return {'error': '当前无活跃持仓,无法进行压力测试'} + + # 获取实时价格 + symbols = [p['symbol'] for p in active] + quotes = _sina_batch_quotes(symbols) + quote_map = {q.get('代码'): q for q in quotes} + + betas = _get_sector_betas() + results = [] + + for pos in active: + sym = pos['symbol'] + cost = pos['cost'] + shares = pos['shares'] + + q = quote_map.get(sym, {}) + current_price = float(q.get('最新价', 0) or 0) + if current_price <= 0: + continue + + current_value = current_price * shares + + # 猜测行业Beta + sector = _guess_sector(sym) + beta = betas.get(sector, 1.0) + + # 模拟压力:个股跌幅 ≈ 大盘跌幅 × Beta + # 对上涨场景同样适用 + stock_drop = hs300_drop * beta + stressed_price = current_price * (1 + stock_drop / 100) + stressed_value = stressed_price * shares + + loss = stressed_value - current_value + loss_pct = round((stressed_price - current_price) / current_price * 100, 2) + + # 对总成本的压力 + total_pnl = stressed_value - cost * shares + total_pnl_pct = round((stressed_price - cost) / cost * 100, 2) if cost > 0 else 0 + + results.append({ + 'symbol': sym, + 'name': pos.get('name', sym), + 'sector': sector, + 'beta': beta, + 'current_price': round(current_price, 2), + 'stressed_price': round(stressed_price, 2), + 'current_value': round(current_value, 2), + 'stressed_value': round(stressed_value, 2), + 'change': round(loss, 2), + 'change_pct': loss_pct, + 'vs_cost_pct': total_pnl_pct, + }) + + # 汇总 + total_current = sum(r['current_value'] for r in results) + total_stressed = sum(r['stressed_value'] for r in results) + total_change = total_stressed - total_current + total_change_pct = round(total_change / total_current * 100, 2) if total_current > 0 else 0 + + # 账户级别 + acct = read_account() + available_cash = acct.get('available_cash', 0) + total_capital = acct.get('total_capital', 0) + current_total_assets = total_current + available_cash + stressed_total_assets = total_stressed + available_cash + current_pnl = round(current_total_assets - total_capital, 2) if total_capital > 0 else 0 + stressed_pnl = round(stressed_total_assets - total_capital, 2) if total_capital > 0 else 0 + + # 组合加权Beta + weighted_beta = round(sum(r['beta'] * r['current_value'] for r in results) / total_current, 2) if total_current > 0 else 1.0 + + return { + 'scenario': sc['name'], + 'description': sc['desc'], + 'hs300_change_pct': hs300_drop, + 'portfolio_beta': weighted_beta, + 'positions': results, + 'summary': { + 'total_current_value': round(total_current, 2), + 'total_stressed_value': round(total_stressed, 2), + 'total_change': round(total_change, 2), + 'total_change_pct': total_change_pct, + 'current_assets': round(current_total_assets, 2), + 'stressed_assets': round(stressed_total_assets, 2), + 'current_pnl': current_pnl, + 'stressed_pnl': stressed_pnl, + 'available_cash': round(available_cash, 2), + } + } + + +# ========== 板块轮动追踪 v3.1 ========== + +def sector_rotation(): + """板块轮动追踪 — 31个申万行业5日/10日/20日动量分析""" + try: + # Step1: 拿行业列表 + ind_data = _tushare_api('index_classify', + {'level': 'L1', 'src': 'SW2021'}, + 'index_code,industry_name') + + if not ind_data or not ind_data.get('items'): + log("板块轮动: Tushare行业列表获取失败,尝试AKShare") + # AKShare容灾:拉东财行业当前快照(无历史对比) + ak_result = _ak_sector_ranking() + if ak_result: + return { + 'mode': 'snapshot_only', + 'message': '历史数据获取失败,仅返回当日快照', + 'today_ranking': ak_result + } + return {'error': '板块数据获取失败'} + + industries = ind_data.get('items', []) + results = [] + + # Step2: 每个行业拉20天日线 + for code, name in industries: + time.sleep(0.12) + idx_data = _tushare_api('index_daily', + {'ts_code': code, 'limit': '22'}, + 'ts_code,trade_date,close,pct_chg') + + if not idx_data or not idx_data.get('items'): + continue + + fields = idx_data.get('fields', []) + items = idx_data.get('items', []) + + # 按日期排序(Tushare返回倒序,最新的在前) + rows = [dict(zip(fields, item)) for item in items] + rows.sort(key=lambda x: x.get('trade_date', '')) # 正序 + + if len(rows) < 5: + continue + + closes = [float(r.get('close', 0) or 0) for r in rows] + + # 计算区间涨跌幅 + today_close = closes[-1] + pct_1d = float(rows[-1].get('pct_chg', 0) or 0) + + pct_5d = 0 + if len(closes) >= 6: + pct_5d = round((today_close - closes[-6]) / closes[-6] * 100, 2) + + pct_10d = 0 + if len(closes) >= 11: + pct_10d = round((today_close - closes[-11]) / closes[-11] * 100, 2) + + pct_20d = 0 + if len(closes) >= 21: + pct_20d = round((today_close - closes[-21]) / closes[-21] * 100, 2) + + # 动量信号判断 + momentum = 'neutral' + if pct_5d > pct_10d > pct_20d and pct_5d > 0: + momentum = 'accelerating' # 动量加速 + elif pct_5d < pct_10d < pct_20d and pct_5d < 0: + momentum = 'decelerating' # 动量衰减 + elif pct_5d > 0 and pct_10d > 0 and pct_20d > 0: + momentum = 'uptrend' # 持续上行 + elif pct_5d < 0 and pct_10d < 0 and pct_20d < 0: + momentum = 'downtrend' # 持续下行 + elif pct_5d > 0 and pct_20d < 0: + momentum = 'rebounding' # 超跌反弹 + elif pct_5d < 0 and pct_20d > 0: + momentum = 'pulling_back' # 回调 + + results.append({ + 'name': name, + 'code': code, + 'pct_1d': pct_1d, + 'pct_5d': pct_5d, + 'pct_10d': pct_10d, + 'pct_20d': pct_20d, + 'momentum': momentum + }) + + if not results: + return {'error': '板块历史数据获取失败'} + + # 排序:按5日涨跌幅 + results.sort(key=lambda x: x['pct_5d'], reverse=True) + + # 分类汇总 + accelerating = [r for r in results if r['momentum'] == 'accelerating'] + decelerating = [r for r in results if r['momentum'] == 'decelerating'] + uptrend = [r for r in results if r['momentum'] == 'uptrend'] + downtrend = [r for r in results if r['momentum'] == 'downtrend'] + rebounding = [r for r in results if r['momentum'] == 'rebounding'] + pulling_back = [r for r in results if r['momentum'] == 'pulling_back'] + + # 强势Top5(5日涨幅最大) + top5 = results[:5] + # 弱势Top5(5日跌幅最大) + bot5 = results[-5:][::-1] + + return { + 'mode': 'full_rotation', + 'scan_time': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + 'data_source': 'Tushare(申万一级)', + 'total_sectors': len(results), + 'top5_5d': top5, + 'bot5_5d': bot5, + 'signals': { + 'accelerating': accelerating, # 资金加速流入 + 'decelerating': decelerating, # 资金加速流出 + 'uptrend': uptrend, # 持续强势 + 'downtrend': downtrend, # 持续弱势 + 'rebounding': rebounding, # 超跌反弹 + 'pulling_back': pulling_back, # 高位回调 + }, + 'all_sectors': results + } + except Exception as e: + return {'error': f'板块轮动分析失败: {str(e)[:80]}'} + + +# ========== 市场温度计 v3.2 ========== + +def market_temperature(): + """市场温度计 — 涨跌停/涨跌比/换手率/成交额 → 市场情绪综合判断""" + try: + today = datetime.now().strftime('%Y%m%d') + + # 1. 涨跌停数据(AKShare东财涨停池) + zt_count = 0 + dt_count = 0 + zt_sectors = [] + try: + zt_df = safe_request(ak.stock_zt_pool_em, date=today) + if zt_df is not None and not zt_df.empty: + zt_count = len(zt_df) + # 统计涨停股所属行业 + if '所属行业' in zt_df.columns: + zt_sectors = zt_df['所属行业'].value_counts().head(5).to_dict() + except Exception: + pass + + try: + dt_df = safe_request(ak.stock_zt_pool_dtgc_em, date=today) + if dt_df is not None and not dt_df.empty: + dt_count = len(dt_df) + except Exception: + pass + + # 2. 全市场行情统计 + df = _get_spot_data() + if df is None or df.empty: + return {"error": "全市场行情数据获取失败"} + + up = int((df['涨跌幅'] > 0).sum()) + down = int((df['涨跌幅'] < 0).sum()) + flat = int((df['涨跌幅'] == 0).sum()) + up_down_ratio = round(up / max(down, 1), 2) + + # 换手率中位数 + turnover_vals = pd.to_numeric(df.get('换手率', pd.Series([0]*len(df))), errors='coerce') + turnover_median = round(float(turnover_vals.median()), 2) + + # 两市成交额(AKShare的成交额单位是元,转亿) + if '成交额' in df.columns: + total_amount = pd.to_numeric(df['成交额'], errors='coerce').sum() + total_amount_yi = round(float(total_amount) / 100000000, 0) + else: + total_amount_yi = 0 + + # 3. 温度计评分(0-100,50为中性) + score = 50.0 + # 涨跌比偏离1.0 → 每偏离0.1加/减2分 + score += (up_down_ratio - 1.0) * 20 + # 涨停-跌停净数 → 每个加/减0.3分 + score += (zt_count - dt_count) * 0.3 + # 成交额偏离8000亿均值 → 每偏离1000亿加/减2分 + score += (total_amount_yi - 8000) / 500 + # 换手率偏离2.5%中位 → 每偏离0.5%加/减1分 + score += (turnover_median - 2.5) / 0.5 + score = max(0, min(100, round(score, 1))) + + # 4. 情绪判断 + if score >= 75: + sentiment = "🔥 极热(情绪亢奋,警惕高位分歧)" + elif score >= 60: + sentiment = "☀️ 偏热(多头占优,可正常操作)" + elif score >= 40: + sentiment = "☁️ 中性(多空均衡,半仓观望)" + elif score >= 25: + sentiment = "❄️ 偏冷(空头占优,收紧止损)" + else: + sentiment = "🧊 极冷(恐慌蔓延,观望或逆向思考)" + + return { + "date": today, + "score": score, + "sentiment": sentiment, + "limit_up": zt_count, + "limit_down": dt_count, + "advance": up, + "decline": down, + "flat": flat, + "up_down_ratio": up_down_ratio, + "turnover_median": turnover_median, + "total_amount_yi": total_amount_yi, + "hot_sectors": zt_sectors, + "hs300_change": get_hs300_change() + } + except Exception as e: + return {"error": f"市场温度计计算失败: {str(e)[:80]}"} + + +# ========== 月度复盘 v3.2 ========== + +def trade_stats_monthly(): + """月度复盘 — 按月/胜率/盈亏比统计""" + trades = read_trades() + sells = [t for t in trades if t.get("action") == "sell"] + + if not sells: + return {"total_trades": 0, "message": "暂无已平仓交易"} + + # 按月分组 + monthly = {} + for t in sells: + date = t.get("date", "") + month_key = date[:7] if len(date) >= 7 else "unknown" + if month_key not in monthly: + monthly[month_key] = { + "month": month_key, + "trades": 0, "wins": 0, "losses": 0, + "total_pnl": 0, "win_pnls": [], "loss_pnls": [] + } + m = monthly[month_key] + m["trades"] += 1 + pnl = t.get("pnl", 0) + m["total_pnl"] += pnl + if pnl > 0: + m["wins"] += 1 + m["win_pnls"].append(pnl) + elif pnl < 0: + m["losses"] += 1 + m["loss_pnls"].append(pnl) + + # 计算每月详细统计 + monthly_stats = [] + for month_key in sorted(monthly.keys()): + m = monthly[month_key] + win_rate = round(m["wins"] / m["trades"] * 100, 1) if m["trades"] > 0 else 0 + avg_win = round(sum(m["win_pnls"]) / len(m["win_pnls"]), 2) if m["win_pnls"] else 0 + avg_loss = round(sum(m["loss_pnls"]) / len(m["loss_pnls"]), 2) if m["loss_pnls"] else 0 + profit_ratio = round(abs(avg_win / avg_loss), 2) if avg_loss != 0 else 0 + + monthly_stats.append({ + "month": month_key, + "trades": m["trades"], + "wins": m["wins"], + "losses": m["losses"], + "win_rate": win_rate, + "total_pnl": round(m["total_pnl"], 2), + "avg_win": avg_win, + "avg_loss": avg_loss, + "profit_ratio": profit_ratio # 盈亏比 = 平均盈利/平均亏损 + }) + + # 总览 + total_pnl = sum(t.get("pnl", 0) for t in sells) + all_wins = [t for t in sells if t.get("pnl", 0) > 0] + all_losses = [t for t in sells if t.get("pnl", 0) < 0] + avg_win = round(sum(t.get("pnl", 0) for t in all_wins) / len(all_wins), 2) if all_wins else 0 + avg_loss = round(sum(t.get("pnl", 0) for t in all_losses) / len(all_losses), 2) if all_losses else 0 + + return { + "total_summary": { + "total_sells": len(sells), + "win_rate": round(len(all_wins) / len(sells) * 100, 1), + "total_pnl": round(total_pnl, 2), + "avg_win": avg_win, + "avg_loss": avg_loss, + "profit_ratio": round(abs(avg_win / avg_loss), 2) if avg_loss != 0 else 0 + }, + "monthly": monthly_stats + } + + +# ========== 自选股管理 ========== + +def read_watchlist(): + if not os.path.exists(WATCHLIST_PATH): + return [] + try: + with open(WATCHLIST_PATH, 'r', encoding='utf-8') as f: + data = json.load(f) + return data.get("stocks", []) + except Exception: + return [] + + +def save_watchlist(stocks): + data = { + "updated": datetime.now().strftime("%Y-%m-%d"), + "stocks": stocks + } + with open(WATCHLIST_PATH, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + +@synchronized_data +def watchlist_add(code, name=""): + stocks = read_watchlist() + for s in stocks: + if s["code"] == code: + return {"status": "exists", "message": f"{code}已在自选股中"} + if not name: + name = code + stocks.append({ + "code": code, + "name": name, + "added": datetime.now().strftime("%Y-%m-%d") + }) + save_watchlist(stocks) + return {"status": "added", "message": f"已添加 {name}({code})"} + + +@synchronized_data +def watchlist_remove(code): + stocks = read_watchlist() + new_stocks = [s for s in stocks if s["code"] != code] + if len(new_stocks) == len(stocks): + return {"status": "not_found", "message": f"{code}不在自选股中"} + removed_name = next((s["name"] for s in stocks if s["code"] == code), code) + save_watchlist(new_stocks) + return {"status": "removed", "message": f"已移除 {removed_name}({code})"} + + +@synchronized_data +def watchlist_show(): + stocks = read_watchlist() + # 自动修复乱码名称 + changed = False + for s in stocks: + if '?' in str(s.get('name', '')): + new_name = _get_name(s['code']) + if new_name and '?' not in new_name: + s['name'] = new_name + changed = True + if changed: + save_watchlist(stocks) + return {"count": len(stocks), "stocks": stocks} + + +# ========== 持仓监控管理 ========== + +def read_positions(): + if not os.path.exists(POSITIONS_PATH): + return [] + try: + with open(POSITIONS_PATH, 'r', encoding='utf-8') as f: + data = json.load(f) + return data.get("positions", []) + except Exception: + return [] + + +def save_positions(positions): + data = { + "updated": datetime.now().strftime("%Y-%m-%d"), + "positions": positions + } + with open(POSITIONS_PATH, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + + +# ========== 交易记录管理 ========== + +def read_trades(): + if not os.path.exists(TRADES_PATH): + return [] + try: + with open(TRADES_PATH, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception: + return [] + + +def save_trades(trades): + with open(TRADES_PATH, 'w', encoding='utf-8') as f: + json.dump(trades, f, ensure_ascii=False, indent=2) + +def read_account(): + """读取账户资金信息""" + _path = ACCOUNT_PATH + if not os.path.exists(_path): + return {"total_capital": 0, "available_cash": 0, "updated": ""} + try: + with open(_path, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception: + return {"total_capital": 0, "available_cash": 0, "updated": ""} + + +def save_account(data): + _path = ACCOUNT_PATH + with open(_path, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + +@synchronized_data +def account_set(total_capital, available_cash=None): + """设置账户总资金和可用资金""" + acct = read_account() + old_capital = acct.get("total_capital", 0) + acct["total_capital"] = float(total_capital) + if available_cash is not None: + acct["available_cash"] = float(available_cash) + elif old_capital == 0: + acct["available_cash"] = float(total_capital) + acct["updated"] = datetime.now().strftime("%Y-%m-%d") + save_account(acct) + return {"status": "set", "total_capital": acct["total_capital"], "available_cash": acct["available_cash"]} + + + + +def _get_name(symbol): + """从新浪行情获取股票名称(解决中文乱码问题)""" + try: + q = _sina_realtime(symbol) + if q and q.get("名称"): + return q["名称"] + except Exception: + pass + return symbol + + +@synchronized_data +def position_add(symbol, name, cost, shares, stop_loss, target, reason=""): + positions = read_positions() + for p in positions: + if p["symbol"] == symbol and p.get("status") == "active": + return {"status": "exists", "message": f"{symbol}已有活跃持仓"} + + if not name or name == symbol or '?' in str(name): + name = _get_name(symbol) + + today = datetime.now().strftime("%Y-%m-%d") + + positions.append({ + "symbol": symbol, + "name": name, + "cost": float(cost), + "shares": int(shares), + "stop_loss": float(stop_loss), + "target": float(target), + "entry_date": today, + "added": today, + "entry_reason": reason, + "status": "active" + }) + save_positions(positions) + + # === 扣减可用资金 (真实费率 v3.2.2) === + # 佣金:读取用户配置,最低5元 + comm_rate = COMMISSION_RATE_ETF if symbol.startswith(('5', '159')) else COMMISSION_RATE_STOCK + comm_fee = max(float(cost) * int(shares) * comm_rate, 5) + # 过户费:沪市双边万0.1 + transfer_fee = float(cost) * int(shares) * 0.00001 if symbol.startswith(('6', '5', '9', '11', '13')) else 0 + total_fee = round(comm_fee + transfer_fee, 2) + # 真实摊薄成本 = (买入金额 + 手续费) / 股数 + actual_cost = round((float(cost) * int(shares) + total_fee) / int(shares), 4) + positions[-1]["cost"] = actual_cost # 更新刚追加的持仓成本 + save_positions(positions) + cost_total = float(cost) * int(shares) + total_fee + acct = read_account() + if acct.get("total_capital", 0) > 0: + acct["available_cash"] = round(acct.get("available_cash", 0) - cost_total, 2) + acct["updated"] = today + save_account(acct) + + trades = read_trades() + trades.append({ + "symbol": symbol, + "name": name, + "action": "buy", + "price": float(cost), + "shares": int(shares), + "date": today, + "reason": reason + }) + save_trades(trades) + + return {"status": "added", "message": f"已添加持仓 {name}({symbol}) 成本{cost} 数量{shares} 止损{stop_loss} 目标{target}", "trades_updated": True} + + +@synchronized_data +def position_close(symbol, sell_price, shares=None, reason="", commission=5): + positions = read_positions() + pos = None + for p in positions: + if p["symbol"] == symbol and p.get("status") == "active": + pos = p + break + + if not pos: + return {"status": "not_found", "message": f"{symbol}无活跃持仓"} + + sell_price = float(sell_price) + cost = pos["cost"] + # 数量解析:None/缺省=全部,0=无效,负数=无效 + if shares is not None: + close_shares = int(shares) + else: + close_shares = pos["shares"] + # 数量上限保护:卖出数量不超过实际持仓 + if close_shares > pos["shares"]: + close_shares = pos["shares"] + if close_shares <= 0: + return {"status": "error", "message": f"卖出数量无效(shares={shares})"} + today = datetime.now().strftime("%Y-%m-%d") + + # === 真实A股费率计算引擎 v3.2.2 === + # 1. 佣金:读取用户配置,最低5元 + comm_rate = COMMISSION_RATE_ETF if symbol.startswith(('5', '159')) else COMMISSION_RATE_STOCK + raw_amount = sell_price * close_shares + comm_fee = max(raw_amount * comm_rate, 5) + # 2. 印花税:千1(0.1%),仅股票卖出收取,ETF免收 + stamp_tax = raw_amount * 0.001 if not symbol.startswith(('5', '159')) else 0 + # 3. 过户费:万0.1(0.001%),沪市双边收取,深市免收 + transfer_fee = raw_amount * 0.00001 if symbol.startswith(('6', '5', '9', '11', '13')) else 0 + # 卖出净到手金额 + total_fee = round(comm_fee + stamp_tax + transfer_fee, 2) + return_total = raw_amount - total_fee + # 真实盈亏 = 净到手 - 买入总成本 + cost_basis_total = cost * close_shares + pnl = round(return_total - cost_basis_total, 2) + pnl_pct = round((return_total - cost_basis_total) / cost_basis_total * 100, 2) + + entry_date = pos.get("entry_date", pos.get("added", today)) + try: + d1 = datetime.strptime(entry_date, "%Y-%m-%d") + d2 = datetime.strptime(today, "%Y-%m-%d") + hold_days = (d2 - d1).days + except Exception: + hold_days = 0 + + trades = read_trades() + trades.append({ + "symbol": symbol, + "name": pos.get("name", symbol), + "action": "sell", + "price": sell_price, + "shares": close_shares, + "date": today, + "commission": commission, + "cost_basis": cost, + "pnl": pnl, + "pnl_pct": pnl_pct, + "hold_days": hold_days, + "reason": reason + }) + save_trades(trades) + + # 检查是否为部分减仓 + if close_shares < pos["shares"]: + # 部分减仓:保留剩余持仓,更新数量 + pos["shares"] = pos["shares"] - close_shares + save_positions(positions) + + # 返还可用资金(使用真实费率计算的净到手金额) + acct = read_account() + if acct.get("total_capital", 0) > 0: + acct["available_cash"] = round(acct.get("available_cash", 0) + return_total, 2) + acct["updated"] = today + save_account(acct) + + result_str = "盈利" if pnl > 0 else "亏损" + return { + "status": "partial_closed", + "message": f"部分减仓 {pos.get('name', symbol)}({symbol}) 卖出价{sell_price} 卖出{close_shares}股 {result_str}{abs(pnl)}元({pnl_pct}%) 持仓{hold_days}天。剩余{pos['shares']}股", + "pnl": pnl, + "pnl_pct": pnl_pct, + "hold_days": hold_days, + "remaining_shares": pos["shares"] + } + + # 全部平仓 + pos["status"] = "closed" + pos["closed"] = today + pos["sell_price"] = sell_price + pos["pnl"] = pnl + save_positions(positions) + + # 返还可用资金(使用真实费率计算的净到手金额) + acct = read_account() + if acct.get("total_capital", 0) > 0: + acct["available_cash"] = round(acct.get("available_cash", 0) + return_total, 2) + acct["updated"] = today + save_account(acct) + + result_str = "盈利" if pnl > 0 else "亏损" + return { + "status": "closed", + "message": f"已平仓 {pos.get('name', symbol)}({symbol}) 卖出价{sell_price} {result_str}{abs(pnl)}元({pnl_pct}%) 持仓{hold_days}天", + "pnl": pnl, + "pnl_pct": pnl_pct, + "hold_days": hold_days + } + + +@synchronized_data +def position_update(symbol, stop_loss=None, target=None): + """更新活跃持仓的风控参数(止损/目标价)""" + positions = read_positions() + found = False + for pos in positions: + if pos.get("symbol") == symbol and pos.get("status") == "active": + if stop_loss is not None: + pos["stop_loss"] = float(stop_loss) + pos["atr_stop_loss"] = float(stop_loss) + if target is not None: + pos["target"] = float(target) + found = True + break + if not found: + return {"status": "not_found", "message": f"{symbol}无活跃持仓"} + save_positions(positions) + return {"status": "success", "message": f"{symbol} 风控参数已更新", "position": pos} + +@synchronized_data +def position_remove(symbol): + positions = read_positions() + found = False + for p in positions: + if p["symbol"] == symbol and p.get("status") == "active": + p["status"] = "closed" + p["closed"] = datetime.now().strftime("%Y-%m-%d") + found = True + if not found: + return {"status": "not_found", "message": f"{symbol}无活跃持仓"} + save_positions(positions) + return {"status": "removed", "message": f"已平仓 {symbol}(旧版接口,建议用position_close记录盈亏)"} + + +@synchronized_data +def position_show(): + positions = read_positions() + active = [p for p in positions if p.get("status") == "active"] + # 自动修复乱码名称 + changed = False + for p in active: + if '?' in str(p.get('name', '')): + new_name = _get_name(p['symbol']) + if new_name and '?' not in new_name: + p['name'] = new_name + changed = True + if changed: + save_positions(positions) + return {"count": len(active), "positions": active} + + +def trade_history(symbol=None): + trades = read_trades() + if symbol: + trades = [t for t in trades if t.get("symbol") == symbol] + return {"count": len(trades), "trades": trades} + + +def trade_stats(): + trades = read_trades() + sells = [t for t in trades if t.get("action") == "sell"] + + if not sells: + return {"total_trades": 0, "message": "暂无已平仓交易"} + + total_pnl = sum(t.get("pnl", 0) for t in sells) + wins = [t for t in sells if t.get("pnl", 0) > 0] + losses = [t for t in sells if t.get("pnl", 0) < 0] + win_rate = round(len(wins) / len(sells) * 100, 1) if sells else 0 + avg_hold = round(sum(t.get("hold_days", 0) for t in sells) / len(sells), 1) if sells else 0 + + best = max(sells, key=lambda x: x.get("pnl", 0)) if sells else None + worst = min(sells, key=lambda x: x.get("pnl", 0)) if sells else None + + return { + "total_sells": len(sells), + "win_rate": win_rate, + "wins": len(wins), + "losses": len(losses), + "total_pnl": round(total_pnl, 2), + "avg_hold_days": avg_hold, + "best": {"name": best.get("name"), "pnl": best.get("pnl"), "pct": best.get("pnl_pct")} if best else None, + "worst": {"name": worst.get("name"), "pnl": worst.get("pnl"), "pct": worst.get("pnl_pct")} if worst else None, + } + + + +@synchronized_data +def _calc_atr_trailing_stop(symbol, current_stop, price, multiplier=2.5): + """ATR跟踪止损:价格上涨时止损线上移,下跌时不动""" + try: + kline = get_kline_with_indicators(symbol, days=35) + if "latest" not in kline: + return current_stop + atr = kline["latest"].get("ATR") + if not atr or atr <= 0 or not _PTA_AVAILABLE: + return current_stop + # 跟踪止损线 = 当前价格 - multiplier × ATR + atr_stop = round(price - multiplier * atr, 2) + # 只上移不下移 + if atr_stop > current_stop: + return atr_stop + return current_stop + except Exception: + return current_stop + + +@synchronized_data +def update_trailing_stops(): + """批量更新所有活跃持仓的ATR跟踪止损线""" + positions = read_positions() + active = [p for p in positions if p.get("status") == "active"] + if not active: + return {"updated": 0, "message": "无活跃持仓"} + + symbols = [p["symbol"] for p in active] + quotes = _sina_batch_quotes(symbols) + quote_map = {q.get("代码"): q for q in quotes} + + updated = 0 + changes = [] + for p in active: + sym = p["symbol"] + old_stop = p.get("stop_loss", 0) + q = quote_map.get(sym, {}) + price = float(q.get("最新价", 0) or 0) + if price <= 0: + continue + new_stop = _calc_atr_trailing_stop(sym, old_stop, price) + if new_stop != old_stop: + p["stop_loss"] = new_stop + updated += 1 + changes.append({ + "symbol": sym, + "name": p.get("name", sym), + "old_stop": old_stop, + "new_stop": new_stop, + "change_pct": round((new_stop - old_stop) / old_stop * 100, 2) + }) + + if updated > 0: + save_positions(positions) + + return { + "updated": updated, + "total": len(active), + "changes": changes + } + + +@synchronized_data +def portfolio_summary(): + """组合概览:持仓+实时价格+盈亏+距止损(含ATR动态止损展示)""" + positions = read_positions() + active = [p for p in positions if p.get("status") == "active"] + if not active: + return {"count": 0, "positions": []} + symbols = [p["symbol"] for p in active] + quotes = _sina_batch_quotes(symbols) + quote_map = {q.get("代码"): q for q in quotes} + results = [] + for p in active: + sym = p["symbol"] + cost = p["cost"] + stop = p["stop_loss"] + target = p["target"] + q = quote_map.get(sym, {}) + price = float(q.get("最新价", 0) or 0) + # ATR动态止损参考(只读不写) + atr_stop = _calc_atr_trailing_stop(sym, stop, price) if price > 0 else stop + if price > 0 and cost > 0: + pnl_pct = round((price - cost) / cost * 100, 2) + dist_stop = round((price - stop) / stop * 100, 2) if stop > 0 else None + dist_atr_stop = round((price - atr_stop) / atr_stop * 100, 2) if atr_stop > 0 else None + dist_target = round((target - price) / target * 100, 2) if target > 0 else None + else: + pnl_pct = None + dist_stop = None + dist_atr_stop = None + dist_target = None + warning = "" + if dist_stop is not None and dist_stop < 3: + warning = f"距止损仅{dist_stop}%" + results.append({ + "symbol": sym, "name": p.get("name", sym), "cost": cost, "price": price, + "shares": p["shares"], "pnl_pct": pnl_pct, "stop_loss": stop, "target": target, + "atr_stop_loss": atr_stop, "dist_stop_pct": dist_stop, "dist_atr_stop_pct": dist_atr_stop, + "dist_target_pct": dist_target, + "warning": warning, "change_pct": float(q.get("涨跌幅", 0) or 0) + }) + # 计算总览 + total_market_value = sum(r.get("price", 0) * r.get("shares", 0) for r in results) + acct = read_account() + available_cash = acct.get("available_cash", 0) + total_capital = acct.get("total_capital", 0) + total_assets = round(total_market_value + available_cash, 2) + total_pnl = round(total_assets - total_capital, 2) if total_capital > 0 else None + total_pnl_pct = round(total_pnl / total_capital * 100, 2) if total_capital > 0 else None + + return { + "count": len(results), + "positions": results, + "account": { + "total_capital": total_capital, + "available_cash": round(available_cash, 2), + "market_value": round(total_market_value, 2), + "total_assets": total_assets, + "total_pnl": total_pnl, + "total_pnl_pct": total_pnl_pct + } + } + + +# ========== 行情与指标 ========== + +def get_realtime_quote(symbol): + try: + result = _sina_realtime(symbol) + if result: + return result + return {"error": f"未找到 {symbol}"} + except Exception as e: + return {"error": str(e)[:80]} + + +def get_batch_quotes(symbols): + try: + results = _sina_batch_quotes(symbols) + return {"stocks": results, "count": len(results)} + except Exception as e: + return {"error": str(e)[:80]} + + +def _sf(val): + if val is None or (isinstance(val, float) and (val != val)): + return None + return round(float(val), 2) + + +def _ma_align(latest): + ma5, ma10, ma20 = latest.get('MA5'), latest.get('MA10'), latest.get('MA20') + if any(v is None or (isinstance(v, float) and (v != v)) for v in [ma5, ma10, ma20]): + return "数据不足" + if ma5 > ma10 > ma20: + return "多头排列" + elif ma5 < ma10 < ma20: + return "空头排列" + return "交叉缠绕" + + +def get_kline_with_indicators(symbol, days=120): + try: + # 优先用新浪K线API(秒级响应) + df = _sina_kline(symbol, datalen=days) + + # 新浪失败时降级到AKShare + if df is None or df.empty: + sz_code = f"sz{symbol}" if symbol.startswith(('0', '3')) else f"sh{symbol}" + df = safe_request(ak.stock_zh_a_daily, symbol=sz_code, adjust="qfq") + + if df is None or df.empty: + return {"error": f"未获取到 {symbol} K线"} + + df = df.tail(days).copy() + df['date'] = df['date'].astype(str) + close = df['close'].astype(float) + high = df['high'].astype(float) if 'high' in df.columns else close + low = df['low'].astype(float) if 'low' in df.columns else close + volume = df['volume'].astype(float) if 'volume' in df.columns else pd.Series([0]*len(df)) + + for w in [5, 10, 20, 60]: + df[f'MA{w}'] = close.rolling(window=w).mean().round(2) + + ema12 = close.ewm(span=12, adjust=False).mean() + ema26 = close.ewm(span=26, adjust=False).mean() + dif = (ema12 - ema26).round(3) + dea = dif.ewm(span=9, adjust=False).mean().round(3) + df['MACD_DIF'] = dif + df['MACD_DEA'] = dea + df['MACD'] = ((dif - dea) * 2).round(3) + + delta = close.diff() + gain = delta.where(delta > 0, 0) + loss = (-delta).where(delta < 0, 0) + avg_gain = gain.rolling(window=14).mean() + avg_loss = loss.rolling(window=14).mean() + rs = avg_gain / avg_loss.replace(0, np.nan) + df['RSI'] = (100 - (100 / (1 + rs))).round(2) + + bb_mid = close.rolling(window=20).mean() + bb_std = close.rolling(window=20).std() + df['BOLL_UP'] = (bb_mid + 2 * bb_std).round(2) + df['BOLL_MID'] = bb_mid.round(2) + df['BOLL_DN'] = (bb_mid - 2 * bb_std).round(2) + + if _PTA_AVAILABLE: + # pandas-ta 模式:计算 KDJ, OBV, ATR, WR, CCI + stoch_df = ta.stoch(high, low, close, k=9, d=3, smooth_k=3) + if stoch_df is not None and not stoch_df.empty: + df['KDJ_K'] = stoch_df.iloc[:, 0].round(2) + df['KDJ_D'] = stoch_df.iloc[:, 1].round(2) + df['KDJ_J'] = (3 * df['KDJ_K'] - 2 * df['KDJ_D']).round(2) + else: + df['KDJ_K'] = np.nan; df['KDJ_D'] = np.nan; df['KDJ_J'] = np.nan + + obv_s = ta.obv(close, volume) + df['OBV'] = obv_s.round(0) if obv_s is not None else pd.Series([0]*len(df)) + + atr_s = ta.atr(high, low, close, length=14) + df['ATR'] = atr_s.round(2) if atr_s is not None else pd.Series([0]*len(df)) + + wr_s = ta.willr(high, low, close, length=14) + df['WR'] = wr_s.round(2) if wr_s is not None else pd.Series([-50]*len(df)) + + cci_s = ta.cci(high, low, close, length=14) + df['CCI'] = cci_s.round(2) if cci_s is not None else pd.Series([0]*len(df)) + else: + # 降级模式:填充 NaN + df['KDJ_K'] = np.nan; df['KDJ_D'] = np.nan; df['KDJ_J'] = np.nan + df['OBV'] = np.nan; df['ATR'] = np.nan; df['WR'] = np.nan; df['CCI'] = np.nan + + latest = df.iloc[-1] + rsi_val = float(latest.get('RSI', 50)) if latest.get('RSI') is not None and not (isinstance(latest.get('RSI'), float) and (latest.get('RSI') != latest.get('RSI'))) else 50.0 + + macd_signal = "无明显信号" + if len(df) >= 3: + h1 = df['MACD'].iloc[-1] + h2 = df['MACD'].iloc[-2] + if h2 < 0 and h1 > 0: + macd_signal = "金叉(偏多)" + elif h2 > 0 and h1 < 0: + macd_signal = "死叉(偏空)" + + rsi_judge = "超买区" if rsi_val > 70 else ("超卖区" if rsi_val < 30 else "中性区") + + # 新增指标判断 (仅 pandas-ta 模式有效) + kdj_k = _sf(latest.get('KDJ_K')) + kdj_d = _sf(latest.get('KDJ_D')) + kdj_j = _sf(latest.get('KDJ_J')) + kdj_judge = "未启用" + if _PTA_AVAILABLE and kdj_j is not None: + if kdj_j > 100: kdj_judge = "超买区(J>100)" + elif kdj_j < 0: kdj_judge = "超卖区(J<0)" + elif kdj_k > 80 and kdj_d > 80: kdj_judge = "高位区" + elif kdj_k < 20 and kdj_d < 20: kdj_judge = "低位区" + else: kdj_judge = "中性区" + + wr_val = _sf(latest.get('WR')) + wr_judge = "未启用" + if _PTA_AVAILABLE and wr_val is not None: + wr_judge = "超买区" if wr_val > -20 else ("超卖区" if wr_val < -80 else "中性区") + + cci_val = _sf(latest.get('CCI')) + cci_judge = "未启用" + if _PTA_AVAILABLE and cci_val is not None: + cci_judge = "超买区" if cci_val > 100 else ("超卖区" if cci_val < -100 else "中性区") + + recent = [] + for _, row in df.tail(3).iterrows(): + recent.append({ + "日期": str(row['date']), + "收盘": round(float(row['close']), 2), + "成交量": int(row['volume']) if row.get('volume') is not None else 0, + }) + + return { + "symbol": symbol, + "latest": { + "日期": str(latest['date']), + "收盘": round(float(latest['close']), 2), + "MA5": _sf(latest.get('MA5')), + "MA10": _sf(latest.get('MA10')), + "MA20": _sf(latest.get('MA20')), + "MA60": _sf(latest.get('MA60')), + "MACD_DIF": _sf(latest.get('MACD_DIF')), + "MACD_DEA": _sf(latest.get('MACD_DEA')), + "MACD柱": _sf(latest.get('MACD')), + "RSI": round(rsi_val, 1), + "BOLL上": _sf(latest.get('BOLL_UP')), + "BOLL中": _sf(latest.get('BOLL_MID')), + "BOLL下": _sf(latest.get('BOLL_DN')), + }, + "summary": { + "MACD信号": macd_signal, + "RSI状态": rsi_judge, + "均线排列": _ma_align(latest), + }, + "recent_3days": recent + } + except Exception as e: + return {"error": str(e)[:80]} + + +def get_stock_info(symbol): + try: + result = {} + # Tushare daily_basic + basic = _get_daily_basic(symbol) + if basic: + total_mv = basic.get('total_mv') + circ_mv = basic.get('circ_mv') + result.update({ + "股票简称": symbol, + "PE(TTM)": round(basic.get('pe_ttm', 0) or 0, 1), + "PB": round(basic.get('pb', 0) or 0, 2), + "总市值(亿)": round(total_mv / 10000, 1) if total_mv else None, + "流通市值(亿)": round(circ_mv / 10000, 1) if circ_mv else None, + "换手率": round(basic.get('turnover_rate', 0) or 0, 2), + "量比": round(basic.get('volume_ratio', 0) or 0, 2), + "股息率": round(basic.get('dv_ratio', 0) or 0, 2), + "数据日期": basic.get('trade_date', ''), + "数据源": "Tushare" + }) + # 新浪实时行情补充 + quote = _sina_realtime(symbol) + if quote: + result["股票简称"] = quote.get("名称", symbol) + result["最新价"] = quote.get("最新价", 0) + result["涨跌幅"] = quote.get("涨跌幅", 0) + result["成交额"] = quote.get("成交额", 0) + return result if result else {"error": f"未找到 {symbol}"} + except Exception as e: + return {"error": str(e)[:60]} + + +def _em_market_code(symbol): + """东方财富市场代码""" + if symbol.startswith('6'): + return f"1.{symbol}" + else: + return f"0.{symbol}" + + +def _get_etf_flow(symbol): + """ETF资金流向(AKShare fund_etf_spot_em实时快照)""" + try: + df = safe_request(ak.fund_etf_spot_em) + if df is None or df.empty: + return None + row = df[df['代码'].astype(str) == str(symbol)] + if row.empty: + return None + r = row.iloc[0] + + def _safe_float(val, divide=1): + try: + return round(float(val or 0) / divide, 2) + except: + return 0 + + return { + "symbol": symbol, + "name": str(r.get('名称', symbol)), + "latest_flow": { + "日期": str(r.get('数据日期', ''))[:10], + "主力净流入": _safe_float(r.get('主力净流入-净额'), 10000), + "超大单净流入": _safe_float(r.get('超大单净流入-净额'), 10000), + "大单净流入": _safe_float(r.get('大单净流入-净额'), 10000), + "中单净流入": _safe_float(r.get('中单净流入-净额'), 10000), + "小单净流入": _safe_float(r.get('小单净流入-净额'), 10000), + }, + "recent_flow": [{ + "日期": str(r.get('数据日期', ''))[:10], + "主力净流入": _safe_float(r.get('主力净流入-净额'), 10000), + "超大单净流入": _safe_float(r.get('超大单净流入-净额'), 10000), + "大单净流入": _safe_float(r.get('大单净流入-净额'), 10000), + "中单净流入": _safe_float(r.get('中单净流入-净额'), 10000), + "小单净流入": _safe_float(r.get('小单净流入-净额'), 10000), + }], + "summary": { + "主力净流入万": _safe_float(r.get('主力净流入-净额'), 10000), + "超大单净流入万": _safe_float(r.get('超大单净流入-净额'), 10000), + "大单净流入万": _safe_float(r.get('大单净流入-净额'), 10000), + "方向": "主力流入" if _safe_float(r.get('主力净流入-净额')) > 0 else "主力流出", + "换手率": _safe_float(r.get('换手率')), + "折价率": _safe_float(r.get('基金折价率')), + "净份额(亿)": _safe_float(r.get('最新份额'), 100000000), + }, + "_source": "AKShare(ETF现货)" + } + except Exception as e: + log(f"ETF资金流向获取失败: {e}") + return None + + +def get_capital_flow(symbol): + """资金流向 — ETF走fund_etf_spot_em,个股走Tushare moneyflow""" + # ETF代码检测:5开头(沪市ETF)或159开头(深市ETF) + if symbol.startswith('5') or symbol.startswith('159'): + etf_result = _get_etf_flow(symbol) + if etf_result: + return etf_result + return {"error": f"ETF {symbol} 资金流向获取失败(fund_etf_spot_em无数据)"} + try: + ts_code = _ts_code(symbol) + data = _tushare_api('moneyflow', + {'ts_code': ts_code, 'limit': '5'}, + 'ts_code,trade_date,buy_sm_amount,sell_sm_amount,buy_md_amount,sell_md_amount,buy_lg_amount,sell_lg_amount,buy_elg_amount,sell_elg_amount,net_mf_amount') + if not data or not data.get('items'): + log(f"Tushare moneyflow无数据({symbol}),尝试AKShare容灾") + ak_result = _ak_capital_flow(symbol) + if ak_result: + return ak_result + return {"error": f"未获取到 {symbol} 资金流向(Tushare+AKShare均无数据,ETF可能不覆盖)"} + fields = data.get('fields', []) + recent_flow = [] + for item in data['items']: + row = dict(zip(fields, item)) + trade_date = row.get('trade_date', '') + formatted_date = f"{trade_date[:4]}-{trade_date[4:6]}-{trade_date[6:]}" if len(trade_date) == 8 else trade_date + sm_net = round(float(row.get('buy_sm_amount', 0) or 0) - float(row.get('sell_sm_amount', 0) or 0), 2) + md_net = round(float(row.get('buy_md_amount', 0) or 0) - float(row.get('sell_md_amount', 0) or 0), 2) + lg_net = round(float(row.get('buy_lg_amount', 0) or 0) - float(row.get('sell_lg_amount', 0) or 0), 2) + elg_net = round(float(row.get('buy_elg_amount', 0) or 0) - float(row.get('sell_elg_amount', 0) or 0), 2) + main_net = round(float(row.get('net_mf_amount', 0) or 0), 2) + entry = { + "日期": formatted_date, + "主力净流入": main_net, + "小单净流入": sm_net, + "中单净流入": md_net, + "大单净流入": lg_net, + "超大单净流入": elg_net, + } + recent_flow.append(entry) + latest = recent_flow[0] if recent_flow else {} + return { + "symbol": symbol, + "latest_flow": latest, + "recent_flow": recent_flow, + "summary": { + "主力净流入万": latest.get("主力净流入", 0), + "超大单净流入万": latest.get("超大单净流入", 0), + "大单净流入万": latest.get("大单净流入", 0), + "方向": "主力流入" if latest.get("主力净流入", 0) > 0 else "主力流出" + } + } + except Exception as e: + return {"error": f"资金流向获取失败: {str(e)[:60]}"} + + +def get_sector_ranking(): + """Tushare申万行业涨跌排名(替代已封禁的东方财富push2)""" + try: + # Step1: 拿31个申万一级行业 + ind_data = _tushare_api('index_classify', + {'level': 'L1', 'src': 'SW2021'}, + 'index_code,industry_name') + if not ind_data or not ind_data.get('items'): + log("Tushare申万行业列表获取失败,尝试AKShare容灾") + ak_result = _ak_sector_ranking() + if ak_result: + return ak_result + return {"error": "板块排名获取失败(Tushare+AKShare均无数据)"} + + industries = ind_data.get('items', []) + + # Step2: 批量拉涨跌幅 + results = [] + for code, name in industries: + time.sleep(0.15) + idx_data = _tushare_api('index_daily', + {'ts_code': code, 'limit': '1'}, + 'ts_code,trade_date,pct_chg') + if idx_data and idx_data.get('items'): + pct = float(idx_data['items'][0][2] or 0) + results.append({"板块名称": name, "涨跌幅": round(pct, 2)}) + + if not results: + return {"error": "板块涨跌幅数据获取失败"} + + results.sort(key=lambda x: x["涨跌幅"], reverse=True) + top3 = results[:3] + bot3 = results[-3:] + return {"涨幅前3": top3, "跌幅前3": bot3, "数据源": "Tushare(申万一级)"} + except Exception as e: + return {"error": f"板块排名获取失败: {str(e)[:60]}"} + + +# ========== 增强异动扫描 ========== + +SEVERITY_ORDER = {"normal": 0, "medium": 1, "high": 2, "critical": 3} + + +def _max_severity(current, new): + if SEVERITY_ORDER.get(new, 0) > SEVERITY_ORDER.get(current, 0): + return new + return current + + +def get_hs300_change(): + """沪深300今日涨跌幅(Tushare主→AKShare容灾)""" + try: + data = _tushare_api('index_daily', + {'ts_code': '000300.SH', 'limit': '1'}, + 'ts_code,trade_date,close,pct_chg') + if data and data.get('items'): + return float(data['items'][0][3] or 0) + except Exception: + pass + # AKShare容灾 + try: + df = safe_request(ak.stock_zh_index_daily, symbol='sh000300') + if df is not None and len(df) >= 2: + c1 = float(df.iloc[-2].get('close', 0) or 0) + c2 = float(df.iloc[-1].get('close', 0) or 0) + if c1 > 0: + return round((c2 - c1) / c1 * 100, 2) + except Exception as e: + log(f"AKShare沪深300容灾失败: {e}") + return 0.0 + + +def scan_anomalies_enhanced(symbols_override=None): + """增强版异动扫描 - 7类检测""" + if symbols_override: + watchlist = [{"code": s, "name": s} for s in symbols_override] + else: + watchlist = read_watchlist() + + if not watchlist: + return { + "scan_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "total": 0, "anomaly_count": 0, "normal_count": 0, + "anomalies": [], + "message": "自选股列表为空,请先通过 watchlist_add 添加自选股" + } + + symbols = [s["code"] for s in watchlist] + + # 1. 批量实时行情(新浪源) + try: + df_all = _get_spot_data() + if df_all is None: + return {"error": "全市场行情数据获取失败(新浪源)"} + except Exception as e: + return {"error": f"行情数据获取失败: {str(e)[:80]}"} + + # 2. 沪深300涨跌幅 + hs300_change = get_hs300_change() + + anomalies = [] + normal_count = 0 + + for item in watchlist: + sym = item["code"] + sym_name = item.get("name", sym) + + row = df_all[df_all['代码'] == sym] + if row.empty: + normal_count += 1 + continue + + r = row.iloc[0] + + # 提取实时数据 + pct = float(r.get('涨跌幅', 0) or 0) + price = round(float(r.get('最新价', 0) or 0), 2) + vol_ratio = float(r.get('量比', 0) or 0) + turnover = float(r.get('换手率', 0) or 0) + high = round(float(r.get('最高', 0) or 0), 2) + prev_close = round(float(r.get('昨收', 0) or 0), 2) + flow_mv = float(r.get('流通市值', 0) or 0) + + alert = { + "code": sym, + "name": sym_name, + "price": price, + "change_pct": round(pct, 2), + "volume_ratio": round(vol_ratio, 1), + "turnover_rate": round(turnover, 1), + "anomaly_types": [], + "severity": "normal", + "details": [] + } + + # ===== Tier 1: 立即通知 ===== + + # 涨跌停逼近 (≥±8%) + if abs(pct) >= 8.0: + alert["anomaly_types"].append("涨跌停逼近") + alert["severity"] = "critical" + d = "逼近涨停" if pct > 0 else "逼近跌停" + alert["details"].append(f"{d},涨跌幅{pct:+.2f}%") + + # 放量标记(后续结合MA判断) + heavy_volume = vol_ratio >= 2.0 + + # ===== Tier 2: 盘中汇总 ===== + + # 涨跌幅偏离大盘 (≥±3%) + deviation = pct - hs300_change + if abs(deviation) >= 3.0: + alert["anomaly_types"].append("独立走势") + alert["severity"] = _max_severity(alert["severity"], "medium") + alert["details"].append(f"偏离沪深300 {deviation:+.2f}%") + + # 换手率异动 (>10%) + if turnover >= 10.0: + alert["anomaly_types"].append("高换手率") + alert["severity"] = _max_severity(alert["severity"], "medium") + alert["details"].append(f"换手率{turnover:.1f}%") + + # 量价背离 (创新高 + 量比<0.7) + if prev_close > 0 and high > prev_close * 1.02 and 0 < vol_ratio < 0.7: + alert["anomaly_types"].append("量价背离") + alert["severity"] = _max_severity(alert["severity"], "medium") + alert["details"].append(f"创盘中新高但量比仅{vol_ratio:.1f},警惕诱多") + + # ===== 深度检查:仅对有异动的个股取K线+资金 ===== + need_deep = heavy_volume or alert["severity"] != "normal" + + if heavy_volume: + alert["details"].append(f"量比{vol_ratio:.1f}倍") + if alert["severity"] == "normal": + alert["severity"] = "medium" + + if need_deep: + # K线 → MA20/MA60破位 + kline = get_kline_with_indicators(sym, days=35) + if "latest" in kline: + ma20 = kline["latest"].get("MA20") + ma60 = kline["latest"].get("MA60") + + if ma20 and price and prev_close: + if heavy_volume and price > ma20 and prev_close <= ma20: + alert["anomaly_types"].append("放量突破MA20") + alert["severity"] = _max_severity(alert["severity"], "high") + alert["details"].append(f"放量突破MA20({ma20:.2f})") + elif heavy_volume and price < ma20 and prev_close >= ma20: + alert["anomaly_types"].append("放量跌破MA20") + alert["severity"] = _max_severity(alert["severity"], "high") + alert["details"].append(f"放量跌破MA20({ma20:.2f})") + + if ma60 and price and prev_close: + if price > ma60 and prev_close <= ma60: + alert["anomaly_types"].append("突破MA60牛熊线") + alert["severity"] = _max_severity(alert["severity"], "high") + alert["details"].append(f"突破MA60({ma60:.2f})") + elif price < ma60 and prev_close >= ma60: + alert["anomaly_types"].append("跌破MA60牛熊线") + alert["severity"] = _max_severity(alert["severity"], "high") + alert["details"].append(f"跌破MA60({ma60:.2f})") + + # Tushare真实量比补充 + basic = _get_daily_basic(sym) + if basic and basic.get('volume_ratio'): + real_vr = float(basic.get('volume_ratio', 0) or 0) + if real_vr > vol_ratio: + vol_ratio = real_vr + alert["volume_ratio"] = round(real_vr, 1) + if real_vr >= 2.0 and not heavy_volume: + heavy_volume = True + alert["anomaly_types"].append("放量(Tushare)") + alert["severity"] = _max_severity(alert["severity"], "medium") + alert["details"].append(f"量比{real_vr:.1f}倍(Tushare)") + + # 资金流向 → 主力异动 + flow = get_capital_flow(sym) + if "recent_flow" in flow and flow["recent_flow"]: + main_net = flow["recent_flow"][-1].get("主力净流入万", 0) + if abs(main_net) >= 2000: + alert["anomaly_types"].append("主力资金异动") + alert["severity"] = _max_severity(alert["severity"], "high") + alert["details"].append(f"主力净流入{main_net:+.0f}万") + elif flow_mv > 0: + threshold_1pct = flow_mv * 0.01 / 10000 + if abs(main_net) >= threshold_1pct: + alert["anomaly_types"].append("主力资金异动(占比)") + alert["severity"] = _max_severity(alert["severity"], "medium") + pct_flow = abs(main_net) * 10000 / flow_mv * 100 + alert["details"].append(f"主力净流入占流通市值{pct_flow:.1f}%") + + # 汇总 + if alert["severity"] != "normal": + anomalies.append(alert) + else: + normal_count += 1 + + # ===== 持仓监控 ===== + active_positions = [p for p in read_positions() if p.get("status") == "active"] + for pos in active_positions: + sym = pos["symbol"] + row_p = df_all[df_all['代码'] == sym] + if row_p.empty: + continue + r = row_p.iloc[0] + price = round(float(r.get('最新价', 0) or 0), 2) + if price <= 0: + continue + + stop_loss = pos["stop_loss"] + target = pos["target"] + cost = pos["cost"] + name = pos.get("name", sym) + + p_alert = { + "code": sym, + "name": name, + "price": price, + "change_pct": round(float(r.get('涨跌幅', 0) or 0), 2), + "position": True, + "anomaly_types": [], + "severity": "normal", + "details": [f"持仓: 成本{cost} 数量{pos['shares']} 止损{stop_loss} 目标{target}"] + } + + if price <= stop_loss: + p_alert["anomaly_types"].append("触发止损") + p_alert["severity"] = "critical" + p_alert["details"].append(f"现价{price} <= 止损位{stop_loss}") + elif price >= target: + p_alert["anomaly_types"].append("触发目标") + p_alert["severity"] = "critical" + p_alert["details"].append(f"现价{price} >= 目标位{target}") + else: + if stop_loss > 0: + dist_stop = (price - stop_loss) / stop_loss * 100 + if dist_stop < 3: + p_alert["anomaly_types"].append("接近止损") + p_alert["severity"] = "high" + p_alert["details"].append(f"距止损仅{dist_stop:.1f}%") + if target > 0: + dist_target = (target - price) / target * 100 + if dist_target < 5: + p_alert["anomaly_types"].append("接近目标") + p_alert["severity"] = "high" + p_alert["details"].append(f"距目标仅{dist_target:.1f}%") + + if p_alert["severity"] != "normal": + anomalies.append(p_alert) + + return { + "scan_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "hs300_change": round(hs300_change, 2), + "total": len(symbols), + "anomaly_count": len(anomalies), + "normal_count": normal_count, + "anomalies": anomalies + } + + +# ========== 盘后日报 ========== + +def daily_report(): + watchlist = read_watchlist() + if not watchlist: + return {"error": "自选股列表为空"} + + symbols = [item["code"] for item in watchlist] + try: + quotes = _sina_batch_quotes(symbols) + quote_map = {q.get("代码"): q for q in quotes} + except Exception as e: + return {"error": f"行情数据获取失败: {str(e)[:80]}"} + + hs300_change = get_hs300_change() + report_items = [] + + for item in watchlist: + sym = item["code"] + sym_name = item.get("name", sym) + + q = quote_map.get(sym, {}) + if not q: + continue + + entry = { + "code": sym, + "name": sym_name, + "price": round(float(q.get("最新价", 0) or 0), 2), + "change_pct": round(float(q.get("涨跌幅", 0) or 0), 2), + "volume_ratio": 0, + "turnover": 0, + } + + basic = _get_daily_basic(sym) + if basic: + entry["volume_ratio"] = round(basic.get('volume_ratio', 0) or 0, 2) + entry["turnover"] = round(basic.get('turnover_rate', 0) or 0, 2) + + # K线 → 均线状态 + 关注要点 + kline = get_kline_with_indicators(sym, days=70) + if "latest" in kline: + entry["ma_align"] = kline["summary"]["均线排列"] + entry["macd_signal"] = kline["summary"]["MACD信号"] + entry["rsi"] = kline["latest"]["RSI"] + entry["ma5"] = kline["latest"].get("MA5") + entry["ma20"] = kline["latest"].get("MA20") + entry["ma60"] = kline["latest"].get("MA60") + + # 次日关注要点 + notes = [] + price = entry["price"] + ma5 = kline["latest"].get("MA5") + ma20 = kline["latest"].get("MA20") + ma60 = kline["latest"].get("MA60") + rsi = kline["latest"]["RSI"] + + if ma5 and price: + notes.append(f"MA5支撑/压力: {ma5:.2f}") + if ma20 and price: + gap = abs(price - ma20) / ma20 * 100 + if gap < 1.5: + notes.append(f"接近MA20({ma20:.2f}),关注突破方向") + if ma60 and price: + gap = abs(price - ma60) / ma60 * 100 + if gap < 2: + notes.append(f"接近MA60({ma60:.2f}),牛熊线攻防") + if rsi > 70: + notes.append("RSI超买,注意回调风险") + elif rsi < 30: + notes.append("RSI超卖,关注反弹信号") + + entry["next_day_notes"] = notes + else: + entry["next_day_notes"] = ["K线数据不足"] + + report_items.append(entry) + + return { + "report_date": datetime.now().strftime("%Y-%m-%d"), + "hs300_change": round(hs300_change, 2), + "total": len(report_items), + "stocks": report_items + } + + +# ========== 主入口 ========== + +# ========== 回测引擎 v3.1 ========== + +def _get_kline_range(symbol, start_date, end_date): + """获取指定区间的日K线数据(Tushare主→AKShare容灾)""" + # Tushare主源 + ts_code = _ts_code(symbol) + data = _tushare_api('daily', + {'ts_code': ts_code, 'start_date': start_date.replace('-', ''), 'end_date': end_date.replace('-', '')}, + 'ts_code,trade_date,open,high,low,close,vol,amount,pct_chg') + + if data and data.get('items'): + fields = data.get('fields', []) + rows = [] + for item in data['items']: + row = dict(zip(fields, item)) + rows.append({ + 'date': f"{row['trade_date'][:4]}-{row['trade_date'][4:6]}-{row['trade_date'][6:]}", + 'open': float(row.get('open', 0) or 0), + 'high': float(row.get('high', 0) or 0), + 'low': float(row.get('low', 0) or 0), + 'close': float(row.get('close', 0) or 0), + 'volume': float(row.get('vol', 0) or 0) + }) + # Tushare返回的是倒序,需要反转为正序 + rows.reverse() + df = pd.DataFrame(rows) + return df + + # AKShare容灾 + try: + sz_code = f"sz{symbol}" if symbol.startswith(('0', '3')) else f"sh{symbol}" + df = safe_request(ak.stock_zh_a_daily, symbol=sz_code, start_date=start_date, end_date=end_date, adjust="qfq") + if df is not None and not df.empty: + df = df.rename(columns={'date': 'date', 'open': 'open', 'high': 'high', 'low': 'low', 'close': 'close', 'volume': 'volume'}) + return df + except Exception as e: + log(f"AKShare区间K线容灾失败: {e}") + + return None + + +def _gen_signals(df, strategy): + """根据策略名称在K线DataFrame上生成买卖信号列""" + df = df.copy() + df['signal'] = 0 # 1=买入, -1=卖出 + + if strategy == "ma_cross": + df['MA5'] = df['close'].rolling(5).mean() + df['MA20'] = df['close'].rolling(20).mean() + df['prev_MA5'] = df['MA5'].shift(1) + df['prev_MA20'] = df['MA20'].shift(1) + + # 金叉买入 + golden_cross = (df['MA5'] > df['MA20']) & (df['prev_MA5'] <= df['prev_MA20']) + df.loc[golden_cross, 'signal'] = 1 + # 死叉卖出 + death_cross = (df['MA5'] < df['MA20']) & (df['prev_MA5'] >= df['prev_MA20']) + df.loc[death_cross, 'signal'] = -1 + + elif strategy == "macd": + ema12 = df['close'].ewm(span=12, adjust=False).mean() + ema26 = df['close'].ewm(span=26, adjust=False).mean() + df['DIF'] = ema12 - ema26 + df['DEA'] = df['DIF'].ewm(span=9, adjust=False).mean() + df['MACD'] = (df['DIF'] - df['DEA']) * 2 + + df['prev_MACD'] = df['MACD'].shift(1) + # MACD柱由负转正买入 + df.loc[(df['MACD'] > 0) & (df['prev_MACD'] <= 0), 'signal'] = 1 + # MACD柱由正转负卖出 + df.loc[(df['MACD'] < 0) & (df['prev_MACD'] >= 0), 'signal'] = -1 + + elif strategy == "rsi": + delta = df['close'].diff() + gain = delta.where(delta > 0, 0) + loss = (-delta).where(delta < 0, 0) + avg_gain = gain.rolling(14).mean() + avg_loss = loss.rolling(14).mean() + rs = avg_gain / avg_loss.replace(0, np.nan) + df['RSI'] = 100 - (100 / (1 + rs)) + + # RSI跌破30买入(超卖反弹) + df.loc[df['RSI'] < 30, 'signal'] = 1 + # RSI突破70卖出(超买回落) + df.loc[df['RSI'] > 70, 'signal'] = -1 + + elif strategy == "boll": + df['BOLL_MID'] = df['close'].rolling(20).mean() + df['BOLL_STD'] = df['close'].rolling(20).std() + df['BOLL_UP'] = df['BOLL_MID'] + 2 * df['BOLL_STD'] + df['BOLL_DN'] = df['BOLL_MID'] - 2 * df['BOLL_STD'] + + # 跌破下轨买入 + df.loc[df['close'] < df['BOLL_DN'], 'signal'] = 1 + # 突破上轨卖出 + df.loc[df['close'] > df['BOLL_UP'], 'signal'] = -1 + + return df + + +def _run_backtest_core(df, initial_capital, commission_rate=0.0003): + """执行回测主循环""" + position = 0 # 持仓数量 + cash = float(initial_capital) + entry_price = 0 + trades = [] + equity_curve = [] + + for _, row in df.iterrows(): + signal = row['signal'] + price = row['close'] + date = row['date'] + + if signal == 1 and position == 0: + # 全仓买入(回测允许零股,不受A股100股整手限制) + shares = cash / (price * (1 + commission_rate)) + if shares > 0: + cost = shares * price * (1 + commission_rate) + cash -= cost + position = shares + entry_price = price + trades.append({'date': date, 'action': 'BUY', 'price': price, 'shares': shares}) + + elif signal == -1 and position > 0: + # 全仓卖出 + revenue = position * price * (1 - commission_rate) + cash += revenue + pnl = (price - entry_price) * position + trades.append({'date': date, 'action': 'SELL', 'price': price, 'shares': position, 'pnl': round(pnl, 2)}) + position = 0 + entry_price = 0 + + # 记录每日净值 + total_value = cash + position * price + equity_curve.append({'date': date, 'equity': round(total_value, 2)}) + + final_value = cash + position * df.iloc[-1]['close'] + return trades, equity_curve, round(final_value, 2) + + +def _calc_metrics(equity_curve, trades, initial_capital, strategy): + """计算绩效指标""" + df_eq = pd.DataFrame(equity_curve) + df_eq['daily_return'] = df_eq['equity'].pct_change().fillna(0) + + final_equity = df_eq['equity'].iloc[-1] + total_return = round((final_equity - initial_capital) / initial_capital * 100, 2) + + # 最大回撤 + df_eq['peak'] = df_eq['equity'].cummax() + df_eq['drawdown'] = (df_eq['equity'] - df_eq['peak']) / df_eq['peak'] + max_drawdown = round(df_eq['drawdown'].min() * 100, 2) + + # 夏普比率 (年化,无风险利率设为2%) + daily_returns = df_eq['daily_return'].values + if len(daily_returns) > 1 and np.std(daily_returns) > 0: + sharpe = np.mean(daily_returns) * 252 / (np.std(daily_returns) * np.sqrt(252)) + # 减去无风险利率 + sharpe = round(sharpe - 0.02, 2) + else: + sharpe = 0.0 + + # 胜率 + sell_trades = [t for t in trades if t['action'] == 'SELL'] + if sell_trades: + wins = [t for t in sell_trades if t.get('pnl', 0) > 0] + win_rate = round(len(wins) / len(sell_trades) * 100, 1) + total_pnl = round(sum(t.get('pnl', 0) for t in sell_trades), 2) + else: + win_rate = 0.0 + total_pnl = 0.0 + + return { + "strategy": strategy, + "initial_capital": initial_capital, + "final_equity": final_equity, + "total_return_pct": total_return, + "max_drawdown_pct": max_drawdown, + "sharpe_ratio": sharpe, + "total_trades": len(sell_trades), + "win_rate": win_rate, + "total_pnl": total_pnl + } + + +def backtest(symbol, start_date, end_date, strategy="ma_cross", initial_capital=100000): + """回测主入口""" + valid_strategies = ["ma_cross", "macd", "rsi", "boll"] + if strategy not in valid_strategies: + return {"error": f"无效策略: {strategy}。支持: {', '.join(valid_strategies)}"} + + df = _get_kline_range(symbol, start_date, end_date) + if df is None or df.empty or len(df) < 30: + return {"error": f"获取{symbol}在{start_date}至{end_date}的数据失败或不足30条"} + + df = _gen_signals(df, strategy) + trades, equity_curve, final_value = _run_backtest_core(df, initial_capital) + metrics = _calc_metrics(equity_curve, trades, initial_capital, strategy) + + return { + "symbol": symbol, + "period": f"{start_date} 至 {end_date}", + "metrics": metrics, + "trades": trades, + "equity_curve_sample": equity_curve[::max(1, len(equity_curve)//10)][:15] # 采样返回,避免过长 + } + + +# ========== 选股框架 v3.0 ========== + +def market_check(): + """大盘环境判断 — 沪深300趋势""" + try: + # 沪深300用Tushare index_daily + ts_data = _tushare_api('index_daily', + {'ts_code': '000300.SH'}, + 'ts_code,trade_date,close,pct_chg') + if not ts_data or not ts_data.get('items'): + log("Tushare沪深300数据失败(market_check),尝试AKShare容灾") + try: + df_idx = safe_request(ak.stock_zh_index_daily, symbol='sh000300') + if df_idx is not None and len(df_idx) >= 60: + closes = [round(float(x), 2) for x in df_idx['close'].tail(60).tolist()] + else: + return {"error": "沪深300数据获取失败(Tushare+AKShare均无数据)"} + except Exception as e2: + return {"error": f"沪深300数据获取失败: {str(e2)[:60]}"} + + items = ts_data.get('items', []) + fields = ts_data.get('fields', []) + closes = [dict(zip(fields, item)).get('close', 0) for item in items[:60]] + closes.reverse() # 最新在最后 + + if len(closes) < 60: + return {"error": "沪深300数据不足"} + + arr = np.array(closes, dtype=float) + ma5 = round(float(arr[-5:].mean()), 2) + ma20 = round(float(arr[-20:].mean()), 2) + ma60 = round(float(arr[-60:].mean()), 2) + close = round(float(arr[-1]), 2) + pct = round(float(dict(zip(fields, items[0])).get('pct_chg', 0)), 2) + + + if ma5 > ma20 > ma60: + trend = "多头排列" + action = "正常仓位,可以建仓" + elif ma5 < ma20 < ma60: + trend = "空头排列" + action = "不建仓,收紧止损" + else: + trend = "交叉缠绕/震荡" + action = "半仓观望,只买确定性高的" + + return { + "index": "沪深300", + "close": close, + "pct_chg": pct, + "trend": trend, + "action": action, + "ma5": ma5, + "ma20": ma20, + "ma60": ma60 + } + except Exception as e: + return {"error": str(e)[:60]} + + +def stock_screen(symbol): + """选股框架三问 + 禁买清单""" + result = { + "symbol": symbol, + "pass": True, + "rejected_by": [], + "details": {} + } + + # === 禁买清单检查 === + + # 1. 基本面数据 + basic = _get_daily_basic(symbol) + if not basic: + result["pass"] = False + result["rejected_by"].append("数据获取失败,无法判断") + result["verdict"] = "REJECT" + result["reason"] = "无法获取基本面数据" + return result + + pe_ttm = basic.get("pe_ttm", 0) or 0 + pb = basic.get("pb", 0) or 0 + total_mv = basic.get("total_mv", 0) or 0 # 万元 + turnover = basic.get("turnover_rate", 0) or 0 + close = basic.get("close", 0) or 0 + + result["details"]["PE_TTM"] = round(pe_ttm, 1) + result["details"]["PB"] = round(pb, 2) + result["details"]["total_mv_yi"] = round(total_mv / 10000, 1) if total_mv else None + result["details"]["turnover"] = round(turnover, 2) + result["details"]["close"] = close + + # 禁买1: 市值<100亿 + if total_mv and total_mv / 10000 < 100: + result["pass"] = False + result["rejected_by"].append(f"市值{round(total_mv/10000,1)}亿<100亿") + + # 2. 技术面数据 + kline = get_kline_with_indicators(symbol, days=70) + if "latest" not in kline or "error" in kline: + result["pass"] = False + result["rejected_by"].append("技术面数据获取失败") + result["verdict"] = "REJECT" + result["reason"] = "技术面数据不足" + return result + + latest = kline["latest"] + rsi6 = latest.get("RSI", 50) + ma5 = latest.get("MA5", 0) + ma10 = latest.get("MA10", 0) + ma20 = latest.get("MA20", 0) + ma60 = latest.get("MA60", 0) + macd_bar = latest.get("MACD柱", 0) + boll_up = latest.get("BOLL上", 0) + boll_mid = latest.get("BOLL中", 0) + boll_dn = latest.get("BOLL下", 0) + + result["details"]["RSI6"] = rsi6 + result["details"]["MA_align"] = kline["summary"].get("均线排列", "") + result["details"]["MACD_signal"] = kline["summary"].get("MACD信号", "") + result["details"]["MACD_bar"] = macd_bar + + # 禁买2: RSI>70 + if rsi6 > 70: + result["pass"] = False + result["rejected_by"].append(f"RSI6={rsi6}>70超买") + + # 禁买3: 价格在布林上轨以上 + if close and boll_up and close > boll_up: + result["pass"] = False + result["rejected_by"].append(f"价格{close}>布林上轨{boll_up}") + + # 禁买4: 均线空头排列 + if ma5 and ma10 and ma20 and ma60: + if ma5 < ma20 < ma60: + result["pass"] = False + result["rejected_by"].append("均线空头排列") + + # === 三问筛选 === + q_results = {} + + # Q2: 公司能赚钱吗? + # 需要ROE — Tushare daily_basic没有ROE,用PE/PB反推 + # ROE ≈ PB / PE (近似) + roe_approx = 0 + if pb > 0 and pe_ttm > 0: + roe_approx = round(pb / pe_ttm * 100, 2) + result["details"]["ROE_approx"] = roe_approx + result["details"]["PB_ROE_ratio"] = round(pb / roe_approx, 3) if roe_approx > 0 else None + + q2_pass = True + q2_reasons = [] + + if roe_approx > 0 and roe_approx < 3: + q2_pass = False + q2_reasons.append(f"ROE约{roe_approx}%<3%(盈利能力差)") + + if pe_ttm > 30: + q2_pass = False + q2_reasons.append(f"PE {pe_ttm}>30(估值过高)") + + # PB动态门槛:PB/ROE ≤ 0.15(即投资回报率≥6.7%) + pb_roe_ratio = round(pb / roe_approx, 3) if roe_approx > 0 else 99 + if pb_roe_ratio > 0.15: + q2_pass = False + q2_reasons.append(f"PB/ROE={pb_roe_ratio}>0.15(投资回报率偏低)") + + q_results["Q2_公司能赚钱"] = { + "pass": q2_pass, + "reasons": q2_reasons if q2_reasons else ["ROE/PE/PB在合理范围"] + } + + # Q3: 价格合理吗? + q3_pass = True + q3_reasons = [] + + if pe_ttm > 25: + q3_pass = False + q3_reasons.append(f"PE {pe_ttm}>25") + + if rsi6 > 65: + q3_pass = False + q3_reasons.append(f"RSI {rsi6}>65偏强") + + if macd_bar < 0: + q3_pass = False + q3_reasons.append("MACD死叉") + + q_results["Q3_价格合理"] = { + "pass": q3_pass, + "reasons": q3_reasons if q3_reasons else ["PE/RSI/MACD在合理范围"] + } + + # Q1: 行业在变好吗?(简化版 — 用均线趋势判断) + q1_pass = True + q1_reasons = [] + + if ma5 and ma20 and ma60: + if not (ma5 > ma20): + q1_pass = False + q1_reasons.append("MA5= 2: + tc = recent[-1].get("收盘", 0) + yc = recent[-2].get("收盘", 0) + if yc > 0: + quote["涨跌幅"] = round((tc - yc) / yc * 100, 2) + + info = get_stock_info(symbol) + flow = get_capital_flow(symbol) + + # 附加daily_basic到技术面(PE/PB/换手率/量比) + basic = _get_daily_basic(symbol) + if basic and "latest" in kline: + kline["latest"]["PE_TTM"] = round(basic.get('pe_ttm', 0) or 0, 1) + kline["latest"]["PB"] = round(basic.get('pb', 0) or 0, 2) + kline["latest"]["换手率"] = round(basic.get('turnover_rate', 0) or 0, 2) + kline["latest"]["量比"] = round(basic.get('volume_ratio', 0) or 0, 2) + + out = {"status": "success", "result": {"行情": quote, "技术面": kline, "基本面": info, "资金面": flow}} + + # ===== 新增命令 ===== + + elif action == "scan_anomalies": + out = {"status": "success", "result": scan_anomalies_enhanced(symbols if symbols else None)} + + elif action == "daily_report": + out = {"status": "success", "result": daily_report()} + + elif action == "watchlist_add": + name = params.get("name", "") + if not symbol: + out = {"status": "error", "message": "缺少symbol参数"} + else: + out = {"status": "success", "result": watchlist_add(symbol, name)} + + elif action == "watchlist_remove": + if not symbol: + out = {"status": "error", "message": "缺少symbol参数"} + else: + out = {"status": "success", "result": watchlist_remove(symbol)} + + elif action == "watchlist_show": + out = {"status": "success", "result": watchlist_show()} + + elif action == "position_add": + p_name = params.get("name", "") + p_cost = params.get("cost", 0) + p_shares = params.get("shares", 0) + p_stop = params.get("stop_loss", 0) + p_target = params.get("target", 0) + p_reason = params.get("reason", "") + if not symbol or not p_cost or not p_shares: + out = {"status": "error", "message": "缺少必要参数: symbol/cost/shares"} + else: + out = {"status": "success", "result": position_add(symbol, p_name, p_cost, p_shares, p_stop, p_target, p_reason)} + + elif action == "position_close": + p_price = params.get("sell_price", 0) + p_shares = params.get("shares") + p_reason = params.get("reason", "") + p_commission = params.get("commission", 5) + if not symbol or not p_price: + out = {"status": "error", "message": "缺少必要参数: symbol/sell_price"} + else: + out = {"status": "success", "result": position_close(symbol, p_price, p_shares, p_reason, p_commission)} + + elif action == "position_update": + p_stop = params.get("stop_loss") + p_target = params.get("target") + if not symbol: + out = {"status": "error", "message": "缺少symbol参数"} + else: + out = {"status": "success", "result": position_update(symbol, p_stop, p_target)} + + elif action == "position_remove": + if not symbol: + out = {"status": "error", "message": "缺少symbol参数"} + else: + out = {"status": "success", "result": position_remove(symbol)} + + elif action == "position_show": + out = {"status": "success", "result": position_show()} + + elif action == "trade_history": + out = {"status": "success", "result": trade_history(symbol if symbol else None)} + + elif action == "trade_stats": + out = {"status": "success", "result": trade_stats()} + + elif action == "portfolio_summary": + out = {"status": "success", "result": portfolio_summary()} + + elif action == "account_set": + p_capital = params.get("total_capital", 0) + p_cash = params.get("available_cash") + if not p_capital: + out = {"status": "error", "message": "缺少参数: total_capital"} + else: + out = {"status": "success", "result": account_set(p_capital, p_cash)} + + elif action == "account_show": + out = {"status": "success", "result": read_account()} + + elif action == "stock_screen": + out = {"status": "success", "result": stock_screen(symbol)} + + elif action == "market_check": + out = {"status": "success", "result": market_check()} + + elif action == "lhb_detail": + p_days = params.get("days", 5) + out = {"status": "success", "result": _ak_lhb_detail(symbol, p_days)} + + elif action == "block_trade": + p_days = params.get("days", 5) + out = {"status": "success", "result": _ak_block_trade(symbol, p_days)} + + elif action == "share_unlock": + out = {"status": "success", "result": _ak_share_unlock()} + + elif action == "earnings_forecast": + p_date = params.get("date", "") + forecast_data, report_date = _ak_earnings_forecast(p_date if p_date else None) + out = {"status": "success", "result": {"report_date": report_date, "count": len(forecast_data), "records": forecast_data}} + + elif action == "scan_events": + p_days = params.get("days", 5) + out = {"status": "success", "result": scan_events(symbols if symbols else None, p_days)} + + elif action == "sentiment_scan": + out = {"status": "success", "result": sentiment_scan(symbol if symbol else None, symbols if symbols else None)} + + elif action == "sentiment_rank": + out = {"status": "success", "result": {"count": 0, "records": _ak_sentiment_market_rank()}} + + elif action == "stress_test": + p_scenario = params.get("scenario", "crash_2015") + p_custom = params.get("custom_drop") + out = {"status": "success", "result": stress_test(p_scenario, None, p_custom)} + + elif action == "sector_rotation": + out = {"status": "success", "result": sector_rotation()} + + elif action == "market_temperature": + out = {"status": "success", "result": market_temperature()} + + elif action == "trade_stats_monthly": + out = {"status": "success", "result": trade_stats_monthly()} + + elif action == "update_trailing_stops": + out = {"status": "success", "result": update_trailing_stops()} + + elif action == "backtest": + p_start = params.get("start_date", "20240101") + p_end = params.get("end_date", "20241231") + p_strategy = params.get("strategy", "ma_cross") + p_capital = params.get("initial_capital", 100000) + out = {"status": "success", "result": backtest(symbol, p_start, p_end, p_strategy, p_capital)} + + else: + out = {"status": "error", "message": f"未知操作: {action}"} + + print(json.dumps(out, ensure_ascii=False, default=str)) + + except json.JSONDecodeError as e: + print(json.dumps({"status": "error", "message": f"JSON错误: {e}"}, ensure_ascii=False)) + except Exception as e: + print(json.dumps({"status": "error", "message": f"异常: {str(e)[:150]}"}, ensure_ascii=False)) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/Plugin/GuanLan/plugin-manifest.json b/Plugin/GuanLan/plugin-manifest.json new file mode 100644 index 0000000..fb35e3e --- /dev/null +++ b/Plugin/GuanLan/plugin-manifest.json @@ -0,0 +1,35 @@ +{ + "name": "GuanLan", + "displayName": "观澜-A股数据引擎", + "version": "3.2.2", + "description": "A股智能数据引擎,38+专业命令。支持真实A股费率引擎(佣金/印花税/过户费自动计算)、双数据源容灾(Tushare+AKShare)、filelock跨进程并发安全、ATR动态止损、ETF资金流向适配。新增 position_update 风控参数秒级更新。作者:观澜 & 翔", + "author": "观澜 & 翔", + "pluginType": "synchronous", + "entryPoint": { + "command": "python main.py" + }, + "communication": { + "protocol": "stdio", + "timeout": 120000 + }, + "configSchema": [ + { + "name": "TUSHARE_TOKEN", + "description": "Tushare API Token,用于基本面/资金流向/板块数据。注册地址:https://tushare.pro", + "required": false, + "type": "string" + }, + { + "name": "BROKER_COMMISSION_STOCK", + "description": "股票交易佣金费率(默认万2.5即0.00025,请修改为您券商的真实费率)", + "required": false, + "type": "string" + }, + { + "name": "BROKER_COMMISSION_ETF", + "description": "ETF交易佣金费率(默认万2.5即0.00025,请修改为您券商的真实费率)", + "required": false, + "type": "string" + } + ] +} \ No newline at end of file diff --git a/Plugin/GuanLan/positions.json b/Plugin/GuanLan/positions.json new file mode 100644 index 0000000..2edf131 --- /dev/null +++ b/Plugin/GuanLan/positions.json @@ -0,0 +1,4 @@ +{ + "updated": "", + "positions": [] +} \ No newline at end of file diff --git a/Plugin/GuanLan/requirements.txt b/Plugin/GuanLan/requirements.txt new file mode 100644 index 0000000..f484d6d --- /dev/null +++ b/Plugin/GuanLan/requirements.txt @@ -0,0 +1,5 @@ +akshare>=1.18.0 +pandas>=2.0.0 +numpy>=1.24.0 +filelock>=3.12.0 +pandas-ta>=0.3.14b \ No newline at end of file diff --git a/Plugin/GuanLan/trades.json b/Plugin/GuanLan/trades.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/Plugin/GuanLan/trades.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/Plugin/GuanLan/watchlist.json b/Plugin/GuanLan/watchlist.json new file mode 100644 index 0000000..aa0c00c --- /dev/null +++ b/Plugin/GuanLan/watchlist.json @@ -0,0 +1,4 @@ +{ + "updated": "", + "stocks": [] +} \ No newline at end of file