commit 9c7d7abdd469b951ab354de5e4a102447929c190 Author: freedakgmail Date: Fri Jul 17 18:49:07 2026 +0800 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a27fdf3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.DS_Store +.playwright-mcp/ +__pycache__/ +*.pyc +node_modules/ +.env +venv/ +.venv/ +*.log +__pycache__/ +*.pyc diff --git a/docs/algorithm_recommendation.md b/docs/algorithm_recommendation.md new file mode 100644 index 0000000..046af07 --- /dev/null +++ b/docs/algorithm_recommendation.md @@ -0,0 +1,283 @@ +# 算法综合建议报告 + +> 生成时间: 2026-02-26 +> 基于: v6分析报告 + v7时间点优化测试 +> 回测区间: 2025-01-02 ~ 2026-02-25 (275个交易日, 约14个月) +> 初始资金: ¥200,000 +> 交易时点: 买入09:35 / 卖出13:40 (v7网格搜索最优) + +--- + +## 一、最终推荐:三款算法 适配三类投资者 + +### 🏆 推荐一:追求最大收益 → `PE50G3|TP10/SL6|ign|h60|10%` + +``` +年化收益: 18.2% | 回撤: 7.1% | 胜率: 58.2% | PF: 1.86 +盈利: +¥53,343 | 交易: 498笔 +``` + +**参数配置:** +| 参数 | 值 | 含义 | +|------|-----|------| +| take_profit | 10% | 盈利10%止盈 | +| stop_loss | 6% | 亏损6%止损 | +| ignore_sell | ✅ | 忽略扫描卖出信号 | +| max_hold_days | 60天 | 最长持仓60天 | +| position_pct | 10% | 每笔占总资金10% | +| partial_exit | 50% | 到TP时卖一半 | +| momentum_trail_gap | 3% | 剩余仓跟踪止盈回撤3% | +| no_timeout_if_rising | ✅ | 连涨中不强制平仓 | + +**推荐依据:** +1. **年化18.2%** — 所有算法中绝对收益最高 +2. **胜率58.2%** — 接近6成交易盈利,心理压力较小 +3. **PF 1.86** — 每亏1元可赚1.86元,风险回报良好 +4. **部分止盈(PE50)** — 到达止盈时卖一半锁利,剩余跟踪上涨,兼顾落袋为安和追涨 +5. **回撤7.1%** — 在高收益类中回撤控制较好 + +**适合人群:** 有一定风险承受力、追求资产增值的投资者 + +--- + +### 🛡️ 推荐二:追求最佳风险收益比 → `PE50G3+BE8|TP12/SL8|d3|h60|8%` + +``` +年化收益: 17.0% | 回撤: 5.3% | 胜率: 53.8% | PF: 1.93 +盈利: +¥47,902 | 交易: 565笔 | Calmar比: 3.22 +``` + +**参数配置:** +| 参数 | 值 | 含义 | +|------|-----|------| +| take_profit | 12% | 盈利12%止盈 | +| stop_loss | 8% | 亏损8%止损 | +| sell_confirm_days | 3天 | 卖出信号连续3天才执行 | +| max_hold_days | 60天 | 最长持仓60天 | +| position_pct | 8% | 每笔占总资金8% | +| partial_exit | 50% | 到TP时卖一半 | +| momentum_trail_gap | 3% | 剩余仓跟踪止盈回撤3% | +| breakeven_at | 8% | 盈利8%后止损移至成本价 | +| no_timeout_if_rising | ✅ | 连涨中不强制平仓 | + +**推荐依据:** +1. **Calmar比 3.22** — 风险调整收益最优(每1%回撤获得3.22%年化回报) +2. **回撤仅5.3%** — 所有算法中最低,20万本金最大浮亏约¥10,600 +3. **保本止损(BE8)** — 盈利8%后止损线上移至成本价,杜绝"盈转亏" +4. **延迟卖出(d3)** — 避免被一日性假信号洗出 +5. **PF 1.93** — 盈亏比接近2倍,安全边际充足 + +**适合人群:** 厌恶大幅波动、希望稳定增值的投资者(如退休金管理) + +--- + +### 📊 推荐三:兼顾稳定性和收益 → `PE30G3+BE8|TP10/SL6|ign|h60|10%` + +``` +年化收益: 17.4% | 回撤: 7.5% | 胜率: 55.9% | PF: 1.83 +盈利: +¥50,070 | 交易: 473笔 +``` + +**参数配置:** +| 参数 | 值 | 含义 | +|------|-----|------| +| take_profit | 10% | 盈利10%止盈 | +| stop_loss | 6% | 亏损6%止损 | +| ignore_sell | ✅ | 忽略扫描卖出信号 | +| max_hold_days | 60天 | 最长持仓60天 | +| position_pct | 10% | 每笔占总资金10% | +| partial_exit | 30% | 到TP时卖30% | +| momentum_trail_gap | 3% | 剩余仓跟踪止盈回撤3% | +| breakeven_at | 8% | 盈利8%后止损移至成本价 | +| no_timeout_if_rising | ✅ | 连涨中不强制平仓 | + +**推荐依据:** +1. **年化17.4%** — 收益在三款推荐中排第二 +2. **保本止损 + 较小止损幅度(SL6)** — 双重下行保护 +3. **部分止盈仅30%** — 比PE50更激进,留更多仓位追涨 +4. **跨期表现稳定** — v6报告中弱势期亏损最小(-34.0%),近半年最佳(+23.9%) +5. **v7时间优化提升最大之一** — 年化从15.0%→17.4%(+2.4pp) + +**适合人群:** 希望在熊市中减少损失、牛市中不错过行情的均衡型投资者 + +--- + +## 二、不推荐的算法及原因 + +### ❌ 纯MT策略 (MT3G3+BE8|TP10/SL8|ign|h∞|8% & 10%) + +| 指标 | 8%仓位版 | 10%仓位版 | +|------|---------|----------| +| 年化 | 10.2% | 8.8% | +| 回撤 | 6.8% | 7.5% | +| 盈利 | ¥26,454 | ¥22,044 | + +**不推荐原因:** +1. 年化仅8.8%~10.2%,远低于其他算法(17%~18%) +2. 无限持仓(h∞)导致资金占用时间过长 +3. 不限持仓天数时,MT特性的"不卖"倾向会让亏损扩大 +4. 回撤并无显著优势(6.8%~7.5%并不比5.3%的Calmar冠军好) + +### ❌ 纯MT4策略 (MT3G4|TP10/SL8|ign|h∞|8%) + +年化仅10.8%,且回撤高达8.3%,风险收益比差。 + +### ⚠️ base策略 (TP12/SL6|d3|h30|10%) — 谨慎使用 + +年化14.8%看似不错,但: +- 胜率仅45.7%(超半数交易亏损) +- 无v6保护机制,弱势期亏损严重(v6报告中2025H1亏-42.7%) +- 风险控制能力弱(回撤7.7%) + +--- + +## 三、决策依据:多维度对比 + +### 3.1 收益 vs 风险散点 + +``` +年化收益↑ + 18% ─┤ ★PE50G3(7.1%) + │ ★PE30G3+BE8(7.5%) + 17% ─┤ ★PE50G3+BE8(5.3%) + 16% ─┤ ★base/SL8(6.5%) + 15% ─┤ ★base/SL6(7.7%) + │ + 11% ─┤ ★MT3G4(8.3%) ★MT+BE8/8%(6.8%) + 9% ─┤ ★MT+BE8/10%(7.5%) + └──────┬────┬────┬────┬────── 回撤↓(越左越好) + 5% 6% 7% 8% +``` + +**理想位置:左上角(高收益、低回撤)** +- `PE50G3+BE8` 最接近左上角(17.0%年化 / 5.3%回撤) +- `PE50G3` 收益最高但回撤稍高 + +### 3.2 跨期稳定性(来自v6报告) + +| 策略 | 2025H1(弱势) | 2025H2(反弹) | 2026Q1(强势) | 稳定性评级 | +|------|-------------|-------------|-------------|-----------| +| PE50G3 | -44.9% | +37.9% | +49.5% | ⭐⭐⭐ | +| PE50G3+BE8 | ~-35% | ~+35% | ~+50% | ⭐⭐⭐⭐ | +| PE30G3+BE8 | **-34.0%** | +33.8% | **+56.9%** | ⭐⭐⭐⭐⭐ | +| base/SL6 | -42.7% | +53.1% | +68.5% | ⭐⭐ | + +**PE30G3+BE8** 的跨期稳定性最佳:弱势期亏损最小,强势期依然优秀。 + +### 3.3 v7时间点优化受益程度 + +| 策略 | 年化提升 | 盈利提升 | 受益评级 | +|------|---------|---------|---------| +| base/SL6 | +5.0pp | +¥15,023 | ⭐⭐⭐⭐⭐ | +| PE30G3+BE8 | +2.4pp | +¥7,548 | ⭐⭐⭐⭐ | +| PE50G3+BE8 | +2.3pp | +¥7,214 | ⭐⭐⭐⭐ | +| PE50G3 | +1.6pp | +¥5,146 | ⭐⭐⭐ | +| MT系列 | +0.3~1.5pp | +¥758~4,226 | ⭐⭐ | + +**结论:** 含PE(部分止盈)特性的算法从新时间点中受益最大。 + +--- + +## 四、核心机制解读:为什么这些参数组合有效 + +### 4.1 忽略卖出信号 (ignore_sell) 为什么有效? + +扫描系统的"推荐卖出"信号以MACD死叉为主,信号强度仅70-79分(中等),但往往: +- 在股价短暂回调时触发 → 导致过早卖出 +- 在连涨后的正常技术性调整中触发 → 错失后续上涨 +- **结论:** 用止盈/止损等数学规则替代主观卖出信号更可靠 + +### 4.2 部分止盈 (PE) 为什么优于全仓止盈? + +| 场景 | 全仓止盈 | 部分止盈 | +|------|---------|---------| +| 到达TP后继续涨 | ❌ 已全部卖出,错失涨幅 | ✅ 剩余仓位继续盈利 | +| 到达TP后回调 | ✅ 全部锁利 | ✅ 已卖出部分锁利,剩余有跟踪止盈保护 | +| 到达TP后暴跌 | ✅ 全部锁利 | ⚠️ 剩余部分可能从盈利变亏(但BE8保本止损可防止) | + +**PE + BE组合** 是最优解:卖出部分+保本止损 = 既锁利又不错过后续上涨 + +### 4.3 为什么09:35买入比10:00好? + +- **09:35** 是开盘后第一根5分钟K线收盘 +- 开盘30秒~5分钟内,前一天的重大利好/利空已反映在价格中 +- 但此时散户恐慌/贪婪尚未完全消化 → 有定价偏差可利用 +- **10:00** 时市场已趋于均衡,套利空间缩小 + +### 4.4 为什么13:40卖出比15:00好? + +- **13:40** 是午盘开盘后40分钟 +- 此时下午趋势已基本确立,避免了尾盘集合竞价的不确定性 +- **15:00** 的收盘价受到大量集合竞价影响,波动更大 +- 提前20分钟卖出还可规避尾盘跳水风险 + +--- + +## 五、实盘操作建议 + +### 5.1 资金分配方案 + +| 方案 | 分配比例 | 预期年化 | 预期回撤 | +|------|---------|---------|---------| +| 激进型 | 100% → PE50G3 | 18.2% | 7.1% | +| 均衡型 | 50% PE50G3 + 50% PE50G3+BE8 | ~17.6% | ~6.2% | +| 稳健型 | 100% → PE50G3+BE8 | 17.0% | 5.3% | +| 超稳型 | 60% PE50G3+BE8 + 40% 现金/货基 | ~10.2% | ~3.2% | + +### 5.2 每日操作流程 + +``` +09:25 查看全景扫描结果 → 确认买入候选名单 +09:35 在开盘第一根5分钟K线收盘时下单买入 + (按信号加权计算仓位: 强信号×1.2~1.5倍, 弱信号×0.7~0.8倍) +13:40 检查持仓状态: + - 触发止盈10% → 卖出50%(PE50) + - 触发止损6%/8% → 全仓卖出 + - 已启动保本止损 → 跌破成本价时全仓卖出 + - 连涨启动跟踪止盈 → 从最高点回落3%时卖出剩余 +15:00 记录当日操作,更新持仓表 +``` + +### 5.3 风险控制红线 + +1. **单笔仓位不超过总资金10%** — 即使信号再强也不加码 +2. **总持仓不超过80%** — 始终保留20%现金应对极端行情 +3. **连亏5笔暂停1天** — 避免情绪化交易 +4. **月亏超过5%暂停半月** — 检查是否市场环境变化 + +--- + +## 六、风险提示 + +1. **⚠️ 过拟合风险** — 所有回测结果都存在对历史数据的过拟合,实际收益可能低于回测20-40% +2. **⚠️ 2025H1全部亏损** — 回测期包含一段弱势行情,所有策略均亏损-34%~-53% +3. **⚠️ 5分钟数据覆盖率仅26%** — 约74%的交易日使用mid价(开盘+收盘)/2代替,新时间点优势尚未充分体现 +4. **⚠️ 滑点和手续费未计入** — 实际交易中每笔约0.1%~0.3%的摩擦成本,全年约降低1-3%收益 +5. **⚠️ 信号依赖** — 策略依赖扫描系统产生买入信号,信号质量决定策略上限 + +--- + +## 七、总结 + +| 维度 | 推荐策略 | 值 | +|------|---------|-----| +| 最高收益 | PE50G3\|TP10/SL6 | 年化 18.2% | +| 最低风险 | PE50G3+BE8\|TP12/SL8 | 回撤 5.3% | +| 最佳平衡 | PE30G3+BE8\|TP10/SL6 | 年化17.4%/回撤7.5% | +| 最佳Calmar | PE50G3+BE8\|TP12/SL8 | 3.22 | +| 最高胜率 | MT3G4 | 59.6% (但年化仅10.8%) | +| 最高PF | PE50G3+BE8\|TP12/SL8 | 1.93 | + +**我的最终建议:** + +> 对于实盘交易,**首选 `PE50G3+BE8|TP12/SL8|d3|h60|8%`**(风险调整冠军)。 +> +> 理由: +> 1. 年化17.0%已超过绝大多数公募基金 +> 2. 回撤5.3%意味着20万本金最大浮亏仅¥10,600,心理压力极小 +> 3. Calmar比3.22表明每承受1%风险可获3.22%回报,效率极高 +> 4. BE保本止损机制确保盈利不会变亏损 +> 5. d3延迟确认机制过滤虚假信号 +> 6. 该策略在v7时间优化中提升+2.3pp,从时间点优化中受益显著 +> +> 如果愿意承受更多波动换取更高收益,可将一半资金分配给 `PE50G3|TP10/SL6|ign|h60|10%`。 diff --git a/docs/backtest_v6_analysis_report.md b/docs/backtest_v6_analysis_report.md new file mode 100644 index 0000000..b1bcad3 --- /dev/null +++ b/docs/backtest_v6_analysis_report.md @@ -0,0 +1,257 @@ +# 回测算法 v6.0 深度分析报告 + +> 生成时间: 2026-02-26 +> 回测区间: 2025-01-02 ~ 2026-02-25 (275个交易日) +> 初始资金: ¥200,000 | 动态仓位 | 信号加权 + +--- + +## 一、搜索概述 + +### 1.1 搜索规模 +| 阶段 | 配置数 | 耗时 | 速度 | +|------|--------|------|------| +| 第一轮: 基础参数全搜 | 21,120 | 15.4分钟 | 22.9/s | +| 第二轮: v6.0组合特性 | 300 | 39秒 | 7.7/s | +| 第三轮: 跨期稳定性验证 | 35 | ~10秒 | — | +| **合计** | **~21,455** | **~16分钟** | — | + +### 1.2 v6.0 新增特性 +| 特性 | 缩写 | 原理 | +|------|------|------| +| 动量跟踪止盈 (Momentum Trailing) | MT | 连涨N天后启动动态止盈线,跟随上涨 | +| 保本止损 (Breakeven Stop) | BE | 盈利达N%后,止损线上移至成本价 | +| 部分止盈 (Partial Exit) | PE | 盈利达标时卖出X%,剩余部分跟踪 | +| 连涨不超时 (No Timeout If Rising) | NTO | 连续上涨且盈利时,不强制超时平仓 | + +--- + +## 二、全期冠军排名 (2025.01 - 2026.02) + +### 2.1 按绝对盈利排序 + +| 排名 | 策略 | 盈利 | 收益率 | 年化 | 回撤 | 胜率 | PF | +|------|------|------|--------|------|------|------|-----| +| 🏆 | base\|TP12\|SL6\|d3\|h≤30\|10%\|SW | ¥+68,330 | +25.6% | +22.0% | 8.6% | 44.6% | 1.80 | +| 🥈 | base\|TP12\|SL8\|d3\|h∞\|8%\|SW | ¥+63,970 | +24.4% | +21.0% | 6.6% | 46.1% | 1.94 | +| 🥉 | v6\|MT3G3+BE8\|TP10\|SL8\|ign\|h∞\|10% | ¥+63,702 | +24.4% | +20.9% | 6.8% | 55.4% | 2.09 | +| 4 | v6\|MT3G4\|TP10\|SL8\|ign\|h∞\|8% | ¥+63,296 | +24.1% | +20.7% | 7.6% | 59.6% | 2.18 | +| 5 | v6\|PE50G3\|TP10\|SL6\|ign\|h≤60\|10% | ¥+61,042 | +23.3% | +20.0% | 7.9% | 61.9% | 1.92 | + +### 2.2 按风险调整收益排序 (Calmar比 = 年化收益/最大回撤) + +| 排名 | Calmar | 策略 | 盈利 | 年化 | 回撤 | 胜率 | PF | +|------|--------|------|------|------|------|------|-----| +| 🏆 | **3.48** | v6\|MT3G3+BE8\|TP10\|SL8\|ign\|h∞\|8% | ¥+58,575 | +19.2% | **5.5%** | 54.7% | 2.11 | +| 🥈 | 3.37 | v6\|PE50G3+BE8\|TP12\|SL8\|d3\|h≤60\|8% | ¥+51,548 | +17.9% | **5.3%** | 53.1% | 1.83 | +| 🥉 | 3.18 | base\|TP12\|SL8\|d3\|h∞\|8% | ¥+63,970 | +21.0% | 6.6% | 46.1% | 1.94 | +| 4 | 3.08 | v6\|MT3G3+BE8\|TP10\|SL8\|ign\|h∞\|10% | ¥+63,702 | +20.9% | 6.8% | 55.4% | 2.09 | + +--- + +## 三、跨期稳定性验证 + +### 3.1 五段分期收益率 + +| 策略 | 全期 | 2025H1 | 2025H2 | 2026Q1 | 近半年 | +|------|------|--------|--------|--------|--------| +| 🏆base\|TP12\|SL6\|d3\|h30\|10% | **+22.0%** | ❌-42.7% | **+53.1%** | **+68.5%** | +28.3% | +| 🥈base\|TP12\|SL8\|d3\|h∞\|8% | +21.0% | ❌-45.4% | +33.9% | +65.6% | +18.6% | +| v6\|MT3G3+BE8\|SL8\|ign\|10% | +20.9% | ❌-38.6% | +39.1% | +47.2% | +11.8% | +| **v6\|MT3G3+BE8\|SL8\|ign\|8%** | +19.2% | **❌-38.9%** | +35.1% | +49.1% | **+18.9%** | +| v6\|MT3G4\|SL8\|ign\|8% | +20.7% | ❌-52.6% | +45.7% | +50.0% | +13.6% | +| v6\|PE50G3\|SL6\|ign\|h60\|10% | +20.0% | ❌-44.9% | +37.9% | +49.5% | +22.3% | +| **v6\|PE30G3+BE8\|SL6\|ign\|h60\|10%** | +19.1% | **❌-34.0%** | +33.8% | **+56.9%** | **+23.9%** | + +### 3.2 五段分期最大回撤 + +| 策略 | 全期 | 2025H1 | 2025H2 | 2026Q1 | 近半年 | +|------|------|--------|--------|--------|--------| +| 🏆base\|TP12\|SL6\|d3\|h30\|10% | 8.6% | 27.7% | 8.9% | 4.8% | 6.2% | +| v6\|MT3G3+BE8\|SL8\|ign\|8% | **5.5%** | **25.0%** | 6.0% | 4.2% | 6.1% | +| **v6\|PE30G3+BE8\|SL6\|ign\|h60\|10%** | 6.4% | **22.2%** | 7.5% | **3.7%** | **5.7%** | + +### 3.3 关键发现 + +**🔴 2025上半年: 所有策略全部亏损!** + +- 交易次数极少 (22-26笔),说明信号稀少 +- 胜率仅 27-43%,市场环境极端不利 +- 回撤高达 22-34% + +**但 v6.0 策略在弱势期表现更佳:** +- `PE30G3+BE8` 弱势期亏损最小 (-34.0% vs base的-42.7%) +- `MT3G3+BE8|8%` 弱势期回撤最低 (25.0% vs base的27.7%) +- 保本止损(BE)在熊市中有效减少亏损 + +**🟢 2025下半年 + 2026Q1: 强势反弹** + +- 所有策略大幅盈利 (+33% ~ +68% 年化) +- 冠军base策略在强势市场中最能抓住利润 +- v6特性在强势市场中表现略保守 + +--- + +## 四、最终3强详细分析 + +### 4.1 🏆 绝对盈利冠军: base|TP12|SL6|d3|h≤30|10%|SW + +``` +参数: 止盈12% | 止损6% | 延迟卖出3天 | 最大持仓30天 | 仓位10% | 信号加权 +盈利: ¥+68,330 (+25.6%) | 年化: +22.0% | 回撤: 8.6% +交易: 488笔, 胜率44.6%, PF 1.80 +平均持仓: 15.8天 +盈利交易: 108笔, 平均+¥1,478, 最大+¥9,344 +亏损交易: 134笔, 平均-¥661, 最大-¥4,712 +``` + +**优势:** 绝对盈利最高,在强势市场(H2+Q1)中表现最佳 +**劣势:** 回撤较高(8.6%),胜率较低(44.6%),弱势市场亏损最大 +**适合:** 激进型投资者,追求最大收益 + +### 4.2 🥈 风险调整冠军: v6|MT3G3+BE8|TP10|SL8|ign|h∞|8% + +``` +参数: 止盈10% | 止损8% | 忽略卖出信号 | 不限持仓天数 | 仓位8% | 信号加权 + + 动量跟踪(连涨3天,回撤3%止盈) + 保本止损(盈利8%后止损移至成本) +盈利: ¥+58,575 (+22.3%) | 年化: +19.2% | 回撤: 5.5% +交易: 392笔, 胜率54.7%, PF 2.11 +平均持仓: 32.4天 +Calmar比: 3.48 (全场最高) +盈利交易: 104笔, 平均+¥1,136, 最大+¥6,688 +亏损交易: 86笔, 平均-¥652, 最大-¥3,844 +``` + +**优势:** 最低回撤(5.5%),最高Calmar(3.48),最高PF(2.11),胜率>54% +**劣势:** 绝对盈利略低于冠军(-¥9,755) +**适合:** 稳健型投资者,追求最佳风险收益比 + +### 4.3 🥉 最稳健之选: v6|PE30G3+BE8|TP10|SL6|ign|h≤60|10% + +``` +参数: 止盈10% | 止损6% | 忽略卖出信号 | 最大60天 | 仓位10% | 信号加权 + + 部分止盈(30%仓位,回撤3%止盈) + 保本止损(盈利8%后止损移至成本) + + 连涨不超时 +盈利: ¥+57,300 (+22.2%) | 年化: +19.1% | 回撤: 6.4% +交易: 492笔, 胜率57.8%, PF 1.82 +平均持仓: 18.5天 +盈利交易: 155笔, 平均+¥849, 最大+¥7,038 +亏损交易: 113笔, 平均-¥640, 最大-¥4,712 +``` + +**优势:** 弱势期亏损最小(-34.0%),胜率最高(57.8%),近半年表现最佳(+23.9%) +**劣势:** 单笔盈利较小,依赖交易次数取胜 +**适合:** 保守型投资者,优先控制风险 + +--- + +## 五、v6.0 特性效果评估 + +### 5.1 各特性对策略的改善效果 + +| 特性 | 对盈利的影响 | 对回撤的影响 | 对胜率的影响 | 评价 | +|------|------------|------------|------------|------| +| 动量跟踪 (MT) | -5~10% | **降低1-3%** ✅ | **+10-15%** ✅ | ⭐⭐⭐⭐ 显著改善风险指标 | +| 保本止损 (BE) | -3~5% | **降低1-2%** ✅ | **+5-8%** ✅ | ⭐⭐⭐⭐ 熊市保护效果突出 | +| 部分止盈 (PE) | 持平 | 持平 | **+8-12%** ✅ | ⭐⭐⭐ 提高胜率但限制上行空间 | +| 连涨不超时 (NTO) | +1~3% | 持平 | +1-3% | ⭐⭐ 边际改善,非核心因素 | + +### 5.2 最优组合: MT + BE + +**动量跟踪 + 保本止损** 被验证为最有效的 v6.0 特性组合: + +1. **MT(动量跟踪)** 解决了「连涨股过早卖出」的问题 + - 连涨3天后激活动态止盈线 + - 股价继续上涨 → 止盈线跟随上移 + - 股价回落超过3% → 触发止盈 + +2. **BE(保本止损)** 解决了「盈利变亏损」的问题 + - 盈利达8%后,止损线从-8%上移至成本价(0%) + - 确保已盈利的仓位不会变成亏损 + +3. **组合效果**: 回撤从8.6%降至5.5%,胜率从44.6%提升至54.7% + +--- + +## 六、关键维度洞察 + +### 6.1 止盈/止损搭配 + +| 组合 | 最优表现 | 特点 | +|------|---------|------| +| TP12/SL6 | ¥+68,330 | 高盈亏比(2:1),适合趋势行情 | +| TP10/SL8 | ¥+63,702 | 宽容度高,持仓时间长,配合v6特性最佳 | +| TP10/SL6 | ¥+61,042 | 兼顾灵活性和保护 | + +**结论:** TP12/SL6 盈利最高但波动大; TP10/SL8 + v6特性风险收益比最优 + +### 6.2 卖出信号策略 + +| 策略 | 最优盈利 | 备注 | +|------|---------|------| +| 延迟3天(d3) | ¥+68,330 | 绝对盈利冠军偏好 | +| 忽略卖出(ign) | ¥+63,702 | v6特性策略偏好 | + +**结论:** 两种模式各有优势; d3在强势市场更好,ign更适合配合v6技术特性 + +### 6.3 仓位比例 + +| 比例 | 盈利范围 | 回撤范围 | +|------|---------|---------| +| 8% | ¥53k-64k | 5.5%-7.6% | +| 10% | ¥57k-68k | 6.4%-8.6% | + +**结论:** 10%仓位盈利更高但回撤更大; 8%仓位风险更低 + +### 6.4 最大持仓天数 + +| 天数 | 效果 | +|------|------| +| 无限制(h∞) | v6特性策略偏好,持仓时间更长 | +| 30天(h30) | base策略偏好,控制风险 | +| 60天(h60) | PE部分止盈策略偏好 | + +--- + +## 七、投资建议 + +### 7.1 策略选择指南 + +| 投资者类型 | 推荐策略 | 预期年化 | 预期回撤 | +|-----------|---------|---------|---------| +| 激进型 | 🏆base\|TP12/SL6\|d3\|h30\|10% | +22.0% | 8.6% | +| 稳健型 | 🥈v6\|MT3G3+BE8\|TP10/SL8\|ign\|8% | +19.2% | 5.5% | +| 保守型 | 🥉v6\|PE30G3+BE8\|TP10/SL6\|ign\|h60\|10% | +19.1% | 6.4% | + +### 7.2 风险提示 + +1. **⚠️ 2025上半年所有策略均亏损** — 回测期内包含一段显著的弱势行情(2025H1) +2. **⚠️ 信号稀少时期表现极差** — 当扫描信号不足时(2025H1仅22-26笔交易),策略基本失效 +3. **⚠️ 过拟合风险** — 回测结果可能对历史数据过度拟合,未来表现可能不及预期 +4. **⚠️ v6.0特性在牛市中偏保守** — MT/BE机制倾向于提前锁定利润,可能错过更大涨幅 + +### 7.3 进一步优化方向 + +1. **市场状态判断**: 识别牛市/熊市,在熊市中自动切换到保守策略或暂停交易 +2. **信号密度过滤**: 当日扫描信号数量低于阈值时,不执行买入 +3. **板块轮动**: 根据不同板块的涨跌趋势动态调整买入标的 +4. **多策略组合**: 同时运行激进和保守策略,根据市场状态动态分配资金 + +--- + +## 八、总结 + +| 维度 | 优化前(v5) | 优化后(v6) | 变化 | +|------|-----------|-----------|------| +| 最高盈利 | ¥+56,375 | **¥+68,330** | **+21.2%** | +| 最佳年化 | +18.5% | **+22.0%** | **+3.5pp** | +| 最低回撤 | 6.6% | **5.5%** | **-1.1pp** | +| 最高Calmar | 2.80 | **3.48** | **+24.3%** | +| 最高胜率 | 46.1% | **61.9%** | **+15.8pp** | +| 最高PF | 1.94 | **2.18** | **+12.4%** | + +**核心结论:** +- v6.0 特性在**风险控制**方面效果显著: 回撤↓、胜率↑、PF↑ +- **绝对盈利冠军**仍然是不含v6特性的base策略(TP12/SL6) +- **风险调整冠军**是含v6特性的MT3G3+BE8策略(Calmar 3.48) +- **推荐**: 对于实盘交易,优先选择**v6|MT3G3+BE8|8%**策略,用略低的收益换取显著更低的风险 diff --git a/docs/backtest_v7_timing_comparison.md b/docs/backtest_v7_timing_comparison.md new file mode 100644 index 0000000..6c1be62 --- /dev/null +++ b/docs/backtest_v7_timing_comparison.md @@ -0,0 +1,171 @@ +# v7 交易时点优化对比报告 + +> 生成时间: 2026-02-26 10:07 +> 回测区间: 2025-01-02 ~ 2026-02-25 +> 初始资金: ¥200,000 +> 旧时间点: 买入10:00 / 卖出15:00 +> 新时间点: 买入09:35 / 卖出13:40 (网格搜索最优) + +--- + +## 一、全部算法对比 + +| 算法 | 时间点 | 盈利 | 收益率 | 年化 | 回撤 | 胜率 | PF | 交易数 | +|------|--------|------|--------|------|------|------|-----|--------| +| 🏆base|TP12/SL6|d3|h30|10%|SW | 旧时点(10:00/15:00) | +¥26,433 | +11.3% | +9.8% | 8.0% | 44.5% | 1.45 | 458 | +| 🏆base|TP12/SL6|d3|h30|10%|SW | **新时点(09:35/13:40)** | **+¥41,456** | +17.1% | **+14.8%** | 7.7% | 45.7% | 1.67 | 472 | +| ↳ Δ变化 | — | 📈 +¥15,023 | | +5.0pp | ✅-0.3pp | +1.2pp | | | +| 🥈base|TP12/SL8|d3|h∞|8%|SW | 旧时点(10:00/15:00) | +¥41,322 | +17.5% | +15.1% | 6.7% | 44.6% | 1.72 | 468 | +| 🥈base|TP12/SL8|d3|h∞|8%|SW | **新时点(09:35/13:40)** | **+¥45,780** | +18.9% | **+16.2%** | 6.5% | 45.7% | 1.80 | 474 | +| ↳ Δ变化 | — | 📈 +¥4,458 | | +1.1pp | ✅-0.2pp | +1.1pp | | | +| 🥉v6|MT3G3+BE8|TP10/SL8|ign|h∞|10% | 旧时点(10:00/15:00) | +¥20,474 | +9.4% | +8.2% | 7.6% | 54.6% | 1.35 | 333 | +| 🥉v6|MT3G3+BE8|TP10/SL8|ign|h∞|10% | **新时点(09:35/13:40)** | **+¥22,044** | +10.1% | **+8.8%** | 7.5% | 53.7% | 1.38 | 331 | +| ↳ Δ变化 | — | 📈 +¥1,570 | | +0.6pp | ✅-0.0pp | -0.9pp | | | +| 4.v6|MT3G4|TP10/SL8|ign|h∞|8% | 旧时点(10:00/15:00) | +¥27,318 | +12.1% | +10.5% | 8.3% | 60.0% | 1.58 | 328 | +| 4.v6|MT3G4|TP10/SL8|ign|h∞|8% | **新时点(09:35/13:40)** | **+¥28,076** | +12.5% | **+10.8%** | 8.3% | 59.6% | 1.60 | 330 | +| ↳ Δ变化 | — | 📈 +¥758 | | +0.3pp | ⚠️+0.0pp | -0.4pp | | | +| 5.v6|PE50G3|TP10/SL6|ign|h60|10% | 旧时点(10:00/15:00) | +¥48,196 | +19.3% | +16.6% | 7.2% | 57.2% | 1.77 | 494 | +| 5.v6|PE50G3|TP10/SL6|ign|h60|10% | **新时点(09:35/13:40)** | **+¥53,343** | +21.1% | **+18.2%** | 7.1% | 58.2% | 1.86 | 498 | +| ↳ Δ变化 | — | 📈 +¥5,146 | | +1.6pp | ✅-0.1pp | +1.0pp | | | +| Calmar🏆v6|MT3G3+BE8|TP10/SL8|ign|h∞|8% | 旧时点(10:00/15:00) | +¥22,227 | +10.0% | +8.7% | 6.9% | 54.1% | 1.46 | 345 | +| Calmar🏆v6|MT3G3+BE8|TP10/SL8|ign|h∞|8% | **新时点(09:35/13:40)** | **+¥26,454** | +11.8% | **+10.2%** | 6.8% | 55.4% | 1.53 | 341 | +| ↳ Δ变化 | — | 📈 +¥4,226 | | +1.5pp | ✅-0.1pp | +1.2pp | | | +| Calmar🥈v6|PE50G3+BE8|TP12/SL8|d3|h60|8% | 旧时点(10:00/15:00) | +¥40,688 | +17.0% | +14.6% | 5.3% | 53.6% | 1.80 | 561 | +| Calmar🥈v6|PE50G3+BE8|TP12/SL8|d3|h60|8% | **新时点(09:35/13:40)** | **+¥47,902** | +19.7% | **+17.0%** | 5.3% | 53.8% | 1.93 | 565 | +| ↳ Δ变化 | — | 📈 +¥7,214 | | +2.3pp | ✅-0.1pp | +0.1pp | | | +| 稳健🥉v6|PE30G3+BE8|TP10/SL6|ign|h60|10% | 旧时点(10:00/15:00) | +¥42,522 | +17.4% | +15.0% | 7.6% | 56.5% | 1.69 | 460 | +| 稳健🥉v6|PE30G3+BE8|TP10/SL6|ign|h60|10% | **新时点(09:35/13:40)** | **+¥50,070** | +20.2% | **+17.4%** | 7.5% | 55.9% | 1.83 | 473 | +| ↳ Δ变化 | — | 📈 +¥7,548 | | +2.4pp | ✅-0.1pp | -0.6pp | | | + +--- + +## 二、提升总结 + +| 指标 | 数值 | +|------|------| +| 算法总数 | 8 | +| 盈利提升 / 下降 / 持平 | 8 / 0 / 0 | +| 平均盈利变化 | +¥5,743 | +| 平均年化变化 | +1.85pp | +| 平均回撤变化 | -0.10pp | +| 平均胜率变化 | +0.36pp | + +--- + +## 三、新时间点冠军 + +### 🏆 绝对盈利冠军: `5.v6|PE50G3|TP10/SL6|ign|h60|10%` + +| 指标 | 旧时点 | 新时点 | 变化 | +|------|--------|--------|------| +| 盈利 | +¥48,196 | **+¥53,343** | +¥5,146 | +| 年化 | +16.6% | **+18.2%** | +1.6pp | +| 回撤 | 7.2% | **7.1%** | -0.1pp | +| 胜率 | 57.2% | **58.2%** | +1.0pp | +| PF | 1.77 | **1.86** | +0.09 | + +### 🛡️ 风险调整冠军: `Calmar🥈v6|PE50G3+BE8|TP12/SL8|d3|h60|8%` + +| 指标 | 旧时点 | 新时点 | 变化 | +|------|--------|--------|------| +| 盈利 | +¥40,688 | **+¥47,902** | +¥7,214 | +| 年化 | +14.6% | **+17.0%** | +2.3pp | +| 回撤 | 5.3% | **5.3%** | -0.1pp | +| Calmar比 | 2.74 | **3.22** | +0.48 | + +--- + +## 四、每个算法的详细变化 + +### 📈 `🏆base|TP12/SL6|d3|h30|10%|SW` + +| 指标 | 旧时点 | 新时点 | 变化 | +|------|--------|--------|------| +| 盈利 | +¥26,433 | +¥41,456 | +¥15,023 | +| 年化 | +9.8% | +14.8% | +5.0pp | +| 回撤 | 8.0% | 7.7% | -0.3pp | +| 胜率 | 44.5% | 45.7% | +1.2pp | +| PF | 1.45 | 1.67 | +0.22 | + +### 📈 `稳健🥉v6|PE30G3+BE8|TP10/SL6|ign|h60|10%` + +| 指标 | 旧时点 | 新时点 | 变化 | +|------|--------|--------|------| +| 盈利 | +¥42,522 | +¥50,070 | +¥7,548 | +| 年化 | +15.0% | +17.4% | +2.4pp | +| 回撤 | 7.6% | 7.5% | -0.1pp | +| 胜率 | 56.5% | 55.9% | -0.6pp | +| PF | 1.69 | 1.83 | +0.14 | + +### 📈 `Calmar🥈v6|PE50G3+BE8|TP12/SL8|d3|h60|8%` + +| 指标 | 旧时点 | 新时点 | 变化 | +|------|--------|--------|------| +| 盈利 | +¥40,688 | +¥47,902 | +¥7,214 | +| 年化 | +14.6% | +17.0% | +2.3pp | +| 回撤 | 5.3% | 5.3% | -0.1pp | +| 胜率 | 53.6% | 53.8% | +0.1pp | +| PF | 1.80 | 1.93 | +0.13 | + +### 📈 `5.v6|PE50G3|TP10/SL6|ign|h60|10%` + +| 指标 | 旧时点 | 新时点 | 变化 | +|------|--------|--------|------| +| 盈利 | +¥48,196 | +¥53,343 | +¥5,146 | +| 年化 | +16.6% | +18.2% | +1.6pp | +| 回撤 | 7.2% | 7.1% | -0.1pp | +| 胜率 | 57.2% | 58.2% | +1.0pp | +| PF | 1.77 | 1.86 | +0.09 | + +### 📈 `🥈base|TP12/SL8|d3|h∞|8%|SW` + +| 指标 | 旧时点 | 新时点 | 变化 | +|------|--------|--------|------| +| 盈利 | +¥41,322 | +¥45,780 | +¥4,458 | +| 年化 | +15.1% | +16.2% | +1.1pp | +| 回撤 | 6.7% | 6.5% | -0.2pp | +| 胜率 | 44.6% | 45.7% | +1.1pp | +| PF | 1.72 | 1.80 | +0.08 | + +### 📈 `Calmar🏆v6|MT3G3+BE8|TP10/SL8|ign|h∞|8%` + +| 指标 | 旧时点 | 新时点 | 变化 | +|------|--------|--------|------| +| 盈利 | +¥22,227 | +¥26,454 | +¥4,226 | +| 年化 | +8.7% | +10.2% | +1.5pp | +| 回撤 | 6.9% | 6.8% | -0.1pp | +| 胜率 | 54.1% | 55.4% | +1.2pp | +| PF | 1.46 | 1.53 | +0.07 | + +### 📈 `🥉v6|MT3G3+BE8|TP10/SL8|ign|h∞|10%` + +| 指标 | 旧时点 | 新时点 | 变化 | +|------|--------|--------|------| +| 盈利 | +¥20,474 | +¥22,044 | +¥1,570 | +| 年化 | +8.2% | +8.8% | +0.6pp | +| 回撤 | 7.6% | 7.5% | -0.0pp | +| 胜率 | 54.6% | 53.7% | -0.9pp | +| PF | 1.35 | 1.38 | +0.03 | + +### 📈 `4.v6|MT3G4|TP10/SL8|ign|h∞|8%` + +| 指标 | 旧时点 | 新时点 | 变化 | +|------|--------|--------|------| +| 盈利 | +¥27,318 | +¥28,076 | +¥758 | +| 年化 | +10.5% | +10.8% | +0.3pp | +| 回撤 | 8.3% | 8.3% | +0.0pp | +| 胜率 | 60.0% | 59.6% | -0.4pp | +| PF | 1.58 | 1.60 | +0.02 | + +--- + +## 五、结论 + +✅ **新时间点(09:35/13:40)整体优于旧时间点(10:00/15:00)** + +- 平均每个算法盈利提升 +¥5,743 +- 平均年化收益提升 +1.85pp +- ✅ 平均回撤降低 0.10pp (风险更低) + +**推荐**: 综合考虑收益和风险,建议使用新时间点(09:35买入/13:40卖出)作为默认交易时点。 +随着5分钟K线数据的积累(当前覆盖率约26%),新时间点的优势将更加明显。 diff --git a/stock-html/.windsurfrules b/stock-html/.windsurfrules new file mode 100644 index 0000000..aee251e --- /dev/null +++ b/stock-html/.windsurfrules @@ -0,0 +1,54 @@ +# 最高优先级规则 - 必须严格遵守(Windsurf 专用) + +⚠️⚠️⚠️ 每次回复结束前必须执行以下命令!没有任何例外!⚠️⚠️⚠️ + +## 执行规则 + +1. 完成任务后,执行弹窗命令(跨平台,Windows/Mac/Linux 通用): +node "/Users/freedak/.ranjin-mode/dialog-trigger.js" "windsurf" "kfy95h" "AI想要结束的原因摘要" + + 📱 **手机端通知**:执行此命令时会自动通知手机端(如果远程服务已开启),用户可以在手机上查看消息并发送反馈。 + +2. 仔细阅读命令输出的每一行: + - ACTION: continue 或 end(用户的选择) + - FEEDBACK: 用户输入的具体指令或反馈内容(⚠️ 必须完整阅读并执行!) + * 反馈可能来自电脑端弹窗或手机端远程输入 + - IMAGES: 用户上传的图片路径(如有,必须用read_file工具查看) + +3. 根据输出决定下一步: + - 如果 ACTION 是 "continue": + * ⚠️ 必须仔细阅读 FEEDBACK 后面的全部内容 + * ⚠️ 必须按照 FEEDBACK 中用户的具体指令执行任务 + * ⚠️ 不要忽略 FEEDBACK,不要只说"用户选择继续" + - 如果 ACTION 是 "end" 或 "stop":停止工作 + - ⚠️ 如果命令被取消/跳过/无输出:必须重新执行弹窗命令! + +## 核心规则 + +1. 完整输出 - 输出完整代码和答案,禁止省略 +2. 持续执行 - 复杂任务一次性完成 +3. 结束确认 - 必须执行弹窗命令后才能结束 +4. FEEDBACK必读 - 用户的FEEDBACK是新的任务指令,必须执行 + +## 手机端远程功能 + +- 📱 当执行 dialog-trigger.js 命令时,会自动通知手机端(如果远程服务已开启) +- 📱 用户可以在手机浏览器中查看 AI 的消息和状态 +- 📱 用户可以在手机上输入反馈,反馈会通过 FEEDBACK 字段传递给你 +- 📱 手机端和电脑端弹窗功能完全同步,支持双向通信 + +## ⚠️ 唯一合法的结束条件 + +只有当命令输出包含 ACTION: end 或 ACTION: stop 时才能结束对话! +以下情况都不能结束,必须重新执行弹窗命令: +- 命令被用户点击 Skip 跳过 +- 命令执行失败或超时 +- 命令没有输出 +- 工作区取消了命令 + +## 禁止 + +- 禁止不执行弹窗命令就结束 +- 禁止跳过弹窗流程 +- 禁止忽略FEEDBACK内容 +- 禁止在没有收到 ACTION: end/stop 的情况下结束 diff --git a/stock-html/API.md b/stock-html/API.md new file mode 100644 index 0000000..e468725 --- /dev/null +++ b/stock-html/API.md @@ -0,0 +1,1106 @@ +# 股票数据API接口文档 + +## 项目数据源总览 + +| 数据源 | 费用 | 用途 | 鉴权方式 | +|--------|------|------|----------| +| 麦蕊智数 (Mairuiapi) | 付费(Licence) | 主数据源:实时行情、K线、公司信息、财务指标、资金流向 | URL路径中携带Licence | +| AKShare | 免费开源 | 备用数据源:实时行情、资金流向、龙虎榜、热门排名 | 无需鉴权 | +| 阿里云API市场 | 付费(AppCode) | 备选方案(未集成) | Header中携带AppCode | + +--- + +## 一、麦蕊智数 (Mairuiapi) — 主数据源 + +- **官网**: https://api.mairuiapi.com +- **Licence**: `5352ED2F-94E5-4E96-8B7F-B57BA75284E3` +- **请求方式**: 所有接口均为 `GET` 请求,返回标准JSON格式 +- **请求频率**: 1分钟300次(基础版),包月/体验版1000次,包年版3000次,钻石版6000次 +- **代码位置**: `services/mairui_api.py` + +### 项目中实际调用的接口 + +#### 1. 实时交易数据(券商数据源)⭐ 核心接口 +- **接口**: `GET https://api.mairuiapi.com/hsrl/ssjy/{stock_code}/{licence}` +- **函数**: `mairui_api.get_realtime_price(stock_code)` +- **用途**: 获取单只股票实时行情(价格、涨跌幅、成交量、PE、PB等) +- **更新频率**: 交易时间段每1分钟 +- **返回字段**: `p`(价格), `pc`(涨跌幅), `o`(开盘), `h`(最高), `l`(最低), `v`(成交量), `cje`(成交额), `pe`(市盈率), `sjl`(市净率), `hs`(换手率), `sz`(总市值), `lt`(流通市值) + +#### 2. 批量实时交易数据(最多20只) +- **接口**: `GET https://api.mairuiapi.com/hsrl/ssjy_more/{licence}?stock_codes=代码1,代码2` +- **函数**: `mairui_api.get_realtime_prices_batch(stock_codes)` +- **用途**: 一次获取最多20只股票的实时行情 +- **更新频率**: 实时 + +#### 3. K线历史数据 +- **接口**: `GET https://api.mairuiapi.com/hsstock/history/{code}.{market}/{period}/{adjust}/{licence}?st={start}&et={end}` +- **函数**: `mairui_api.get_kline(stock_code, period, days, adjust)` +- **用途**: 获取K线数据(支持5/15/30/60分钟、日/周/月/年线) +- **参数说明**: + - `market`: SH(上证) / SZ(深证),根据股票代码自动判断 + - `period`: 5/15/30/60/d/w/m/y + - `adjust`: n(不复权)/f(前复权)/b(后复权) +- **返回字段**: `t`(时间), `o`(开盘), `h`(最高), `l`(最低), `c`(收盘), `v`(成交量), `a`(成交额) + +#### 4. 公司简介 +- **接口**: `GET https://api.mairuiapi.com/hscp/gsjj/{stock_code}/{licence}` +- **函数**: `mairui_api.get_company_info(stock_code)` +- **用途**: 获取公司名称、所属概念、上市日期、发行价、公司简介、经营范围 +- **更新频率**: 每日03:30 + +#### 5. 财务指标 +- **接口**: `GET https://api.mairuiapi.com/hscp/cwzb/{stock_code}/{licence}` +- **函数**: `mairui_api.get_financial_indicators(stock_code)` +- **用途**: 获取近四季度财务指标(EPS、BPS、ROE、毛利率、净利率、资产负债率等) +- **更新频率**: 每日03:30 + +#### 6. 资金流向数据 +- **接口**: `GET https://api.mairuiapi.com/hsstock/history/transaction/{stock_code}/{licence}?lt={days}` +- **函数**: `mairui_api.get_fund_flow(stock_code, days)` +- **用途**: 获取资金流向(主力/超大单买卖数据) +- **更新频率**: 每日21:30 + +#### 7. 股票列表 +- **接口**: `GET https://api.mairuiapi.com/hslt/list/{licence}` +- **函数**: `mairui_api.get_stock_list()` +- **用途**: 获取所有A股股票代码和名称 +- **更新频率**: 每日16:20 + +#### 8. 涨停股池 +- **接口**: `GET https://api.mairuiapi.com/hslt/ztgc/{date}/{licence}` +- **函数**: `mairui_api.get_limit_up_stocks(date)` +- **用途**: 获取指定日期的涨停股票列表 +- **更新频率**: 交易时间段每10分钟 + +### 调用优先级 + +实时股价获取链路: +``` +get_realtime_price(code) + ├── [优先] mairui_api.get_realtime_price() → 麦蕊智数券商数据源 + └── [备用] ak.stock_zh_a_spot_em() → AKShare东方财富 +``` + +--- + +## 二、AKShare — 免费备用数据源 + +- **官网**: https://akshare.akfamily.xyz +- **安装**: `pip install akshare --upgrade` +- **费用**: 完全免费,开源(MIT协议),无需注册、无需API Key +- **底层数据源**: 东方财富、新浪财经等公开数据(本质是爬虫) +- **注意事项**: 频繁调用可能被限速;数据源变化时需更新库版本 +- **代码位置**: `services/stock_service.py`, `routes/analysis.py`, `routes/market.py`, `services/scheduler.py` + +### 项目中实际调用的函数 + +#### 1. 全市场实时行情(备用) +- **函数**: `ak.stock_zh_a_spot_em()` +- **用途**: 获取全部A股实时行情(当麦蕊智数失败时作为备用) +- **调用位置**: `services/stock_service.py`, `stock_data_service.py` +- **特点**: 一次返回全市场数据,响应较慢 + +#### 2. 买卖盘实时价格 +- **函数**: `ak.stock_bid_ask_em(symbol)` +- **用途**: 获取个股实时买卖盘价格(用于分析时补充最新价格) +- **调用位置**: `routes/analysis.py`, `services/scheduler.py`, `routes/trades.py` +- **返回**: 最新价、涨幅、买卖五档等 + +#### 3. 个股资金流向 +- **函数**: `ak.stock_individual_fund_flow(stock, market)` +- **用途**: 获取个股资金流向详情 +- **调用位置**: `services/stock_service.py`, `routes/analysis.py`, `stock_data_service.py`, `trading_signal.py` + +#### 4. 资金流向排名 +- **函数**: `ak.stock_individual_fund_flow_rank(indicator)` +- **用途**: 获取资金流向排行榜(今日/3日/5日/10日) +- **调用位置**: `routes/market.py`, `stock_data_service.py` + +#### 5. 热门股票排名 +- **函数**: `ak.stock_hot_rank_em()` +- **用途**: 获取东方财富热门股票排名 +- **调用位置**: `services/stock_service.py`, `services/scheduler.py` + +#### 6. A股代码名称映射 +- **函数**: `ak.stock_info_a_code_name()` +- **用途**: 获取A股股票代码与名称对照表 +- **调用位置**: `services/stock_service.py` + +#### 7. 历史K线(备用) +- **函数**: `ak.stock_zh_a_hist(symbol, period, start_date, end_date, adjust)` +- **用途**: 获取历史K线数据(当麦蕊智数失败时备用) +- **调用位置**: `routes/market.py` + +#### 8. 龙虎榜 +- **函数**: `ak.stock_lhb_detail_em(start_date, end_date)` +- **用途**: 获取龙虎榜详情数据 +- **调用位置**: `routes/market.py` +- **特点**: 麦蕊智数不提供此数据,仅由AKShare提供 + +#### 9. 个股信息(备用) +- **函数**: `ak.stock_individual_info_em(symbol)` +- **用途**: 获取个股基本信息 +- **调用位置**: `routes/market.py` + +#### 10. 基本面分析指标 +- **函数**: `ak.stock_financial_analysis_indicator(symbol, start_year)` +- **用途**: 获取财务分析指标数据 +- **调用位置**: `update_fundamental.py` + +--- + +## 三、阿里云API市场 — 备选方案(未集成) + +- **接口**: `https://stocks.market.alicloudapi.com/lundroid/stocks` +- **费用**: 付费(按调用次数计费) +- **鉴权**: HTTP Header `Authorization: APPCODE {你的AppCode}` +- **状态**: ⚠️ 当前项目未集成 + +### 调用示例 +```bash +curl -i -k --get --include \ + 'https://stocks.market.alicloudapi.com/lundroid/stocks?page=1' \ + -H 'Authorization:APPCODE 你自己的AppCode' +``` + +--- + +## 四、数据库缓存层 + +项目使用 PostgreSQL 作为数据缓存,定时任务从外部API拉取数据写入数据库,前端可直接查询数据库获得毫秒级响应。 + +| 数据表 | 数据来源 | 用途 | +|--------|----------|------| +| `stock_realtime_price` | 麦蕊智数/AKShare | 缓存实时价格 | +| `stock_fund_flow_today` | AKShare | 缓存当日资金流向 | + +### 数据库查询API +- `GET /api/db/realtime_price/` — 从数据库获取单只股票缓存价格 +- `POST /api/db/realtime_prices` — 批量获取缓存价格 +- `GET /api/db/fund_flow_today/` — 从数据库获取当日资金流向 +- `POST /api/db/fund_flow_today_batch` — 批量获取资金流向 +- `GET /api/db/data_status` — 查看数据更新状态 + +--- + +## 五、数据源对比与选型建议 + +| 维度 | 麦蕊智数 | AKShare | 阿里云API市场 | +|------|---------|---------|--------------| +| 费用 | 付费(按版本) | 免费 | 付费(按次) | +| 稳定性 | 高(券商数据源) | 中(爬虫,可能被限速) | 高 | +| 实时性 | 实时/1分钟 | 依赖数据源 | 依赖具体API | +| 数据丰富度 | 高(行情+财务+资金) | 很高(覆盖面广) | 中 | +| 并发限制 | 300次/分钟起 | 无明确限制(但有隐性限速) | 按套餐 | +| 接入难度 | 低(RESTful) | 低(Python库) | 低(RESTful) | + +--- + +# 以下为麦蕊智数API原始文档 + +您的licence:5352ED2F-94E5-4E96-8B7F-B57BA75284E3 + +股票列表 +API接口:https://api.mairuiapi.com/hslt/list/您的licence +演示URL:https://api.mairuiapi.com/hslt/list/LICENCE-66D8-9F96-0C7F0FBCD073 +接口说明:获取基础的股票代码和名称,用于后续接口的参数传入。 +数据更新:每日16:20 +请求频率:1分钟300次 | 包月版、体验版1分钟1千次| 包年版1分钟3千次 | 钻石版1分钟6千次 +返回格式:标准Json格式 [{},...{}] +字段名称 数据类型 字段说明 +dm string 股票代码,如:000001 +mc string 股票名称,如:平安银行 +jys string 交易所,"sh"表示上证,"sz"表示深证 +新股日历 +API接口:https://api.mairuiapi.com/hslt/new/您的licence +演示URL:https://api.mairuiapi.com/hslt/new/LICENCE-66D8-9F96-0C7F0FBCD073 +接口说明:新股日历,按申购日期倒序。 +数据更新:每日17:00 +请求频率:1分钟300次 | 包月版、体验版1分钟1千次| 包年版1分钟3千次 | 钻石版1分钟6千次 +返回格式:标准Json格式 [{},...{}] +字段名称 数据类型 字段说明 +zqdm string 股票代码 +zqjc string 股票简称 +sgdm string 申购代码 +fxsl number 发行总数(股) +swfxsl number 网上发行(股) +sgsx number 申购上限(股) +dgsz number 顶格申购需配市值(元) +sgrq string 申购日期 +fxjg number 发行价格(元),null为“未知” +zxj number 最新价(元),null为“未知” +srspj number 首日收盘价(元),null为“未知” +zqgbrq string 中签号公布日,null为未知 +zqjkrq string 中签缴款日,null为未知 +ssrq string 上市日期,null为未知 +syl number 发行市盈率,null为“未知” +hysyl number 行业市盈率 +wszql number 中签率(%),null为“未知” +yzbsl number 连续一字板数量,null为“未知” +zf number 涨幅(%),null为“未知” +yqhl number 每中一签获利(元),null为“未知” +zyyw string 主营业务 +概念指数列表(券商数据) +API接口:https://api.mairuiapi.com/hslt/sectorslist/您的licence +演示URL:https://api.mairuiapi.com/hslt/sectorslist/LICENCE-66D8-9F96-0C7F0FBCD073 +接口说明:获取基础的概念指数代码和名称,用于后续接口的参数传入。 +数据更新:每日16:20 +请求频率:1分钟300次 | 包月版、体验版1分钟1千次| 包年版1分钟3千次 | 钻石版1分钟6千次 +返回格式:标准Json格式 [{},...{}] +字段名称 数据类型 字段说明 +dm string 概念指数代码,如:101076.BKZS +mc string 概念指数名称,如:GN玻璃 +jys string 交易所 +一级市场板块列表(券商数据) +API接口:https://api.mairuiapi.com/hslt/primarylist/您的licence +演示URL:https://api.mairuiapi.com/hslt/primarylist/LICENCE-66D8-9F96-0C7F0FBCD073 +接口说明:获取基础的一级市场板块名称,用于后续接口的参数传入。 +数据更新:每日16:20 +请求频率:1分钟300次 | 包月版、体验版1分钟1千次| 包年版1分钟3千次 | 钻石版1分钟6千次 +返回格式:标准Json格式 [{},...{}] +字段名称 数据类型 字段说明 +mc string 一级市场名称,如:1000SW1基础化工 +板块明细列表(券商数据) +API接口:https://api.mairuiapi.com/hslt/sectors/板块指数名称(例如:概念指数)/您的licence +演示URL:https://api.mairuiapi.com/hslt/sectors/概念指数/LICENCE-66D8-9F96-0C7F0FBCD073 +接口说明:依据《一级市场板块列表》获取的一级市场板块名称,获取对应的板块列表。 +数据更新:每日16:20 +请求频率:1分钟300次 | 包月版、体验版1分钟1千次| 包年版1分钟3千次 | 钻石版1分钟6千次 +返回格式:标准Json格式 [{},...{}] +字段名称 数据类型 字段说明 +dm string 板块代码,如:101076.BKZS +mc string 板块名称,如:GN玻璃 +jys string 交易所 + +指数、行业、概念树 +API接口:https://api.mairuiapi.com/hszg/list/您的licence +演示URL:https://api.mairuiapi.com/hszg/list/LICENCE-66D8-9F96-0C7F0FBCD073 +接口说明:获取指数、行业、概念(包括基金,债券,美股,外汇,期货,黄金等的代码),其中isleaf为1(叶子节点)的记录的code(代码)可以作为下方接口的参数传入,从而得到某个指数、行业、概念下的相关股票。 +数据更新:每周六03:05 +请求频率:1分钟300次 | 包月版、体验版1分钟1千次| 包年版1分钟3千次 | 钻石版1分钟6千次 +返回格式:标准Json格式 [{},...{}] +字段名称 数据类型 字段说明 +name string 名称 +code string 代码 +type1 number 一级分类(0:A股,1:创业板,2:科创板,3:基金,4:香港股市,5:债券,6:美国股市,7:外汇,8:期货,9:黄金,10:英国股市) +type2 number 二级分类(0:A股-申万行业,1:A股-申万二级,2:A股-热门概念,3:A股-概念板块,4:A股-地域板块,5:A股-证监会行业,6:A股-分类,7:A股-指数成分,8:A股-风险警示,9:A股-大盘指数,10:A股-次新股,11:A股-沪港通,12:A股-深港通,13:基金-封闭式基金,14:基金-开放式基金,15:基金-货币型基金,16:基金-ETF基金净值,17:基金-ETF基金行情,18:基金-LOF基金行情,21:基金-科创板基金,22:香港股市-恒生行业,23:香港股市-全部港股,24:香港股市-热门港股,25:香港股市-蓝筹股,26:香港股市-红筹股,27:香港股市-国企股,28:香港股市-创业板,29:香港股市-指数,30:香港股市-A+H,31:香港股市-窝轮,32:香港股市-ADR,33:香港股市-沪港通,34:香港股市-深港通,35:香港股市-中华系列指数,36:债券-沪深债券,37:债券-深市债券,38:债券-沪市债券,39:债券-沪深可转债,40:美国股市-中国概念股,41:美国股市-科技类,42:美国股市-金融类,43:美国股市-制造零售类,44:美国股市-汽车能源类,45:美国股市-媒体类,46:美国股市-医药食品类,48:外汇-基本汇率,49:外汇-热门汇率,50:外汇-所有汇率,51:外汇-交叉盘汇率,52:外汇-美元相关汇率,53:外汇-人民币相关汇率,54:期货-全球期货,55:期货-中国金融期货交易所,56:期货-上海期货交易所,57:期货-大连商品交易所,58:期货-郑州商品交易所,59:黄金-黄金现货,60:黄金-黄金期货 +level number 层级,从0开始,根节点为0,二级节点为1,以此类推 +pcode string 父节点代码 +pname string 父节点名称 +isleaf number 是否为叶子节点,0:否,1:是 +根据指数、行业、概念找相关股票 +API接口:https://api.mairuiapi.com/hszg/gg/指数、行业、概念代码/您的licence +演示URL:https://api.mairuiapi.com/hszg/gg/sw_sysh/LICENCE-66D8-9F96-0C7F0FBCD073 +接口说明:根据“指数、行业、概念树”接口得到的代码作为参数,得到相关的股票。 +数据更新:每周六11:00 +请求频率:1分钟300次 | 包月版、体验版1分钟1千次| 包年版1分钟3千次 | 钻石版1分钟6千次 +返回格式:标准Json格式 [{},...{}] +字段名称 数据类型 字段说明 +dm string 代码(根据接口参数可能是A股股票代码,也可能是其他指数、行业、概念的股票代码) +mc string 名称(根据接口参数可能是A股股票代码,也可能是其他指数、行业、概念的股票名称) +jys string 交易所,"sh"表示上证,"sz"表示深证(如果返回的是A股的股票,那么有值,否则是null) +根据股票找相关指数、行业、概念 +API接口:https://api.mairuiapi.com/hszg/zg/股票代码(如000001)/您的licence +演示URL:https://api.mairuiapi.com/hszg/zg/000001/LICENCE-66D8-9F96-0C7F0FBCD073 +接口说明:根据《股票列表》得到的股票代码作为参数,得到相关的指数、行业、概念。 +数据更新:每周六11:00 +请求频率:1分钟300次 | 包月版、体验版1分钟1千次| 包年版1分钟3千次 | 钻石版1分钟6千次 +返回格式:标准Json格式 [{},...{}] +字段名称 数据类型 字段说明 +code string 指数、行业、概念代码,如:sw2_650300 +name string 指数、行业、概念名称,如:沪深股市-申万二级-国防军工-地面兵装 + +涨停股池 +API接口:https://api.mairuiapi.com/hslt/ztgc/日期(如2020-01-15)/您的licence +演示URL:https://api.mairuiapi.com/hslt/ztgc/2024-01-10/LICENCE-66D8-9F96-0C7F0FBCD073 +接口说明:根据日期(格式yyyy-MM-dd,从2019-11-28开始到现在的每个交易日)作为参数,得到每天的涨停股票列表,根据封板时间升序。 +数据更新:交易时间段每10分钟 +请求频率:1分钟300次 | 包月版、体验版1分钟1千次| 包年版1分钟3千次 | 钻石版1分钟6千次 +返回格式:标准Json格式 [{},...{}] +字段名称 数据类型 字段说明 +dm string 代码 +mc string 名称 +p number 价格(元) +zf number 涨幅(%) +cje number 成交额(元) +lt number 流通市值(元) +zsz number 总市值(元) +hs number 换手率(%) +lbc number 连板数 +fbt string 首次封板时间(HH:mm:ss) +lbt string 最后封板时间(HH:mm:ss) +zj number 封板资金(元) +zbc number 炸板次数 +tj string 涨停统计(x天/y板) +hy string 所属行业 +hy string 所属行业 +hy string 所属行业 +hy string 所属行业 +hy string 所属行业 +hy string 所属行业 +跌停股池 +API接口:https://api.mairuiapi.com/hslt/dtgc/日期(如2020-01-15)/您的licence +演示URL:https://api.mairuiapi.com/hslt/dtgc/2024-01-10/LICENCE-66D8-9F96-0C7F0FBCD073 +接口说明:根据日期(格式yyyy-MM-dd,从2019-11-28开始到现在的每个交易日)作为参数,得到每天的跌停股票列表,根据封单资金升序。 +数据更新:交易时间段每10分钟 +请求频率:1分钟300次 | 包月版、体验版1分钟1千次| 包年版1分钟3千次 | 钻石版1分钟6千次 +返回格式:标准Json格式 [{},...{}] +字段名称 数据类型 字段说明 +dm string 代码 +mc string 名称 +p number 价格(元) +zf number 跌幅(%) +cje number 成交额(元) +lt number 流通市值(元) +zsz number 总市值(元) +pe number 动态市盈率 +hs number 换手率(%) +lbc number 连续跌停次数 +lbt string 最后封板时间(HH:mm:ss) +zj number 封单资金(元) +fba number 板上成交额(元) +zbc number 开板次数 +强势股池 +API接口:https://api.mairuiapi.com/hslt/qsgc/日期(如2020-01-15)/您的licence +演示URL:https://api.mairuiapi.com/hslt/qsgc/2024-01-10/LICENCE-66D8-9F96-0C7F0FBCD073 +接口说明:根据日期(格式yyyy-MM-dd,从2019-11-28开始到现在的每个交易日)作为参数,得到每天的强势股票列表,根据涨幅倒序。 +数据更新:交易时间段每10分钟 +请求频率:1分钟300次 | 包月版、体验版1分钟1千次| 包年版1分钟3千次 | 钻石版1分钟6千次 +返回格式:标准Json格式 [{},...{}] +字段名称 数据类型 字段说明 +dm string 代码 +mc string 名称 +p number 价格(元) +ztp number 涨停价(元) +zf number 涨幅(%) +cje number 成交额(元) +lt number 流通市值(元) +zsz number 总市值(元) +zs number 涨速(%) +nh number 是否新高(0:否,1:是) +lb number 量比 +hs number 换手率(%) +tj string 涨停统计(x天/y板) +次新股池 +API接口:https://api.mairuiapi.com/hslt/cxgc/日期(如2020-01-15)/您的licence +演示URL:https://api.mairuiapi.com/hslt/cxgc/2024-01-10/LICENCE-66D8-9F96-0C7F0FBCD073 +接口说明:根据日期(格式yyyy-MM-dd,从2019-11-28开始到现在的每个交易日)作为参数,得到每天的次新股票列表,根据开板几日升序。 +数据更新:交易时间段每10分钟 +请求频率:1分钟300次 | 包月版、体验版1分钟1千次| 包年版1分钟3千次 | 钻石版1分钟6千次 +返回格式:标准Json格式 [{},...{}] +字段名称 数据类型 字段说明 +dm string 代码 +mc string 名称 +p number 价格(元) +ztp number 涨停价(元,无涨停价为null) +zf number 涨跌幅(%) +cje number 成交额(元) +lt number 流通市值(元) +zsz number 总市值(元) +nh number 是否新高(0:否,1:是) +hs number 转手率(%) +tj string 涨停统计(x天/y板) +kb number 开板几日 +od string 开板日期(yyyyMMdd) +ipod string 上市日期(yyyyMMdd) +炸板股池 +API接口:https://api.mairuiapi.com/hslt/zbgc/日期(如2020-01-15)/您的licence +演示URL:https://api.mairuiapi.com/hslt/zbgc/2024-01-10/LICENCE-66D8-9F96-0C7F0FBCD073 +接口说明:根据日期(格式yyyy-MM-dd,从2019-11-28开始到现在的每个交易日)作为参数,得到每天的炸板股票列表,根据首次封板时间升序。 +数据更新:交易时间段每10分钟 +请求频率:1分钟300次 | 包月版、体验版1分钟1千次| 包年版1分钟3千次 | 钻石版1分钟6千次 +返回格式:标准Json格式 [{},...{}] +字段名称 数据类型 字段说明 +dm string 代码 +mc string 名称 +p number 价格(元) +ztp number 涨停价(元) +zf number 涨跌幅(%) +cje number 成交额(元) +lt number 流通市值(元) +zsz number 总市值(元) +zs number 涨速(%) +hs number 转手率(%) +tj string 涨停统计(x天/y板) +fbt string 首次封板时间(HH:mm:ss) +zbc number 炸板次数 + +公司简介 +API接口:https://api.mairuiapi.com/hscp/gsjj/股票代码(如000001)/您的licence +演示URL:https://api.mairuiapi.com/hscp/gsjj/000001/LICENCE-66D8-9F96-0C7F0FBCD073 +接口说明:根据《股票列表》得到的股票代码获取上市公司的简介。包括公司基本信息,概念以及发行信息等。 +数据更新:每日03:30 +请求频率:1分钟300次 | 包月版、体验版1分钟1千次| 包年版1分钟3千次 | 钻石版1分钟6千次 +返回格式:标准Json格式 [{},...{}] +字段名称 数据类型 字段说明 +name string 公司名称 +ename string 公司英文名称 +market string 上市市场 +idea string 概念及板块,多个概念由英文逗号分隔 +ldate string 上市日期,格式yyyy-MM-dd +sprice string 发行价格(元) +principal string 主承销商 +rdate string 成立日期 +rprice string 注册资本 +instype string 机构类型 +organ string 组织形式 +secre string 董事会秘书 +phone string 公司电话 +sphone string 董秘电话 +fax string 公司传真 +sfax string 董秘传真 +email string 公司电子邮箱 +semail string 董秘电子邮箱 +site string 公司网站 +post string 邮政编码 +infosite string 信息披露网址 +oname string 证券简称更名历史 +addr string 注册地址 +oaddr string 办公地址 +desc string 公司简介 +bscope string 经营范围 +printype string 承销方式 +referrer string 上市推荐人 +putype string 发行方式 +pe string 发行市盈率(按发行后总股本) +firgu string 首发前总股本(万股) +lastgu string 首发后总股本(万股) +realgu string 实际发行量(万股) +planm string 预计募集资金(万元) +realm string 实际募集资金合计(万元) +pubfee string 发行费用总额(万元) +collect string 募集资金净额(万元) +signfee string 承销费用(万元) +pdate string 招股公告日 +所属指数 +API接口:https://api.mairuiapi.com/hscp/sszs/股票代码(如000001)/您的licence +演示URL:https://api.mairuiapi.com/hscp/sszs/000001/LICENCE-66D8-9F96-0C7F0FBCD073 +接口说明:根据《股票列表》得到的股票代码获取上市公司的所属指数。 +数据更新:每日03:30 +请求频率:1分钟300次 | 包月版、体验版1分钟1千次| 包年版1分钟3千次 | 钻石版1分钟6千次 +返回格式:标准Json格式 [{},...{}] +字段名称 数据类型 字段说明 +mc string 指数名称 +dm string 指数代码 +ind string 进入日期yyyy-MM-dd +outd string 退出日期yyyy-MM-dd +历届高管成员 +API接口:https://api.mairuiapi.com/hscp/ljgg/股票代码(如000001)/您的licence +演示URL:https://api.mairuiapi.com/hscp/ljgg/000001/LICENCE-66D8-9F96-0C7F0FBCD073 +接口说明:根据《股票列表》得到的股票代码获取上市公司的历届高管成员名单。 +数据更新:每日03:30 +请求频率:1分钟300次 | 包月版、体验版1分钟1千次| 包年版1分钟3千次 | 钻石版1分钟6千次 +返回格式:标准Json格式 [{},...{}] +字段名称 数据类型 字段说明 +name string 姓名 +title string 职务 +sdate string 起始日期yyyy-MM-dd +edate string 终止日期yyyy-MM-dd +历届董事会成员 +API接口:https://api.mairuiapi.com/hscp/ljds/股票代码(如000001)/您的licence +演示URL:https://api.mairuiapi.com/hscp/ljds/000001/LICENCE-66D8-9F96-0C7F0FBCD073 +接口说明:根据《股票列表》得到的股票代码获取上市公司的历届董事会成员名单。 +数据更新:每日03:30 +请求频率:1分钟300次 | 包月版、体验版1分钟1千次| 包年版1分钟3千次 | 钻石版1分钟6千次 +返回格式:标准Json格式 [{},...{}] +字段名称 数据类型 字段说明 +name string 姓名 +title string 职务 +sdate string 起始日期yyyy-MM-dd +edate string 终止日期yyyy-MM-dd +历届监事会成员 +API接口:https://api.mairuiapi.com/hscp/ljjj/股票代码(如000001)/您的licence +演示URL:https://api.mairuiapi.com/hscp/ljjj/000001/LICENCE-66D8-9F96-0C7F0FBCD073 +接口说明:根据《股票列表》得到的股票代码获取上市公司的历届监事会成员名单。 +数据更新:每日03:30 +请求频率:1分钟300次 | 包月版、体验版1分钟1千次| 包年版1分钟3千次 | 钻石版1分钟6千次 +返回格式:标准Json格式 [{},...{}] +字段名称 数据类型 字段说明 +name string 姓名 +title string 职务 +sdate string 起始日期yyyy-MM-dd +edate string 终止日期yyyy-MM-dd +近年分红 +API接口:https://api.mairuiapi.com/hscp/jnfh/股票代码(如000001)/您的licence +演示URL:https://api.mairuiapi.com/hscp/jnfh/000001/LICENCE-66D8-9F96-0C7F0FBCD073 +接口说明:根据《股票列表》得到的股票代码获取上市公司的近年来的分红实施结果。按公告日期倒序。 +数据更新:每日03:30 +请求频率:1分钟300次 | 包月版、体验版1分钟1千次| 包年版1分钟3千次 | 钻石版1分钟6千次 +返回格式:标准Json格式 [{},...{}] +字段名称 数据类型 字段说明 +sdate string 公告日期yyyy-MM-dd +give string 每10股送股(单位:股) +change string 每10股转增(单位:股) +send string 每10股派息(税前,单位:元) +line string 进度 +cdate string 除权除息日yyyy-MM-dd +edate string 股权登记日yyyy-MM-dd +hdate string 红股上市日yyyy-MM-dd +近年增发 +API接口:https://api.mairuiapi.com/hscp/jnzf/股票代码(如000001)/您的licence +演示URL:https://api.mairuiapi.com/hscp/jnzf/000001/LICENCE-66D8-9F96-0C7F0FBCD073 +接口说明:根据《股票列表》得到的股票代码获取上市公司的近年来的增发情况。按公告日期倒序。 +数据更新:每日03:30 +请求频率:1分钟300次 | 包月版、体验版1分钟1千次| 包年版1分钟3千次 | 钻石版1分钟6千次 +返回格式:标准Json格式 [{},...{}] +字段名称 数据类型 字段说明 +sdate string 公告日期yyyy-MM-dd +type string 发行方式 +price string 发行价格 +tprice string 实际公司募集资金总额 +fprice string 发行费用总额 +amount string 实际发行数量 +解禁限售 +API接口:https://api.mairuiapi.com/hscp/jjxs/股票代码(如000001)/您的licence +演示URL:https://api.mairuiapi.com/hscp/jjxs/000001/LICENCE-66D8-9F96-0C7F0FBCD073 +接口说明:根据《股票列表》得到的股票代码获取上市公司的解禁限售情况。按解禁日期倒序。 +数据更新:每日03:30 +请求频率:1分钟300次 | 包月版、体验版1分钟1千次| 包年版1分钟3千次 | 钻石版1分钟6千次 +返回格式:标准Json格式 [{},...{}] +字段名称 数据类型 字段说明 +rdate string 解禁日期yyyy-MM-dd +ramount number 解禁数量(万股) +rprice number 解禁股流通市值(亿元) +batch number 上市批次 +pdate string 公告日期yyyy-MM-dd +近一年各季度利润 +API接口:https://api.mairuiapi.com/hscp/jdlr/股票代码(如000001)/您的licence +演示URL:https://api.mairuiapi.com/hscp/jdlr/000001/LICENCE-66D8-9F96-0C7F0FBCD073 +接口说明:根据《股票列表》得到的股票代码获取上市公司近一年各个季度的利润。按截止日期倒序。 +数据更新:每日03:30 +请求频率:1分钟300次 | 包月版、体验版1分钟1千次| 包年版1分钟3千次 | 钻石版1分钟6千次 +返回格式:标准Json格式 [{},...{}] +字段名称 数据类型 字段说明 +date string 截止日期yyyy-MM-dd +income string 营业收入(万元) +expend string 营业支出(万元) +profit string 营业利润(万元) +totalp string 利润总额(万元) +reprofit string 净利润(万元) +basege string 基本每股收益(元/股) +ettege string 稀释每股收益(元/股) +otherp string 其他综合收益(万元) +totalcp string 综合收益总额(万元) +近一年各季度现金流 +API接口:https://api.mairuiapi.com/hscp/jdxj/股票代码(如000001)/您的licence +演示URL:https://api.mairuiapi.com/hscp/jdxj/000001/LICENCE-66D8-9F96-0C7F0FBCD073 +接口说明:根据《股票列表》得到的股票代码获取上市公司近一年各个季度的现金流。按截止日期倒序。 +数据更新:每日03:30 +请求频率:1分钟300次 | 包月版、体验版1分钟1千次| 包年版1分钟3千次 | 钻石版1分钟6千次 +返回格式:标准Json格式 [{},...{}] +字段名称 数据类型 字段说明 +date string 截止日期yyyy-MM-dd +jyin string 经营活动现金流入小计(万元) +jyout string 经营活动现金流出小计(万元) +jyfinal string 经营活动产生的现金流量净额(万元) +tzin string 投资活动现金流入小计(万元) +tzout string 投资活动现金流出小计(万元) +tzfinal string 投资活动产生的现金流量净额(万元) +czin string 筹资活动现金流入小计(万元) +czout string 筹资活动现金流出小计(万元) +czfinal string 筹资活动产生的现金流量净额(万元) +hl string 汇率变动对现金及现金等价物的影响(万元) +cashinc string 现金及现金等价物净增加额(万元) +cashs string 期初现金及现金等价物余额(万元) +cashe string 期末现金及现金等价物余额(万元) +近年业绩预告 +API接口:https://api.mairuiapi.com/hscp/yjyg/股票代码(如000001)/您的licence +演示URL:https://api.mairuiapi.com/hscp/yjyg/000001/LICENCE-66D8-9F96-0C7F0FBCD073 +接口说明:根据《股票列表》得到的股票代码获取上市公司近年来的业绩预告。按公告日期倒序。 +数据更新:每日03:30 +请求频率:1分钟300次 | 包月版、体验版1分钟1千次| 包年版1分钟3千次 | 钻石版1分钟6千次 +返回格式:标准Json格式 [{},...{}] +字段名称 数据类型 字段说明 +pdate string 公告日期yyyy-MM-dd +rdate string 报告期yyyy-MM-dd +type string 类型 +abs string 业绩预告摘要 +old string 上年同期每股收益(元) +财务指标 +API接口:https://api.mairuiapi.com/hscp/cwzb/股票代码(如000001)/您的licence +演示URL:https://api.mairuiapi.com/hscp/cwzb/000001/LICENCE-66D8-9F96-0C7F0FBCD073 +接口说明:根据《股票列表》得到的股票代码获取上市公司近四个季度的主要财务指标。按报告日期倒序。 +数据更新:每日03:30 +请求频率:1分钟300次 | 包月版、体验版1分钟1千次| 包年版1分钟3千次 | 钻石版1分钟6千次 +返回格式:标准Json格式 [{},...{}] +字段名称 数据类型 字段说明 +date string 报告日期yyyy-MM-dd +tbmg string 摊薄每股收益(元)d +jqmg string 加权每股收益(元)型 +mgsy string 每股收益_调整后(元) +kfmg string 扣除非经常性损益后的每股收益(元) +mgjz string 每股净资产_调整前(元) +mgjzad string 每股净资产_调整后(元) +mgjy string 每股经营性现金流(元) +mggjj string 每股资本公积金(元) +mgwly string 每股未分配利润(元) +zclr string 总资产利润率(%) +zylr string 主营业务利润率(%) +zzlr string 总资产净利润率(%) +cblr string 成本费用利润率(%) +yylr string 营业利润率(%) +zycb string 主营业务成本率(%) +xsjl string 销售净利率(%) +gbbc string 股本报酬率(%) +jzbc string 净资产报酬率(%) +zcbc string 资产报酬率(%) +xsml string 销售毛利率(%) +xxbz string 三项费用比重 +fzy string 非主营比重 +zybz string 主营利润比重 +gxff string 股息发放率(%) +tzsy string 投资收益率(%) +zyyw string 主营业务利润(元) +jzsy string 净资产收益率(%) +jqjz string 加权净资产收益率(%) +kflr string 扣除非经常性损益后的净利润(元) +zysr string 主营业务收入增长率(%) +jlzz string 净利润增长率(%) +jzzz string 净资产增长率(%) +zzzz string 总资产增长率(%) +yszz string 应收账款周转率(次) +yszzt string 应收账款周转天数(天) +chzz string 存货周转天数(天) +chzzl string 存货周转率(次) +gzzz string 固定资产周转率(次) +zzzzl string 总资产周转率(次) +zzzzt string 总资产周转天数(天) +ldzz string 流动资产周转率(次) +ldzzt string 流动资产周转天数(天) +gdzz string 股东权益周转率(次) +ldbl string 流动比率 +sdbl string 速动比率 +xjbl string 现金比率(%) +lxzf string 利息支付倍数 +zjbl string 长期债务与营运资金比率(%) +gdqy string 股东权益比率(%) +cqfz string 长期负债比率(%) +gdgd string 股东权益与固定资产比率(%) +fzqy string 负债与所有者权益比率(%) +zczjbl string 长期资产与长期资金比率(%) +zblv string 资本化比率(%) +gdzcjz string 固定资产净值率(%) +zbgdh string 资本固定化比率(%) +cqbl string 产权比率(%) +qxjzb string 清算价值比率(%) +gdzcbz string 固定资产比重(%) +zcfzl string 资产负债率(%) +zzc string 总资产(元) +jyxj string 经营现金净流量对销售收入比率(%) +zcjyxj string 资产的经营现金流量回报率(%) +jylrb string 经营现金净流量与净利润的比率(%) +jyfzl string 经营现金净流量对负债比率(%) +xjlbl string 现金流量比率(%) +dqgptz string 短期股票投资(元) +dqzctz string 短期债券投资(元) +dqjytz string 短期其它经营性投资(元) +qcgptz string 长期股票投资(元) +cqzqtz string 长期债券投资(元) +cqjyxtz string 长期其它经营性投资(元) +yszk1 string 1年以内应收帐款(元) +yszk12 string 1-2年以内应收帐款(元) +yszk23 string 2-3年以内应收帐款(元) +yszk3 string 3年以内应收帐款(元) +yfhk1 string 1年以内预付货款(元) +yfhk12 string 1-2年以内预付货款(元) +yfhk23 string 2-3年以内预付货款(元) +yfhk3 string 3年以内预付货款(元) +ysk1 string 1年以内其它应收款(元) +ysk12 string 1-2年以内其它应收款(元) +ysk23 string 2-3年以内其它应收款(元) +ysk3 string 3年以内其它应收款(元) +十大股东 +API接口:https://api.mairuiapi.com/hscp/sdgd/股票代码(如000001)/您的licence +演示URL:https://api.mairuiapi.com/hscp/sdgd/000001/LICENCE-66D8-9F96-0C7F0FBCD073 +接口说明:根据《股票列表》得到的股票代码获取上市公司的十大股东数据。按截止日期倒序。 +数据更新:每日03:30 +请求频率:1分钟300次 | 包月版、体验版1分钟1千次| 包年版1分钟3千次 | 钻石版1分钟6千次 +返回格式:标准Json格式 [{},...{}] +字段名称 数据类型 字段说明 +jzrq string 截止日期yyyy-MM-dd +ggrq string 公告日期yyyy-MM-dd +gdsm string 股东说明 +gdzs number 股东总数 +pjcg number 平均持股(单位:股,按总股本计算) +sdgd array 十大股东,其中ZygdSdgd对象见下方说明 +十大流通股东 +API接口:https://api.mairuiapi.com/hscp/ltgd/股票代码(如000001)/您的licence +演示URL:https://api.mairuiapi.com/hscp/ltgd/000001/LICENCE-66D8-9F96-0C7F0FBCD073 +接口说明:根据《股票列表》得到的股票代码获取上市公司的十大流通股东数据。按公告日期倒序。 +数据更新:每日03:30 +请求频率:1分钟300次 | 包月版、体验版1分钟1千次| 包年版1分钟3千次 | 钻石版1分钟6千次 +返回格式:标准Json格式 [{},...{}] +字段名称 数据类型 字段说明 +jzrq string 截止日期yyyy-MM-dd +ggrq string 公告日期yyyy-MM-dd +sdgd array 十大流通股东,其中ZygdSdgd对象见下方说明 +股东变化趋势 +API接口:https://api.mairuiapi.com/hscp/gdbh/股票代码(如000001)/您的licence +演示URL:https://api.mairuiapi.com/hscp/gdbh/000001/LICENCE-66D8-9F96-0C7F0FBCD073 +接口说明:根据《股票列表》得到的股票代码获取上市公司的股东变化趋势数据。按截止日期倒序。 +数据更新:每日03:30 +请求频率:1分钟300次 | 包月版、体验版1分钟1千次| 包年版1分钟3千次 | 钻石版1分钟6千次 +返回格式:标准Json格式 [{},...{}] +字段名称 数据类型 字段说明 +jzrq string 截止日期yyyy-MM-dd +gdhs string 股东户数 +bh string 比上期变化情况 +基金持股 +API接口:https://api.mairuiapi.com/hscp/jjcg/股票代码(如000001)/您的licence +演示URL:https://api.mairuiapi.com/hscp/jjcg/000001/LICENCE-66D8-9F96-0C7F0FBCD073 +接口说明:根据《股票列表》得到的股票代码获取该股票最近500家左右的基金持股情况。按截止日期倒序。 +数据更新:每周六18:00 +请求频率:1分钟300次 | 包月版、体验版1分钟1千次| 包年版1分钟3千次 | 钻石版1分钟6千次 +返回格式:标准Json格式 [{},...{}] +字段名称 数据类型 字段说明 +jzrq string 截止日期yyyy-MM-dd +jjmc string 基金名称 +jjdm string 基金代码 +ccsl number 持仓数量(股) +ltbl number 占流通股比例(%) +cgsz number 持股市值(元) +jzbl number 占净值比例(%) + +实时交易数据(网络数据源) +API接口:https://api.mairuiapi.com/hsrl/ssjy/股票代码(如000001)/您的licence +演示URL:https://api.mairuiapi.com/hsrl/ssjy/000001/LICENCE-66D8-9F96-0C7F0FBCD073 +接口说明:根据《股票列表》得到的股票代码获取实时交易数据(您可以理解为日线的最新数据),该接口为网络公开数据源,非券商数据源。 +数据更新:交易时间段每1分钟 +请求频率:1分钟300次 | 包月版、体验版1分钟1千次| 包年版1分钟3千次 | 钻石版1分钟6千次 +返回格式:标准Json格式 [{},...{}] +字段名称 数据类型 字段说明 +fm number 五分钟涨跌幅(%) +h number 最高价(元) +hs number 换手(%) +lb number 量比(%) +l number 最低价(元) +lt number 流通市值(元) +o number 开盘价(元) +pe number 市盈率(动态,总市值除以预估全年净利润,例如当前公布一季度净利润1000万,则预估全年净利润4000万) +pc number 涨跌幅(%) +p number 当前价格(元) +sz number 总市值(元) +cje number 成交额(元) +ud number 涨跌额(元) +v number 成交量(手) +yc number 昨日收盘价(元) +zf number 振幅(%) +zs number 涨速(%) +sjl number 市净率 +zdf60 number 60日涨跌幅(%) +zdfnc number 年初至今涨跌幅(%) +t string 更新时间yyyy-MM-ddHH:mm:ss +当天逐笔交易 +API接口:https://api.mairuiapi.com/hsrl/zbjy/股票代码(如000001)/您的licence +演示URL:https://api.mairuiapi.com/hsrl/zbjy/000001/LICENCE-66D8-9F96-0C7F0FBCD073 +接口说明:根据《股票列表》得到的股票代码获取当天逐笔交易数据,按时间倒序。 +数据更新:每日21:00 +请求频率:1分钟300次 | 包月版、体验版1分钟1千次| 包年版1分钟3千次 | 钻石版1分钟6千次 +返回格式:标准Json格式 [{},...{}] +字段名称 数据类型 字段说明 +d string 数据归属日期(yyyy-MM-dd) +t string 时间(HH:mm:dd) +v number 成交量(股) +p number 成交价 +ts number 交易方向(0:中性盘,1:买入,2:卖出) +实时交易数据(券商数据源) +API接口:https://api.mairuiapi.com/hsstock/real/time/股票代码/证书您的licence +演示URL:https://api.mairuiapi.com/hsstock/real/time/000001/LICENCE-66D8-9F96-0C7F0FBCD073 +接口说明:根据《股票列表》得到的股票代码获取实时交易数据(您可以理解为日线的最新数据)。 +数据更新:实时 +请求频率:1分钟300次 | 包月版、体验版1分钟1千次| 包年版1分钟3千次 | 钻石版1分钟6千次 +返回格式:标准Json格式 [{},...{}] +字段名称 数据类型 字段说明 +p number 最新价 +o number 开盘价 +h number 最高价 +l number 最低价 +yc number 前收盘价 +cje number 成交总额 +v number 成交总量 +pv number 原始成交总量 +ud float 涨跌额 +pc float 涨跌幅 +zf float 振幅 +t string 更新时间 +pe number 市盈率 +tr number 换手率 +pb_ratio number 市净率 +tv number 成交量 +买卖五档盘口 +API接口:https://api.mairuiapi.com/hsstock/real/five/股票代码/证书您的licence +演示URL:https://api.mairuiapi.com/hsstock/real/five/000001/LICENCE-66D8-9F96-0C7F0FBCD073 +接口说明:根据《股票列表》得到的股票代码获取实时买卖五档盘口数据。 +数据更新:实时 +请求频率:1分钟300次 | 包月版、体验版1分钟1千次| 包年版1分钟3千次 | 钻石版1分钟6千次 +返回格式:标准Json格式 [{},...{}] +字段名称 数据类型 字段说明 +ps number 委卖价 +pb number 委买价 +vs number 委卖量 +vb number 委买量 +t string 更新时间 +实时交易数据(全部 | 券商数据源) +API接口:https://a.mairuiapi.com/hsrl/ssjy/all/您的licence +演示URL:https://a.mairuiapi.com/hsrl/ssjy/all/您的licence +接口说明:一次性获取《股票列表》中所有股票的实时交易数据(您可以理解为日线的最新数据),该接口仅限钻石版和包年版证书使用且限制每分钟请求1次。 +数据更新:实时 +请求频率:1分钟300次 | 包月版、体验版1分钟1千次| 包年版1分钟3千次 | 钻石版1分钟6千次 +返回格式:标准Json格式 [{},...{}] +字段名称 数据类型 字段说明 +dm string 股票代码 +p number 最新价 +o number 开盘价 +h number 最高价 +l number 最低价 +yc number 前收盘价 +cje number 成交总额 +v number 成交总量 +pv number 原始成交总量 +ud float 涨跌额 +pc float 涨跌幅 +zf float 振幅 +t string 更新时间 +pe number 市盈率 +tr number 换手率 +pb_ratio number 市净率 +tv number 成交量 +实时交易数据(多股) +API接口:https://api.mairuiapi.com/hsrl/ssjy_more/您的licence?stock_codes=股票代码1,股票代码2……股票代码20 +演示URL:https://api.mairuiapi.com/hsrl/ssjy_more/LICENCE-66D8-9F96-0C7F0FBCD073?stock_codes=000001,000002,000004 +接口说明:一次性获取《股票列表》中不超过20支股票的实时交易数据(您可以理解为日线的最新数据) +数据更新:实时 +请求频率:1分钟300次 | 包月版、体验版1分钟1千次| 包年版1分钟3千次 | 钻石版1分钟6千次 +返回格式:标准Json格式 [{},...{}] +字段名称 数据类型 字段说明 +p number 最新价 +o number 开盘价 +h number 最高价 +l number 最低价 +yc number 前收盘价 +cje number 成交总额 +v number 成交总量 +pv number 原始成交总量 +ud float 涨跌额 +pc float 涨跌幅 +zf float 振幅 +t string 更新时间 +pe number 市盈率 +tr number 换手率 +pb_ratio number 市净率 +tv number 成交量 +实时交易数据(全部 | 网络数据源) +API接口:https://a.mairuiapi.com/hsrl/real/all/您的licence +演示URL:https://a.mairuiapi.com/hsrl/real/all/您的licence +接口说明:一次性获取《股票列表》中所有股票的实时交易数据(您可以理解为日线的最新数据),该接口仅限钻石版和包年版证书使用且限制每分钟请求1次。 +数据更新:交易时间段每1分钟 +请求频率:1分钟300次 | 包月版、体验版1分钟1千次| 包年版1分钟3千次 | 钻石版1分钟6千次 +返回格式:标准Json格式 [{},...{}] +字段名称 数据类型 字段说明 +dm string 股票代码 +fm number 五分钟涨跌幅(%) +h number 最高价(元) +hs number 换手(%) +lb number 量比(%) +l number 最低价(元) +lt number 流通市值(元) +o number 开盘价(元) +pe number 市盈率(动态,总市值除以预估全年净利润,例如当前公布一季度净利润1000万,则预估全年净利润4000万) +pc number 涨跌幅(%) +p number 当前价格(元) +sz number 总市值(元) +cje number 成交额(元) +ud number 涨跌额(元) +v number 成交量(手) +yc number 昨日收盘价(元) +zf number 振幅(%) +zs number 涨速(%) +sjl number 市净率 +zdf60 number 60日涨跌幅(%) +zdfnc number 年初至今涨跌幅(%) +t string 更新时间yyyy-MM-ddHH:mm:ss +资金流向数据 +API接口:https://api.mairuiapi.com/hsstock/history/transaction/股票代码(如000001)/您的licence?st=开始时间&et=结束时间<=最新条数 +演示URL:https://api.mairuiapi.com/hsstock/history/transaction/000001/LICENCE-66D8-9F96-0C7F0FBCD073 +接口说明:根据《股票列表》得到的股票代码获取资金流向数据。开始时间以及结束时间的格式均为 YYYYMMDD,例如:'20240101',不设置开始时间和结束时间则为全部历史数据。同时可以指定获取数据条数,例如指定lt=10,则获取最新的10条数据。下列字段中,特大单为成交金额大于或等于100万元或成交量大于或等于5000手,大单为成交金额大于或等于20万元或成交量大于或等于1000手,中单为成交金额大于或等于4万元或成交量大于或等于200手,其他为小单。 +数据更新:每日21:30更新 +请求频率:1分钟300次 | 包月版、体验版1分钟1千次| 包年版1分钟3千次 | 钻石版1分钟6千次 +返回格式:标准Json格式 [{},...{}] +字段名称 数据类型 字段说明 +t int 交易时间 +zmbzds int 主买单总单数 +zmszds int 主卖单总单数 +dddx float 大单动向 +zddy float 涨跌动因 +ddcf float 大单差分 +zmbzdszl int 主买单总单数增量 +zmszdszl int 主卖单总单数增量 +cjbszl int 成交笔数增量 +zmbtdcje float 主买特大单成交额 +zmbddcje float 主买大单成交额 +zmbzdcje float 主买中单成交额 +zmbxdcje float 主买小单成交额 +zmstdcje float 主卖特大单成交额 +zmsddcje float 主卖大单成交额 +zmszdcje float 主卖中单成交额 +zmsxdcje float 主卖小单成交额 +bdmbtdcje float 被动买特大单成交额 +bdmbddcje float 被动买大单成交额 +bdmbzdcje float 被动买中单成交额 +bdmbxdcje float 被动买小单成交额 +bdmstdcje float 被动卖特大单成交额 +bdmsddcje float 被动卖大单成交额 +bdmszdcje float 被动卖中单成交额 +bdmsxdcje float 被动卖小单成交额 +bdmsljcje float 被动卖累计成交额 +zmbtdcjl int 主买特大单成交量 +zmbddcjl int 主买大单成交量 +zmbzdcjl int 主买中单成交量 +zmbxdcjl int 主买小单成交量 +zmstdcjl int 主卖特大单成交量 +zmsddcjl int 主卖大单成交量 +zmszdcjl int 主卖中单成交量 +zmsxdcjl int 主卖小单成交量 +bdmbtdcjl int 被动买特大单成交量 +bdmbddcjl int 被动买大单成交量 +bdmbzdcjl int 被动买中单成交量 +bdmbxdcjl int 被动买小单成交量 +bdmstdcjl int 被动卖特大单成交量 +bdmsddcjl int 被动卖大单成交量 +bdmszdcjl int 被动卖中单成交量 +bdmsxdcjl int 被动卖小单成交量 +zmbtdcjzl float 主买特大单成交额增量 +zmbddcjzl float 主买大单成交额增量 +zmbzdcjzl float 主买中单成交额增量 +zmbxdcjzl float 主买小单成交额增量 +zmstdcjzl float 主卖特大单成交额增量 +zmsddcjzl float 主卖大单成交额增量 +zmszdcjzl float 主卖中单成交额增量 +zmsxdcjzl float 主卖小单成交额增量 +bdmbtdcjzl float 被动买特大单成交额增量 +bdmbddcjzl float 被动买大单成交额增量 +bdmbzdcjzl float 被动买中单成交额增量 +bdmbxdcjzl float 被动买小单成交额增量 +bdmstdcjzl float 被动卖特大单成交额增量 +bdmsddcjzl float 被动卖大单成交额增量 +bdmszdcjzl float 被动卖中单成交额增量 +bdmsxdcjzl float 被动卖小单成交额增量 +zmbtdcjzlv int 主买特大单成交量增量 +zmbddcjzlv int 主买大单成交量增量 +zmbzdcjzlv int 主买中单成交量增量 +zmbxdcjzlv int 主买小单成交量增量 +zmstdcjzlv int 主卖特大单成交量增量 +zmsddcjzlv int 主卖大单成交量增量 +zmszdcjzlv int 主卖中单成交量增量 +zmsxdcjzlv int 主卖小单成交量增量 +bdmbtdcjzlv int 被动买特大单成交量增量 +bdmbddcjzlv int 被动买大单成交量增量 +bdmbzdcjzlv int 被动买中单成交量增量 +bdmbxdcjzlv int 被动买小单成交量增量 +bdmstdcjzlv int 被动卖特大单成交量增量 +bdmsddcjzlv int 被动卖大单成交量增量 +bdmszdcjzlv int 被动卖中单成交量增量 +bdmsxdcjzlv int 被动卖小单成交量增量 + +最新分时交易 +API接口:https://api.mairuiapi.com/hsstock/latest/股票代码.市场(如000001.SZ)/分时级别(如d)/除权方式/您的licence?lt=最新条数(如5) +演示URL:https://api.mairuiapi.com/hsstock/latest/000001.SZ/d/n/LICENCE-66D8-9F96-0C7F0FBCD073?lt=1 +接口说明:根据《股票列表》得到的股票代码和分时级别获取最新交易数据,交易时间升序。目前分时级别支持5分钟、15分钟、30分钟、60分钟、日线、周线、月线、年线,对应的请求参数分别为5、15、30、60、d、w、m、y,日线以上除权方式有不复权、前复权、后复权、等比前复权、等比后复权,对应的参数分别为n、f、b、fr、br,分钟级无除权数据,对应的参数为n。同时可以指定获取数据条数,例如指定lt=10,则获取最新的10条数据。 +数据更新:实时 +请求频率:1分钟300次 | 包月版、体验版1分钟1千次| 包年版1分钟3千次 | 钻石版1分钟6千次 +返回格式:标准Json格式 [{},...{}] +字段名称 数据类型 字段说明 +t string 交易时间 +o float 开盘价 +h float 最高价 +l float 最低价 +c float 收盘价 +v float 成交量 +a float 成交额 +pc float 前收盘价 +sf int 停牌 1停牌,0 不停牌 +历史分时交易 +API接口:https://api.mairuiapi.com/hsstock/history/股票代码.市场(如000001.SZ)/分时级别(如d)/除权方式/您的licence?st=开始时间(如20240601)&et=结束时间(如20250430)<=最新条数(如100) +演示URL:https://api.mairuiapi.com/hsstock/history/000001.SZ/d/n/LICENCE-66D8-9F96-0C7F0FBCD073?st=20250101&et=20250430<=100 +接口说明:根据《股票列表》得到的股票代码和分时级别获取历史交易数据,交易时间升序。目前分时级别支持5分钟、15分钟、30分钟、60分钟、日线、周线、月线、年线,对应的请求参数分别为5、15、30、60、d、w、m、y,日线以上除权方式有不复权、前复权、后复权、等比前复权、等比后复权,对应的参数分别为n、f、b、fr、br,分钟级无除权数据,对应的参数为n。开始时间以及结束时间的格式均为 YYYYMMDD 或 YYYYMMDDhhmmss,例如:'20240101' 或'20241231235959'。不设置开始时间和结束时间则为全部历史数据。同时可以指定获取数据条数,例如指定lt=10,则获取最新的10条数据。 +数据更新:分钟级别数据盘中更新,分时越小越优先更新,如5分钟级别会每5分钟更新,15分钟级别会每15分钟更新,以此类推,日线及以上级别每日15:30开始更新,预计17:10完成 +请求频率:1分钟300次 | 包月版、体验版1分钟1千次| 包年版1分钟3千次 | 钻石版1分钟6千次 +返回格式:标准Json格式 [{},...{}] +字段名称 数据类型 字段说明 +t string 交易时间 +o float 开盘价 +h float 最高价 +l float 最低价 +c float 收盘价 +v float 成交量 +a float 成交额 +pc float 前收盘价 +sf int 停牌 1停牌,0 不停牌 +历史涨跌停价格 +API接口:https://api.mairuiapi.com/hsstock/stopprice/history/股票代码(如000001.SZ)/您的licence?st=开始时间&et=结束时间 +演示URL:https://api.mairuiapi.com/hsstock/stopprice/history/000001.SZ/LICENCE-66D8-9F96-0C7F0FBCD073?st=20240501&et=20240601 +接口说明:根据《股票列表》得到的股票代码获取历史涨跌停价格,开始时间以及结束时间的格式均为 YYYYMMDD,例如:'20240101'。不设置开始时间和结束时间则为全部历史数据。 +数据更新:每日0点 +请求频率:1分钟300次 | 包月版、体验版1分钟1千次| 包年版1分钟3千次 | 钻石版1分钟6千次 +返回格式:标准Json格式 [{},...{}] +字段名称 数据类型 字段说明 +t string 交易日期 +h float 涨停价格 +l float 跌停价格 +行情指标 +API接口:https://api.mairuiapi.com/hsstock/indicators/股票代码(如000001.SZ)/您的licence?st=开始时间&et=结束时间 +演示URL:https://api.mairuiapi.com/hsstock/indicators/600519.SH/LICENCE-66D8-9F96-0C7F0FBCD073 +接口说明:根据《股票列表》得到的股票代码获取各项行情指标,开始时间以及结束时间的格式均为 YYYYMMDD,例如:'20240101'。不设置开始时间和结束时间则为全部数据。 +数据更新:每日下午16:30开始更新,预计20:00完成更新 +请求频率:1分钟300次 | 包月版、体验版1分钟1千次| 包年版1分钟3千次 | 钻石版1分钟6千次 +返回格式:标准Json格式 [{},...{}] +字段名称 数据类型 字段说明 +time string 更新时间 +lb float 量比 +om float 1分钟涨速(%) +fm float 5分钟涨速(%) +3d float 3日涨幅(%) +5d float 5日涨幅(%) +10d float 10日涨幅(%) +3t float 3日换手(%) +5t float 5日换手(%) +10t float 10日换手(%) +企业版历史数据【1m级别】 +API接口:https://专属子域名.mairuiapi.com/hsstock/vip/股票代码.市场(如000001.SZ)/分时级别(如d)/除权方式/您的licence?st=开始时间(如20240601)&et=结束时间(如20250430)<=最新条数(如100) +演示URL:企业版专属无测试链接 +接口说明:【支持1分钟级】【因该接口为企业专属服务器直接对接券商数据源,故仅限企业版用户使用】根据《股票列表》得到的股票代码和分时级别获取历史交易数据,交易时间升序。目前分时级别支持1分钟、5分钟、15分钟、30分钟、60分钟、日线、周线、月线、年线,对应的请求参数分别为1、5、15、30、60、d、w、m、y,日线以上除权方式有不复权、前复权、后复权、等比前复权、等比后复权,对应的参数分别为n、f、b、fr、br。分钟级历史数据为最近3年以内,日线以上为上市以后所有数据。开始时间以及结束时间的格式均为 YYYYMMDD 或 YYYYMMDDhhmmss,例如:'20240101' 或'20241231235959'。不设置开始时间和结束时间则为全部历史数据。同时可以指定获取数据条数,例如指定lt=10,则获取最新的10条数据。 +数据更新:实时更新 +请求频率:1分钟300次 | 包月版、体验版1分钟1千次| 包年版1分钟3千次 | 钻石版1分钟6千次 +返回格式:标准Json格式 [{},...{}] +字段名称 数据类型 字段说明 +t string 交易时间 +o float 开盘价 +h float 最高价 +l float 最低价 +c float 收盘价 +v float 成交量 +a float 成交额 +pc float 前收盘价 +sf int 停牌 1停牌,0 不停牌 + +股票基础信息 +API接口:https://api.mairuiapi.com/hsstock/instrument/股票代码(如000001.SZ)/您的licence +演示URL:https://api.mairuiapi.com/hsstock/instrument/000001.SZ/LICENCE-66D8-9F96-0C7F0FBCD073 +接口说明:依据《股票列表》中的股票代码获取股票的基础信息 +数据更新:每日1点 +请求频率:1分钟300次 | 包月版、体验版1分钟1千次| 包年版1分钟3千次 | 钻石版1分钟6千次 +返回格式:标准Json格式 [{},...{}] +字段名称 数据类型 字段说明 +ei string 市场代码 +ii string 股票代码 +name string 股票名称 +od string 上市日期(股票IPO日期) +pc float 前收盘价格 +up float 当日涨停价 +dp float 当日跌停价 +fv float 流通股本 +tv float 总股本 +pk float 最小价格变动单位 +is int 股票停牌状态(<=0:正常交易(-1:复牌);>=1停牌天数;) + diff --git a/stock-html/README.md b/stock-html/README.md new file mode 100644 index 0000000..13d5024 --- /dev/null +++ b/stock-html/README.md @@ -0,0 +1,112 @@ +# 股票资金流向分析系统 + +## 功能说明 + +这是一个基于Web的股票资金流向分析系统,可以分析大额净流入和大额净流出对股价的影响。 + +### 主要功能 + +1. **输入股票代码和时间段**:支持输入任意股票代码和自定义时间段 +2. **大额资金流向分析**:自动识别大额净流入(≥2%)和大额净流出(≤-2%)的交易日 +3. **多维度影响分析**:分析大额资金流向对当日、+1日、+2日、+3日、+4日、+5日股价的影响 +4. **可视化展示**:提供清晰的数据概览、对比分析和详细统计表格 + +## 安装和运行 + +### 1. 激活虚拟环境 + +```bash +source venv/bin/activate +``` + +### 2. 安装依赖(如果还未安装) + +```bash +pip install flask flask-cors akshare pandas numpy +``` + +### 3. 启动服务 + +```bash +python app.py +``` + +服务将在 `http://localhost:5001` 启动 + +### 4. 访问Web界面 + +在浏览器中打开:`http://localhost:5001` + +## 使用说明 + +1. **输入股票代码**:例如 `000001`(平安银行)、`600000`(浦发银行)等 +2. **选择时间段**: + - 开始日期:默认为 2025-01-01 + - 结束日期:默认为昨天 + - 可以手动修改 +3. **点击"开始分析"**:系统会自动获取数据并进行分析 +4. **查看分析结果**: + - **数据概览**:显示总交易日数、大额流入/流出日数等 + - **对比分析**:对比大额流入和流出的平均涨跌幅差异 + - **详细统计**:可以切换查看当日、未来1-5日的详细统计数据 + +## API接口 + +### POST /api/analyze + +分析股票资金流向 + +**请求参数:** +```json +{ + "stock_code": "000001", + "start_date": "2025-01-01", + "end_date": "2025-01-22" +} +``` + +**响应示例:** +```json +{ + "success": true, + "stock_code": "000001", + "start_date": "2025-01-01", + "end_date": "2025-01-22", + "data": { + "数据概览": {...}, + "详细统计": {...}, + "对比分析": {...} + } +} +``` + +### GET /api/health + +健康检查接口 + +## 技术栈 + +- **后端**:Flask (Python) +- **前端**:Vue 3 (CDN) +- **数据源**:akshare +- **数据处理**:pandas, numpy + +## 注意事项 + +1. 首次运行可能需要下载股票数据,请耐心等待 +2. 股票代码需要正确(6开头为上海,0/3开头为深圳) +3. 时间段内必须有交易数据,否则会提示错误 +4. 大额流入/流出的阈值设定为净占比2%,可以根据需要调整 + +## 文件结构 + +``` +stock/ +├── app.py # Flask后端服务 +├── templates/ +│ └── index.html # 前端页面 +├── static/ # 静态资源目录 +├── analysis.py # 分析脚本(独立使用) +├── get-data.py # 数据获取脚本 +└── venv/ # Python虚拟环境 +``` diff --git a/stock-html/__init__.py b/stock-html/__init__.py new file mode 100644 index 0000000..ae6c4cd --- /dev/null +++ b/stock-html/__init__.py @@ -0,0 +1 @@ +# Routes 模块 diff --git a/stock-html/app.py b/stock-html/app.py new file mode 100644 index 0000000..d6bf638 --- /dev/null +++ b/stock-html/app.py @@ -0,0 +1,65 @@ +""" +股票投资分析系统 - 主应用入口 +""" +from flask import Flask, render_template, jsonify +from flask_cors import CORS +from config import Config + +# 初始化配置 +Config.init_app() + +# 创建Flask应用 +app = Flask(__name__, template_folder='templates', static_folder='static') +app.config['SECRET_KEY'] = Config.SECRET_KEY +CORS(app, supports_credentials=True) + +# 注册路由蓝图 +from routes.auth import bp as auth_bp +from routes.trades import bp as trades_bp +from routes.watchlist import bp as watchlist_bp +from routes.analysis import bp as analysis_bp +from routes.market import bp as market_bp +from routes.sim_trade import bp as sim_trade_bp +from routes.admin import bp as admin_bp +from routes.smart_trade import bp as smart_trade_bp + +app.register_blueprint(auth_bp) +app.register_blueprint(trades_bp) +app.register_blueprint(watchlist_bp) +app.register_blueprint(analysis_bp) +app.register_blueprint(market_bp) +app.register_blueprint(sim_trade_bp) +app.register_blueprint(admin_bp) +app.register_blueprint(smart_trade_bp) + + +# ========== 基础路由 ========== + +@app.route('/') +def index(): + """首页""" + return render_template('index.html') + + +@app.route('/api/health', methods=['GET']) +def health(): + """健康检查""" + return jsonify({'status': 'ok', 'message': '服务运行正常'}) + + +# ========== 启动 ========== + +if __name__ == '__main__': + print("=" * 60) + print("股票投资分析系统启动") + print("=" * 60) + print(f"Web界面: http://localhost:{Config.PORT}") + print(f"API地址: http://localhost:{Config.PORT}/api/") + print(f"健康检查: http://localhost:{Config.PORT}/api/health") + print("=" * 60) + + # 启动模拟交易定时任务调度器 + from services.scheduler import start_scheduler + start_scheduler() + + app.run(debug=True, host='0.0.0.0', port=Config.PORT) diff --git a/stock-html/auto_scan.sh b/stock-html/auto_scan.sh new file mode 100755 index 0000000..25fc0bf --- /dev/null +++ b/stock-html/auto_scan.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# 每日自动全景扫描脚本 +# 收盘后16:00执行,包含当天完整K线数据 +# 仅在交易日(周一到周五)执行,跳过周末 + +DAY_OF_WEEK=$(date +%u) + +# 周六(6)、周日(7)跳过 +if [ "$DAY_OF_WEEK" -ge 6 ]; then + echo "$(date '+%Y-%m-%d %H:%M:%S') 今天是周末,跳过扫描" + exit 0 +fi + +echo "$(date '+%Y-%m-%d %H:%M:%S') 开始自动全景扫描..." + +cd /opt/stock-app +export DB_PASSWORD=stock_password_2025 + +# 强制重新扫描:确保使用收盘后最新的K线数据 +# 如果今天早上已有旧扫描结果(开盘前),需要覆盖 +export FORCE_RESCAN=1 +/opt/stock-app/venv/bin/python full_signal_scan.py + +EXIT_CODE=$? +echo "$(date '+%Y-%m-%d %H:%M:%S') 扫描完成,退出码: $EXIT_CODE" +exit $EXIT_CODE diff --git a/stock-html/auto_sync_fund_flow.sh b/stock-html/auto_sync_fund_flow.sh new file mode 100755 index 0000000..cf53f31 --- /dev/null +++ b/stock-html/auto_sync_fund_flow.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# 资金流向数据每日采集脚本 +# 从5分钟K线数据自行计算资金流向(无需外部API) +# 建议在15:10后运行(收盘后5分钟K线数据完整) +# +# 数据源:stock_kline_5min 表 +# 执行速度:几秒即可完成(纯数据库计算) + +DAY_OF_WEEK=$(date +%u) + +# 周六(6)、周日(7)跳过 +if [ "$DAY_OF_WEEK" -ge 6 ]; then + echo "$(date '+%Y-%m-%d %H:%M:%S') 今天是周末,跳过资金流向计算" + exit 0 +fi + +echo "$(date '+%Y-%m-%d %H:%M:%S') 开始资金流向计算..." + +cd /opt/stock-app +export DB_PASSWORD=stock_password_2025 +# 计算今日 + 补算历史 +/opt/stock-app/venv/bin/python sync_fund_flow.py --backfill + +EXIT_CODE=$? +echo "$(date '+%Y-%m-%d %H:%M:%S') 资金流向计算完成,退出码: $EXIT_CODE" +exit $EXIT_CODE diff --git a/stock-html/auto_sync_kline.sh b/stock-html/auto_sync_kline.sh new file mode 100755 index 0000000..e00c204 --- /dev/null +++ b/stock-html/auto_sync_kline.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# K线数据增量同步脚本 +# 交易日收盘后执行,同步最新K线数据到本地数据库 +# 在全景扫描之前运行,确保扫描使用本地数据 + +DAY_OF_WEEK=$(date +%u) + +# 周六(6)、周日(7)跳过 +if [ "$DAY_OF_WEEK" -ge 6 ]; then + echo "$(date '+%Y-%m-%d %H:%M:%S') 今天是周末,跳过K线同步" + exit 0 +fi + +echo "$(date '+%Y-%m-%d %H:%M:%S') 开始K线增量同步..." + +cd /opt/stock-app +export DB_PASSWORD=stock_password_2025 +/opt/stock-app/venv/bin/python sync_kline.py + +EXIT_CODE=$? +echo "$(date '+%Y-%m-%d %H:%M:%S') K线同步完成,退出码: $EXIT_CODE" +exit $EXIT_CODE diff --git a/stock-html/auto_sync_kline_5min.sh b/stock-html/auto_sync_kline_5min.sh new file mode 100755 index 0000000..d9b2155 --- /dev/null +++ b/stock-html/auto_sync_kline_5min.sh @@ -0,0 +1,37 @@ +#!/bin/bash +# 5分钟K线数据每日采集脚本 +# 交易日收盘后执行,采集当天全市场的5分钟K线数据 +# 建议在 17:30 后运行(收盘后数据完整) +# +# 使用新浪API(stock_zh_a_minute),稳定不限流 +# 3线程并发(东财API),全市场约5800只,预计耗时约60-90分钟 + +DAY_OF_WEEK=$(date +%u) + +# 周六(6)、周日(7)跳过 +if [ "$DAY_OF_WEEK" -ge 6 ]; then + echo "$(date '+%Y-%m-%d %H:%M:%S') 今天是周末,跳过5分钟K线采集" + exit 0 +fi + +echo "$(date '+%Y-%m-%d %H:%M:%S') 开始5分钟K线采集..." + +cd /opt/stock-app +export DB_PASSWORD=stock_password_2025 + +# 清理可能残留的锁文件(防止权限问题导致无法启动) +LOCK_FILE="/tmp/sync_kline_5min.lock" +if [ -f "$LOCK_FILE" ]; then + # 检查锁文件中记录的PID是否仍在运行 + OLD_PID=$(cat "$LOCK_FILE" 2>/dev/null) + if [ -n "$OLD_PID" ] && ! kill -0 "$OLD_PID" 2>/dev/null; then + echo "$(date '+%Y-%m-%d %H:%M:%S') 清理残留锁文件 (旧PID: $OLD_PID 已不存在)" + rm -f "$LOCK_FILE" + fi +fi + +/opt/stock-app/venv/bin/python sync_kline_5min.py --delay 0.8 --workers 3 + +EXIT_CODE=$? +echo "$(date '+%Y-%m-%d %H:%M:%S') 5分钟K线采集完成,退出码: $EXIT_CODE" +exit $EXIT_CODE diff --git a/stock-html/backtest_recommend.py b/stock-html/backtest_recommend.py new file mode 100644 index 0000000..a07de99 --- /dev/null +++ b/stock-html/backtest_recommend.py @@ -0,0 +1,1290 @@ +#!/usr/bin/env python3 +""" +推荐算法回测脚本 v4.1 — 基于预扫描结果表 + 智能过滤优化 + 纯技术止盈止损 + +v4 新增: + - 买入过滤: min_buy_rate / min_buy_triggered + - 盈利保护: 浮盈超过阈值时不被弱卖出信号清仓 + - 跟踪止盈: 利润达到阈值后激活,回撤固定幅度才卖 +v4.1 新增: + - ignore_sell_signal: 完全忽略推荐卖出(MACD死叉),用纯技术止损 + - max_hold_days: 最大持仓天数(强制平仓) + - 可配置仓位参数: shares_per_trade / position_amount / max_concurrent + - 回测时自动检测扫描数据覆盖范围 + +依赖: 需先运行 scan_history.py 将扫描结果入库。 + +用法: + # v3 兼容模式 + ./venv/bin/python backtest_recommend.py + + # v4.1 纯技术模式(忽略推荐卖出信号,改用跟踪止盈+止损) + ./venv/bin/python backtest_recommend.py --ignore-sell --trailing-start 8 --trailing-gap 3 --stop-loss 5 + ./venv/bin/python backtest_recommend.py --ignore-sell --trailing-start 6 --trailing-gap 3 --stop-loss 8 --max-hold 30 + ./venv/bin/python backtest_recommend.py --ignore-sell --min-triggered 2 --trailing-start 8 --trailing-gap 3 --stop-loss 5 +""" +import sys +import os +import json +import argparse +from datetime import datetime, date, timedelta +from collections import defaultdict + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import psycopg2 +from config import Config + +# ─── 核心参数(默认值,可通过 run_backtest kwargs 覆盖)───── +SHARES_PER_TRADE = 1000 +MAX_BUYS_PER_DAY = 2 +MAX_POSITION_AMOUNT = 30000 +MAX_CONCURRENT_POSITIONS = 8 +SELL_COOLDOWN_DAYS = 3 +PRICE_MIN = 2.0 +PRICE_MAX = 100.0 +START_DATE = date(2026, 1, 2) + + +def get_db_conn(): + return psycopg2.connect( + host=Config.DB_HOST, port=Config.DB_PORT, + dbname=Config.DB_NAME, user=Config.DB_USER, password=Config.DB_PASSWORD, + ) + + +def get_trading_days(conn, start: date, end: date): + with conn.cursor() as cur: + cur.execute(""" + SELECT DISTINCT trade_date::date FROM stock_kline_daily + WHERE trade_date >= %s AND trade_date <= %s ORDER BY trade_date + """, (start, end)) + return [r[0] for r in cur.fetchall()] + + +def get_day_ohlc(conn, trade_date: date): + """返回 code -> (open, high, low, close) 的字典""" + with conn.cursor() as cur: + cur.execute("SELECT code, open, high, low, close FROM stock_kline_daily WHERE trade_date = %s", (trade_date,)) + return {r[0]: (float(r[1]), float(r[2]), float(r[3]), float(r[4])) for r in cur.fetchall()} + + +def get_5min_prices(conn, trade_date: date): + """获取指定交易日 09:35 和 13:40 的5分钟K线收盘价 + 返回: {code: {'buy': price_at_09:35, 'sell': price_at_13:40}} + (向后兼容接口,但新版使用 get_5min_price_at 直接按时间点查询) + """ + from datetime import time as dt_time + result = {} + dt_0935 = datetime.combine(trade_date, dt_time(9, 35)) + dt_1340 = datetime.combine(trade_date, dt_time(13, 40)) + + with conn.cursor() as cur: + cur.execute(""" + SELECT code, dt, close FROM stock_kline_5min + WHERE dt IN (%s, %s) + """, (dt_0935, dt_1340)) + for row in cur.fetchall(): + code, dt, price = row[0], row[1], float(row[2]) + if code not in result: + result[code] = {} + if dt.hour == 9 and dt.minute == 35: + result[code]['buy'] = price + elif dt.hour == 13 and dt.minute == 40: + result[code]['sell'] = price + return result + + +def get_5min_price_at(conn, trade_date: date, time_str: str): + """获取指定交易日指定时间点的5分钟K线收盘价 + time_str: 如 '09:35', '10:00', '14:30', '15:00' + 返回: {code: price} + """ + from datetime import time as dt_time + h, m = int(time_str.split(':')[0]), int(time_str.split(':')[1]) + dt_target = datetime.combine(trade_date, dt_time(h, m)) + + with conn.cursor() as cur: + cur.execute("SELECT code, close FROM stock_kline_5min WHERE dt = %s", (dt_target,)) + return {r[0]: float(r[1]) for r in cur.fetchall()} + + +def load_scan_results(conn, scan_date: date, scan_time: str): + """从 stock_scan_history 加载某日某时段的扫描结果""" + with conn.cursor() as cur: + cur.execute(""" + SELECT code, recommend_display, recommend_type, recommend_reason, + recommend_rate, triggered_count, indicators + FROM stock_scan_history + WHERE scan_date = %s AND scan_time = %s + """, (scan_date, scan_time)) + result = {} + for r in cur.fetchall(): + indicators = r[6] if r[6] else {} + holding_info = indicators.get('_holding', {}) + result[r[0]] = { + 'display': r[1], 'type': r[2], 'reason': r[3], + 'rate': r[4] or 0, 'triggered': r[5] or 0, + 'holding_display': holding_info.get('display', '观望'), + 'holding_reason': holding_info.get('reason', ''), + 'holding_rate': holding_info.get('rate', 0), + } + return result + + +# ─── 数据预加载(一次性加载全部数据到内存,避免反复查询DB)───── +def preload_all_data(conn, start: date, end: date, use_5min=False, full_5min=False): + """ + 预加载回测所需的全部数据到内存。 + 参数: + use_5min: 是否加载5分钟K线数据 + full_5min: 是否加载全部48个时间点(True) 还是仅10:00/15:00(False) + 返回 dict: + 'trading_days': [date, ...] + 'ohlc': {date: {code: (o,h,l,c)}} + '5min': {(date, time_str): {code: price}} -- 新结构! + 'scan': {(date, time_str): {code: info_dict}} + """ + import time as _t + t0 = _t.time() + + # 1) 交易日 + trading_days = get_trading_days(conn, start, end) + print(f" [preload] 交易日: {len(trading_days)} 天", flush=True) + + # 2) 日线 OHLC — 批量加载 + ohlc_all = {} + with conn.cursor() as cur: + cur.execute(""" + SELECT trade_date::date, code, open, high, low, close + FROM stock_kline_daily + WHERE trade_date >= %s AND trade_date <= %s + """, (start, end)) + for r in cur.fetchall(): + d = r[0] + if d not in ohlc_all: + ohlc_all[d] = {} + ohlc_all[d][r[1]] = (float(r[2]), float(r[3]), float(r[4]), float(r[5])) + print(f" [preload] 日线OHLC: {sum(len(v) for v in ohlc_all.values()):,} 条", flush=True) + + # 3) 5分钟K线 — 新结构: {(date, time_str): {code: price}} + fivemin_all = {} + if use_5min: + with conn.cursor() as cur: + if full_5min: + # 加载全部48个时间点 + cur.execute(""" + SELECT dt, code, close FROM stock_kline_5min + WHERE dt::date >= %s AND dt::date <= %s + """, (start, end)) + else: + # 加载常用时间点: 09:35(最优买入), 10:00(旧默认), 13:40(最优卖出), 15:00(旧默认) + cur.execute(""" + SELECT dt, code, close FROM stock_kline_5min + WHERE dt::date >= %s AND dt::date <= %s + AND ( + (EXTRACT(hour FROM dt) = 9 AND EXTRACT(minute FROM dt) = 35) + OR (EXTRACT(hour FROM dt) = 10 AND EXTRACT(minute FROM dt) = 0) + OR (EXTRACT(hour FROM dt) = 13 AND EXTRACT(minute FROM dt) = 40) + OR (EXTRACT(hour FROM dt) = 15 AND EXTRACT(minute FROM dt) = 0) + ) + """, (start, end)) + for row in cur.fetchall(): + dt_val, code, price = row[0], row[1], float(row[2]) + d = dt_val.date() if hasattr(dt_val, 'date') else dt_val + t_str = f"{dt_val.hour:02d}:{dt_val.minute:02d}" + key = (d, t_str) + if key not in fivemin_all: + fivemin_all[key] = {} + fivemin_all[key][code] = price + total_5m = sum(len(v) for v in fivemin_all.values()) + n_slots = len(set(k[1] for k in fivemin_all.keys())) + print(f" [preload] 5分钟K线: {total_5m:,} 条 ({n_slots} 个时间点)", flush=True) + + # 4) 扫描结果 — 批量加载 + scan_all = {} + with conn.cursor() as cur: + cur.execute(""" + SELECT scan_date, scan_time, code, recommend_display, recommend_type, + recommend_reason, recommend_rate, triggered_count, indicators + FROM stock_scan_history + WHERE scan_date >= %s AND scan_date <= %s + """, (start, end)) + for r in cur.fetchall(): + key = (r[0], r[1]) + if key not in scan_all: + scan_all[key] = {} + indicators = r[8] if r[8] else {} + holding_info = indicators.get('_holding', {}) + scan_all[key][r[2]] = { + 'display': r[3], 'type': r[4], 'reason': r[5], + 'rate': r[6] or 0, 'triggered': r[7] or 0, + 'holding_display': holding_info.get('display', '观望'), + 'holding_reason': holding_info.get('reason', ''), + 'holding_rate': holding_info.get('rate', 0), + } + total_scan = sum(len(v) for v in scan_all.values()) + elapsed = _t.time() - t0 + print(f" [preload] 扫描结果: {total_scan:,} 条 ({len(scan_all)} 个时段)", flush=True) + print(f" [preload] 完成! 耗时 {elapsed:.1f}s", flush=True) + + return { + 'trading_days': trading_days, + 'ohlc': ohlc_all, + '5min': fivemin_all, + 'scan': scan_all, + } + + +# ─── 统计指标计算 ───────────────────────────────────── +def calc_stats(trades, start_date, end_date, equity_series=None, max_capital_deployed=0): + total_in = 0.0 + total_out = 0.0 + closed_trades = [] + open_buys = {} + wins = losses = flat = 0 + + for t in trades: + act = t['action'] + code = t['code'] + if act in ('买入', '加仓'): + total_in += t['amount'] + if code not in open_buys: + open_buys[code] = {'cost': 0, 'shares': 0, 'first_buy': t['date']} + open_buys[code]['cost'] += t['amount'] + open_buys[code]['shares'] += t['shares'] + elif act == '清仓': + total_out += t.get('amount', 0) + profit = t.get('profit', 0) + buy_info = open_buys.pop(code, None) + first_buy = buy_info['first_buy'] if buy_info else t['date'] + sell_date = t['date'] + if isinstance(first_buy, str): + first_buy = datetime.strptime(first_buy, '%Y-%m-%d').date() + if isinstance(sell_date, str): + sell_date = datetime.strptime(sell_date, '%Y-%m-%d').date() + hold_days = (sell_date - first_buy).days + closed_trades.append({ + 'code': code, 'buy_date': first_buy, 'sell_date': sell_date, + 'cost': buy_info['cost'] if buy_info else 0, + 'revenue': t.get('amount', 0), 'profit': profit, + 'hold_days': hold_days, 'reason': t.get('reason', ''), + }) + if profit > 0: + wins += 1 + elif profit < 0: + losses += 1 + else: + flat += 1 + + total_closed = wins + losses + flat + win_rate = (wins / total_closed * 100) if total_closed > 0 else 0 + avg_hold = (sum(ct['hold_days'] for ct in closed_trades) / len(closed_trades)) if closed_trades else 0 + profit = total_out - total_in + pct = (profit / total_in * 100) if total_in > 0 else 0 + days = (end_date - start_date).days + + # v4.2: 真实资金收益率(基于最大同时占用资金) + capital_pct = (profit / max_capital_deployed * 100) if max_capital_deployed > 0 else 0 + + # 年化: 短期(<90天)用简单年化, 长期用复利年化(CAGR) + if days >= 90 and max_capital_deployed > 0 and (max_capital_deployed + profit) > 0: + capital_ann = (pow(1 + profit / max_capital_deployed, 365 / days) - 1) * 100 + capital_ann_method = 'compound' + elif days > 0 and max_capital_deployed > 0: + capital_ann = capital_pct * (365 / days) # 简单年化 + capital_ann_method = 'simple' + else: + capital_ann = 0.0 + capital_ann_method = 'N/A' + + # 周转收益率(向后兼容) + if days >= 90 and total_in > 0 and (total_in + profit) > 0: + turnover_ann = (pow((total_in + profit) / total_in, 365 / days) - 1) * 100 + turnover_ann_method = 'compound' + elif days > 0 and total_in > 0: + turnover_ann = pct * (365 / days) # 简单年化 + turnover_ann_method = 'simple' + else: + turnover_ann = 0.0 + turnover_ann_method = 'N/A' + + # 最大回撤 + max_drawdown = 0.0 + max_drawdown_pct = 0.0 + if equity_series: + peak = equity_series[0] + for eq in equity_series: + if eq > peak: + peak = eq + dd = peak - eq + if dd > max_drawdown: + max_drawdown = dd + max_drawdown_pct = round((max_drawdown / max_capital_deployed * 100), 2) \ + if max_capital_deployed > 0 else 0.0 + + avg_win = (sum(ct['profit'] for ct in closed_trades if ct['profit'] > 0) / wins) if wins > 0 else 0 + avg_loss = (sum(ct['profit'] for ct in closed_trades if ct['profit'] < 0) / losses) if losses > 0 else 0 + total_loss = abs(sum(ct['profit'] for ct in closed_trades if ct['profit'] < 0)) + total_gain = abs(sum(ct['profit'] for ct in closed_trades if ct['profit'] > 0)) + profit_factor = (total_gain / total_loss) if total_loss > 0 else 999.99 + + stock_pnl = {} + for ct in closed_trades: + c = ct['code'] + if c not in stock_pnl: + stock_pnl[c] = {'profit': 0, 'trades': 0, 'wins': 0} + stock_pnl[c]['profit'] += ct['profit'] + stock_pnl[c]['trades'] += 1 + if ct['profit'] > 0: + stock_pnl[c]['wins'] += 1 + + return { + 'total_in': total_in, 'total_out': total_out, + 'profit': profit, 'profit_pct': round(pct, 2), + 'annualized_pct': round(turnover_ann, 2), + 'annualized_method': turnover_ann_method, # v5: 年化方法 + 'max_capital': round(max_capital_deployed, 2), # v4.2: 最大占用资金 + 'capital_pct': round(capital_pct, 2), # v4.2: 真实资金收益率 + 'capital_ann_pct': round(capital_ann, 2), # v4.2: 真实年化 + 'capital_ann_method': capital_ann_method, # v5: 年化方法 + 'trade_count': len(trades), 'closed_count': total_closed, + 'wins': wins, 'losses': losses, 'flat': flat, + 'win_rate': round(win_rate, 2), 'avg_hold_days': round(avg_hold, 1), + 'max_drawdown': round(max_drawdown, 2), + 'max_drawdown_pct': max_drawdown_pct, + 'avg_win': round(avg_win, 2), 'avg_loss': round(avg_loss, 2), + 'profit_factor': round(profit_factor, 2), + 'days': days, 'closed_trades': closed_trades, 'stock_pnl': stock_pnl, + } + + +# ─── 核心回测引擎 v6.0 ───────────────────────────────── +def run_backtest(conn, start_date=None, end_date=None, + preloaded=None, # 预加载数据 (from preload_all_data) + take_profit_pct=None, stop_loss_pct=None, + # ── v4 参数 ── + min_buy_rate=0, + min_buy_triggered=0, + profit_protect_pct=0, + sell_confirm_rate=0, + trailing_start_pct=0, + trailing_gap_pct=0, + # ── v4.1 新增参数 ── + ignore_sell_signal=False, # 完全忽略推荐卖出信号 + max_hold_days=0, # 最大持仓天数 (0=不限) + shares_per_trade=None, # 每笔股数 (None=用默认值) + position_amount=None, # 单只上限 (None=用默认值) + max_concurrent=None, # 最大并发持仓 (None=用默认值) + # ── v4.2 新增参数 ── + sell_confirm_days=0, # 连续N天卖出信号才执行 (0=立即) + # ── v5 新增参数 ── + use_5min_prices=False, # 使用5分钟K线实时价格 + # ── v5.1 新增:总资金约束模式 ── + total_capital=0, # 总本金 (0=不限,>0 启用现金追踪) + max_buys_per_day=None, # 每日最多买入 (None=用默认值) + price_min=None, # 股价下限 (None=用默认值) + price_max=None, # 股价上限 (None=用默认值) + # ── v5.2 新增:动态仓位管理 ── + position_pct=0, # 单笔仓位占总资金百分比 (0=用固定股数, >0=动态仓位) + signal_weight=False, # 是否根据信号强度调整仓位 (True=强信号加仓) + # ── v6.0 新增:连涨保护 & 高级止盈止损 ── + momentum_tp=False, # 连涨保护: 当连涨≥N天且浮盈≥TP时转跟踪止盈(不立即卖) + momentum_days=3, # 判定连涨的天数 (≥N天收盘连涨) + momentum_trail_start=0, # 连涨时跟踪止盈启动线(0=用TP作为启动线) + momentum_trail_gap=3, # 连涨时跟踪止盈回撤幅度(%) + breakeven_at=0, # 移动止损: 浮盈≥N%后止损线提升到保本(0=关闭) + profit_lock_pct=0, # 利润锁定: 浮盈≥N%后止损线提升到N/2%(0=关闭) + partial_exit_pct=0, # 部分止盈: 到达TP时卖出该比例(0=全卖, 50=卖一半) + no_timeout_if_rising=False, # 超时保护: 如果股票在涨(浮盈>0且连涨)则不超时平仓 + # ── v7.0 新增:交易时点优化 (网格搜索最优) ── + buy_time='09:35', # 买入时间点 (最优: 09:35 开盘第一根5minK线) + sell_time='13:40', # 卖出/估值时间点 (最优: 13:40 午后开盘35分钟) + verbose=False): + + if start_date is None: + start_date = START_DATE + if end_date is None: + end_date = date.today() + + # 可配参数回退到全局默认值 + _shares = shares_per_trade or SHARES_PER_TRADE + _pos_amt = position_amount or MAX_POSITION_AMOUNT + _max_con = max_concurrent or MAX_CONCURRENT_POSITIONS + _max_buys = max_buys_per_day if max_buys_per_day is not None else MAX_BUYS_PER_DAY + _price_min = price_min if price_min is not None else PRICE_MIN + _price_max = price_max if price_max is not None else PRICE_MAX + + # v5.2: 动态仓位管理模式 + _dynamic_pos = False # 是否使用动态仓位 + if total_capital > 0 and position_pct > 0: + _dynamic_pos = True + _shares = 0 # 标记为动态,不用固定值 + + # v5.1: 总资金约束模式 — 去掉人为限制 + if total_capital > 0: + if position_amount is None: + _pos_amt = total_capital # 单只上限 = 总资金(无限) + if max_concurrent is None: + _max_con = 9999 # 持仓数无限 + if max_buys_per_day is None: + _max_buys = 9999 # 每日买入无限 + if price_min is None: + _price_min = 0 # 股价无下限 + if price_max is None: + _price_max = 999999 # 股价无上限 + + cash_balance = float(total_capital) if total_capital > 0 else None # None=不跟踪 + + # v5.3: 支持预加载数据(内存回测,无DB查询) + _preloaded = preloaded is not None + if _preloaded: + # 从预加载数据中筛选指定日期范围 + all_days = preloaded['trading_days'] + trading_days = [d for d in all_days if start_date <= d <= end_date] + _ohlc_cache = preloaded['ohlc'] + _5min_cache = preloaded.get('5min', {}) + _scan_cache = preloaded.get('scan', {}) + else: + trading_days = get_trading_days(conn, start_date, end_date) + _ohlc_cache = None + _5min_cache = None + _scan_cache = None + + if not trading_days: + if verbose: + print("错误: 无交易日数据") + return None + + if _preloaded: + scan_days = len(set(d for (d, t) in _scan_cache.keys() if start_date <= d <= end_date)) + else: + with conn.cursor() as cur: + cur.execute("SELECT count(DISTINCT scan_date) FROM stock_scan_history WHERE scan_date >= %s AND scan_date <= %s", + (start_date, end_date)) + scan_days = cur.fetchone()[0] + if scan_days == 0: + if verbose: + print("错误: stock_scan_history 表无数据,请先运行 scan_history.py") + return None + if verbose: + print(f" 扫描数据: {scan_days} 天可用", flush=True) + + position = {} # code -> (shares, total_cost, first_buy_date) + trades = [] + cooldown = {} + daily_logs = [] + net_cash = 0.0 + equity_series = [] + peak_profit = {} # code -> 历史最高浮盈百分比 + sell_streak = {} # v4.2: code -> 连续卖出信号天数 + max_capital_deployed = 0.0 # v4.2: 最大同时占用资金 + # v6.0: 连涨跟踪 + prev_close = {} # code -> 前一日收盘价 + rising_days = {} # code -> 连续上涨天数 + momentum_active = {} # code -> True/False, 连涨模式是否激活(转跟踪止盈) + partial_sold = {} # code -> True/False, 是否已部分止盈 + # v6.0: 移动止损 + dynamic_sl = {} # code -> 动态止损线(浮盈%, 负数=亏损) + + # v5.2: 动态仓位计算函数 + def calc_dynamic_shares(price, rate=80, triggered=1): + """根据价格和信号强度计算买入股数(A股最小100股)""" + if price <= 0 or not _dynamic_pos: + return _shares # 回退到固定股数 + # 基础仓位 = 总资金 × position_pct% + base_amount = total_capital * position_pct / 100.0 + # 信号强度加权 + if signal_weight: + if triggered >= 3 or rate >= 90: + weight = 1.5 # 强信号: 1.5倍仓位 + elif triggered >= 2 or rate >= 85: + weight = 1.2 # 较强信号: 1.2倍仓位 + else: + weight = 1.0 # 普通信号: 标准仓位 + base_amount *= weight + # 不超过可用现金 + if cash_balance is not None: + base_amount = min(base_amount, cash_balance * 0.95) # 保留5%缓冲 + # 计算股数(向下取整到100股) + shares_raw = int(base_amount / price / 100) * 100 + return max(shares_raw, 100) if shares_raw > 0 else 0 + + # v5: 统计5分钟数据覆盖情况 + _5min_hit = 0 + _5min_miss = 0 + + for i, t in enumerate(trading_days): + ohlc_t = _ohlc_cache.get(t, {}) if _preloaded else get_day_ohlc(conn, t) + if not ohlc_t: + continue + + # v5/v7: 加载当天5分钟K线价格(支持任意时间点) + if use_5min_prices: + if _preloaded: + # 新结构: {(date, time_str): {code: price}} + fivemin_buy_t = _5min_cache.get((t, buy_time), {}) + fivemin_sell_t = _5min_cache.get((t, sell_time), {}) + else: + fivemin_buy_t = get_5min_price_at(conn, t, buy_time) + fivemin_sell_t = get_5min_price_at(conn, t, sell_time) + else: + fivemin_buy_t = {} + fivemin_sell_t = {} + + day_log = {'date': t, 'buys': [], 'sells': [], 'holds': []} + + # ── 10:00 买入:用 T-1 的 16:30 扫描结果 ── + prev = trading_days[i - 1] if i > 0 else None + if prev is not None and len(position) < _max_con: + scan_1630 = _scan_cache.get((prev, '16:30'), {}) if _preloaded else load_scan_results(conn, prev, '16:30') + + candidates = [] + for code, info in scan_1630.items(): + if info['display'] != '买入': + continue + if code in cooldown and t < cooldown[code]: + continue + if code in position: + continue + if code not in ohlc_t: + continue + if min_buy_rate > 0 and info['rate'] < min_buy_rate: + continue + if min_buy_triggered > 0 and info['triggered'] < min_buy_triggered: + continue + # v5/v7: 买入价 = 5分钟指定时点实时价 > mid=(开盘+收盘)/2 + if use_5min_prices and code in fivemin_buy_t: + buy_price = fivemin_buy_t[code] + _5min_hit += 1 + else: + o, _, _, c = ohlc_t[code] + buy_price = (o + c) / 2 # mid价 + if use_5min_prices: + _5min_miss += 1 + if buy_price < _price_min or buy_price > _price_max: + continue + # v5.2: 动态仓位 — 在筛选阶段只做基本检查 + if _dynamic_pos: + est_shares = calc_dynamic_shares(buy_price, info['rate'], info['triggered']) + if est_shares <= 0: + continue + est_cost = buy_price * est_shares + else: + est_cost = buy_price * _shares + if est_cost > _pos_amt: + continue + # v5.1: 现金约束 + if cash_balance is not None and est_cost > cash_balance: + continue + candidates.append((code, info['rate'], info['triggered'], info['reason'], buy_price)) + + candidates.sort(key=lambda x: (-x[1], -x[2])) + bought = 0 + for code, rate, tc, reason, buy_price in candidates: + if bought >= _max_buys or len(position) >= _max_con: + break + # v5.2: 动态仓位 — 根据信号强度计算实际股数 + if _dynamic_pos: + buy_shares = calc_dynamic_shares(buy_price, rate, tc) + if buy_shares <= 0: + continue + else: + buy_shares = _shares + cost = buy_price * buy_shares + # v5.1: 再次检查现金(因为已经买了 bought 只) + if cash_balance is not None and cost > cash_balance: + # v5.2: 动态仓位模式下,尝试减少股数以适配现金 + if _dynamic_pos and cash_balance > buy_price * 100: + buy_shares = int(cash_balance / buy_price / 100) * 100 + cost = buy_price * buy_shares + if buy_shares <= 0: + continue + else: + continue + position[code] = (buy_shares, cost, t) + peak_profit[code] = 0.0 + trades.append({ + 'date': t, 'time': buy_time, 'action': '买入', + 'code': code, 'price': buy_price, 'shares': buy_shares, + 'amount': cost, 'reason': reason, + }) + net_cash -= cost + if cash_balance is not None: + cash_balance -= cost + day_log['buys'].append({'code': code, 'price': buy_price, 'amount': cost, 'reason': reason}) + bought += 1 + + # ── 15:00 持仓管理 ── + if position: + scan_1130 = _scan_cache.get((t, '11:30'), {}) if _preloaded else load_scan_results(conn, t, '11:30') + + for code in list(position.keys()): + if code not in ohlc_t: + continue + # v5/v7: 卖出价 = 5分钟指定时点实时价 > mid=(开盘+收盘)/2 + if use_5min_prices and code in fivemin_sell_t: + current_price = fivemin_sell_t[code] + else: + o, _, _, c = ohlc_t[code] + current_price = (o + c) / 2 if use_5min_prices else c # 非5min模式保持原close + close_p = current_price + shares, total_cost, first_buy = position[code] + profit_pct = (close_p * shares - total_cost) / total_cost * 100 if total_cost > 0 else 0 + hold_days = (t - first_buy).days if isinstance(first_buy, date) else 0 + + # 更新峰值浮盈 + if code in peak_profit: + if profit_pct > peak_profit[code]: + peak_profit[code] = profit_pct + else: + peak_profit[code] = max(0, profit_pct) + + info = scan_1130.get(code, {}) + holding_disp = info.get('holding_display', '观望') + holding_reason = info.get('holding_reason', '') + h_rate = info.get('holding_rate', 0) + + action_taken = None + + # v6.0: 更新连涨天数 + pc = prev_close.get(code, 0) + if pc > 0 and close_p > pc: + rising_days[code] = rising_days.get(code, 0) + 1 + else: + rising_days[code] = 0 + is_rising = rising_days.get(code, 0) >= momentum_days + + # v6.0: 更新动态止损线 + _effective_sl = stop_loss_pct # 默认止损线 + if code in dynamic_sl: + _effective_sl = dynamic_sl[code] + # 移动止损/保本止损 + if breakeven_at > 0 and profit_pct >= breakeven_at: + new_sl = 0 # 保本 + if profit_lock_pct > 0 and profit_pct >= profit_lock_pct: + new_sl = -(profit_lock_pct / 2) # 锁定一半利润(负值=允许的最大亏损线提高到浮盈/2) + if code not in dynamic_sl or new_sl > dynamic_sl.get(code, -999): + dynamic_sl[code] = new_sl + + # v6.0: 检查连涨保护是否激活 + if momentum_tp and take_profit_pct is not None and profit_pct >= take_profit_pct and is_rising: + momentum_active[code] = True # 连涨中达到TP,激活跟踪模式 + + # 辅助: 清仓并记录 + def _do_sell(reason_text, sell_shares=None): + nonlocal net_cash, cash_balance + s = sell_shares or shares + sell_amount = close_p * s + pft = sell_amount - (total_cost * s / shares if shares > 0 else total_cost) + trades.append({ + 'date': t, 'time': sell_time, 'action': '清仓', + 'code': code, 'price': close_p, 'shares': s, + 'amount': sell_amount, + 'reason': reason_text, + 'profit': pft, + }) + net_cash += sell_amount + if cash_balance is not None: + cash_balance += sell_amount + return pft + + # ── 第1优先: 止损 (含v6.0移动止损) ── + actual_sl = _effective_sl + if code in dynamic_sl and profit_pct >= 0: + # 动态止损: 如果当前浮盈从峰值回撤超过动态止损线 + peak = peak_profit.get(code, 0) + if peak > 0 and profit_pct < dynamic_sl[code]: + actual_sl = dynamic_sl[code] # 用动态止损线 + if stop_loss_pct is not None and profit_pct <= -stop_loss_pct: + _do_sell(f'止损(浮亏{profit_pct:.1f}%≥{stop_loss_pct}%)') + del position[code] + peak_profit.pop(code, None) + for _d in (rising_days, momentum_active, partial_sold, dynamic_sl, prev_close): + _d.pop(code, None) + cooldown[code] = t + timedelta(days=SELL_COOLDOWN_DAYS) + action_taken = '止损清仓' + + # v6.0: 移动止损触发 (保本止损 / 利润锁定) + elif code in dynamic_sl and profit_pct <= dynamic_sl[code] and profit_pct > -(stop_loss_pct or 999): + sl_line = dynamic_sl[code] + _do_sell(f'移动止损(线{sl_line:.1f}%,现{profit_pct:.1f}%)') + del position[code] + peak_profit.pop(code, None) + for _d in (rising_days, momentum_active, partial_sold, dynamic_sl, prev_close): + _d.pop(code, None) + cooldown[code] = t + timedelta(days=SELL_COOLDOWN_DAYS) + action_taken = '移动止损' + + # ── 第2优先: 固定止盈 (含v6.0连涨保护 & 部分止盈) ── + elif take_profit_pct is not None and profit_pct >= take_profit_pct: + # v6.0: 连涨保护 — 连涨中不固定止盈,转跟踪 + if momentum_tp and is_rising: + momentum_active[code] = True + action_taken = f'连涨保护(连涨{rising_days.get(code,0)}天,转跟踪止盈)' + # v6.0: 部分止盈 + elif partial_exit_pct > 0 and not partial_sold.get(code, False): + sell_shares = max(100, int(shares * partial_exit_pct / 100 / 100) * 100) + if sell_shares >= shares: + sell_shares = shares # 不够分就全卖 + partial_cost = total_cost * sell_shares / shares if shares > 0 else 0 + partial_revenue = close_p * sell_shares + partial_profit = partial_revenue - partial_cost + trades.append({ + 'date': t, 'time': sell_time, 'action': '清仓', + 'code': code, 'price': close_p, 'shares': sell_shares, + 'amount': partial_revenue, + 'reason': f'部分止盈{partial_exit_pct}%(浮盈{profit_pct:.1f}%≥{take_profit_pct}%)', + 'profit': partial_profit, + }) + remaining_shares = shares - sell_shares + remaining_cost = total_cost - partial_cost + if remaining_shares <= 0: + del position[code] + peak_profit.pop(code, None) + for _d in (rising_days, momentum_active, partial_sold, dynamic_sl, prev_close): + _d.pop(code, None) + cooldown[code] = t + timedelta(days=SELL_COOLDOWN_DAYS) + else: + position[code] = (remaining_shares, remaining_cost, first_buy) + partial_sold[code] = True + # 部分止盈后启动跟踪止盈模式 + momentum_active[code] = True + net_cash += partial_revenue + if cash_balance is not None: + cash_balance += partial_revenue + action_taken = f'部分止盈{partial_exit_pct}%' + else: + _do_sell(f'止盈(浮盈{profit_pct:.1f}%≥{take_profit_pct}%)') + del position[code] + peak_profit.pop(code, None) + for _d in (rising_days, momentum_active, partial_sold, dynamic_sl, prev_close): + _d.pop(code, None) + cooldown[code] = t + timedelta(days=SELL_COOLDOWN_DAYS) + action_taken = '止盈清仓' + + # ── 第3优先: 跟踪止盈 (原有 + v6.0连涨跟踪) ── + elif (trailing_start_pct > 0 and trailing_gap_pct > 0 + and peak_profit.get(code, 0) >= trailing_start_pct + and profit_pct <= peak_profit.get(code, 0) - trailing_gap_pct): + pk = peak_profit.get(code, 0) + _do_sell(f'跟踪止盈(峰{pk:.1f}%→现{profit_pct:.1f}%,回撤{pk-profit_pct:.1f}%≥{trailing_gap_pct}%)') + del position[code] + peak_profit.pop(code, None) + for _d in (rising_days, momentum_active, partial_sold, dynamic_sl, prev_close): + _d.pop(code, None) + cooldown[code] = t + timedelta(days=SELL_COOLDOWN_DAYS) + action_taken = '跟踪止盈' + + # v6.0: 连涨模式激活后的跟踪止盈 + elif momentum_active.get(code, False): + m_trail_start = momentum_trail_start or (take_profit_pct or 10) + m_trail_gap = momentum_trail_gap + pk = peak_profit.get(code, 0) + if pk >= m_trail_start and profit_pct <= pk - m_trail_gap: + _do_sell(f'连涨跟踪止盈(峰{pk:.1f}%→现{profit_pct:.1f}%,回撤{pk-profit_pct:.1f}%≥{m_trail_gap}%)') + del position[code] + peak_profit.pop(code, None) + for _d in (rising_days, momentum_active, partial_sold, dynamic_sl, prev_close): + _d.pop(code, None) + cooldown[code] = t + timedelta(days=SELL_COOLDOWN_DAYS) + action_taken = '连涨跟踪止盈' + else: + action_taken = f'连涨跟踪中(峰{pk:.1f}%,现{profit_pct:.1f}%,连涨{rising_days.get(code,0)}天)' + + # ── 第4优先: v4.1 最大持仓天数强制平仓 (v6.0:连涨保护) ── + elif max_hold_days > 0 and hold_days >= max_hold_days: + # v6.0: 如果连涨且盈利,不强制平仓 + if no_timeout_if_rising and is_rising and profit_pct > 0: + action_taken = f'超时但连涨保护(持仓{hold_days}天,连涨{rising_days.get(code,0)}天,浮盈{profit_pct:.1f}%)' + else: + _do_sell(f'超时平仓(持仓{hold_days}天≥{max_hold_days}天)') + del position[code] + peak_profit.pop(code, None) + for _d in (rising_days, momentum_active, partial_sold, dynamic_sl, prev_close): + _d.pop(code, None) + cooldown[code] = t + timedelta(days=SELL_COOLDOWN_DAYS) + action_taken = '超时平仓' + + # ── 第5优先: 推荐卖出(v4.1: 可忽略, v4.2: 延迟确认) ── + elif holding_disp == '卖出' and not ignore_sell_signal: + # v4.2: 延迟卖出确认 + sell_streak[code] = sell_streak.get(code, 0) + 1 + streak = sell_streak[code] + + # v4 盈利保护逻辑 + should_sell = True + protect_msg = '' + + # v4.2: 连续卖出天数不足 + if sell_confirm_days > 0 and streak < sell_confirm_days: + should_sell = False + protect_msg = f'延迟确认(连续{streak}/{sell_confirm_days}天)' + + if should_sell and profit_protect_pct > 0 and profit_pct >= profit_protect_pct: + if sell_confirm_rate > 0 and h_rate < sell_confirm_rate: + should_sell = False + protect_msg = f'盈利保护(浮盈{profit_pct:.1f}%,卖出评分{h_rate}<{sell_confirm_rate})' + elif sell_confirm_rate == 0: + should_sell = False + protect_msg = f'盈利保护(浮盈{profit_pct:.1f}%≥{profit_protect_pct}%)' + + if should_sell and sell_confirm_rate > 0 and h_rate < sell_confirm_rate: + should_sell = False + protect_msg = f'卖出评分不足({h_rate}<{sell_confirm_rate})' + + if should_sell: + total_sell = close_p * shares + profit = total_sell - total_cost + trades.append({ + 'date': t, 'time': sell_time, 'action': '清仓', + 'code': code, 'price': close_p, 'shares': shares, + 'amount': total_sell, + 'reason': f'推荐卖出: {holding_reason}', + 'profit': profit, + }) + del position[code] + peak_profit.pop(code, None) + sell_streak.pop(code, None) + net_cash += total_sell + if cash_balance is not None: + cash_balance += total_sell + cooldown[code] = t + timedelta(days=SELL_COOLDOWN_DAYS) + action_taken = '推荐卖出' + else: + action_taken = f'忽略卖出({protect_msg})' + + # ── 第6: 推荐卖出但被 ignore_sell_signal 跳过 ── + elif holding_disp == '卖出' and ignore_sell_signal: + action_taken = f'跳过推荐卖出(纯技术模式)' + + # ── 第7: 加仓 ── + elif holding_disp == '加仓': + sell_streak.pop(code, None) # 非卖出信号重置连续天数 + # v5.2: 动态仓位 — 加仓也用动态计算 + if _dynamic_pos: + add_shares = calc_dynamic_shares(close_p, h_rate, 1) + if add_shares <= 0: + add_shares = 100 # 最低加100股 + else: + add_shares = _shares + add_cost = close_p * add_shares + new_total = total_cost + add_cost + can_add = new_total <= _pos_amt + if cash_balance is not None and add_cost > cash_balance: + # v5.2: 动态模式下尝试减少加仓量 + if _dynamic_pos and cash_balance > close_p * 100: + add_shares = int(cash_balance / close_p / 100) * 100 + add_cost = close_p * add_shares + new_total = total_cost + add_cost + can_add = new_total <= _pos_amt and add_shares > 0 + else: + can_add = False + if can_add: + position[code] = (shares + add_shares, new_total, first_buy) + peak_profit[code] = 0.0 # 加仓后重置峰值 + trades.append({ + 'date': t, 'time': sell_time, 'action': '加仓', + 'code': code, 'price': close_p, 'shares': add_shares, + 'amount': add_cost, + 'reason': f'推荐加仓: {holding_reason}', + }) + net_cash -= add_cost + if cash_balance is not None: + cash_balance -= add_cost + action_taken = '推荐加仓' + else: + action_taken = f'推荐加仓(超限不执行)' + else: + sell_streak.pop(code, None) # 非卖出信号重置连续天数 + action_taken = f'推荐{holding_disp}' + + day_log['holds'].append({ + 'code': code, 'close': close_p, 'pct': round(profit_pct, 2), + 'recommend': holding_disp, 'reason': holding_reason, + 'action': action_taken, + }) + + if action_taken and ('卖出' in action_taken or '清仓' in action_taken or '止盈' in action_taken or '平仓' in action_taken): + if not action_taken.startswith('跳过') and not action_taken.startswith('忽略'): + p = trades[-1].get('profit', 0) if trades else 0 + day_log['sells'].append({ + 'code': code, 'price': close_p, 'profit': p, + 'reason': trades[-1].get('reason', ''), + }) + + # v6.0: 更新所有持仓股票的前一日收盘价(用于次日连涨判断) + for code in position: + if code in ohlc_t: + prev_close[code] = ohlc_t[code][3] # close + + # 当日收盘权益 + 最大占用资金 + position_value = 0.0 + position_cost_sum = 0.0 + for code, (shares, total_cost, first_buy) in position.items(): + position_cost_sum += total_cost # 累计成本 + if code in ohlc_t: + # v5/v7: 权益计算用5分钟卖出时点价,否则用日线收盘价 + if use_5min_prices and code in fivemin_sell_t: + eq_price = fivemin_sell_t[code] + else: + eq_price = ohlc_t[code][3] # daily close + position_value += shares * eq_price + equity_series.append(net_cash + position_value) + if position_cost_sum > max_capital_deployed: + max_capital_deployed = position_cost_sum + + daily_logs.append(day_log) + + # 回测结束仍有持仓 + if position and trading_days: + last_day = trading_days[-1] + ohlc_last = _ohlc_cache.get(last_day, {}) if _preloaded else get_day_ohlc(conn, last_day) + fivemin_last = _5min_cache.get(last_day, {}) if (_preloaded and use_5min_prices) else (get_5min_prices(conn, last_day) if use_5min_prices else {}) + for code, (shares, total_cost, first_buy) in list(position.items()): + if code in ohlc_last: + # v5: 用5分钟价或mid + if use_5min_prices and code in fivemin_last and 'sell' in fivemin_last[code]: + close_p = fivemin_last[code]['sell'] + elif use_5min_prices: + o, _, _, c = ohlc_last[code] + close_p = (o + c) / 2 + else: + close_p = ohlc_last[code][3] # daily close + total_sell = close_p * shares + trades.append({ + 'date': last_day, 'time': '回测结束', 'action': '清仓', + 'code': code, 'price': close_p, 'shares': shares, + 'amount': total_sell, 'reason': '回测截止', + 'profit': total_sell - total_cost, + }) + net_cash += total_sell + if cash_balance is not None: + cash_balance += total_sell + + stats = calc_stats(trades, start_date, end_date, + equity_series=equity_series, + max_capital_deployed=max_capital_deployed) + + # v5: 5分钟数据覆盖率 + if use_5min_prices: + total_5min = _5min_hit + _5min_miss + coverage = (_5min_hit / total_5min * 100) if total_5min > 0 else 0 + stats['5min_hit'] = _5min_hit + stats['5min_miss'] = _5min_miss + stats['5min_coverage'] = round(coverage, 1) + if verbose: + print(f" 5分钟数据: 命中{_5min_hit} 缺失{_5min_miss} 覆盖率{coverage:.1f}%", flush=True) + + # v5.1: 如果有总资金,将其加入stats + if total_capital > 0: + stats['total_capital'] = total_capital + stats['final_cash'] = cash_balance + stats['capital_utilization'] = round(stats['max_capital'] / total_capital * 100, 1) if total_capital > 0 else 0 + + # v5.2: 动态仓位信息 + stats['dynamic_position'] = _dynamic_pos + stats['position_pct'] = position_pct + stats['signal_weight'] = signal_weight + + # v7.0: 交易时点信息 + stats['buy_time'] = buy_time + stats['sell_time'] = sell_time + + return { + 'start_date': start_date, 'end_date': end_date, + 'trading_days': trading_days, 'trades': trades, + 'daily_logs': daily_logs, 'stats': stats, + } + + +def get_codes_with_data(conn, trade_date: date, min_days=30): + """兼容旧接口""" + with conn.cursor() as cur: + cur.execute(""" + SELECT code FROM stock_kline_daily + WHERE trade_date <= %s GROUP BY code HAVING count(*) >= %s + """, (trade_date, min_days)) + return [r[0] for r in cur.fetchall()] + + +# ─── 输出与主函数 ───────────────────────────────────── +def main(): + parser = argparse.ArgumentParser(description='推荐算法回测 v4.1(智能过滤 + 纯技术止盈止损)') + parser.add_argument('--take-profit', type=float, default=None, metavar='PCT', + help='止盈比例(如 10 表示 10%%)') + parser.add_argument('--stop-loss', type=float, default=None, metavar='PCT', + help='止损比例(如 5 表示 5%%)') + parser.add_argument('--min-rate', type=int, default=0, metavar='N', + help='v4: 买入最低评分 (如 85,默认0=不过滤)') + parser.add_argument('--min-triggered', type=int, default=0, metavar='N', + help='v4: 买入最低信号触发数 (如 2,默认0=不过滤)') + parser.add_argument('--profit-protect', type=float, default=0, metavar='PCT', + help='v4: 盈利保护线 (浮盈≥N%%时忽略弱卖出信号,默认0=关闭)') + parser.add_argument('--sell-confirm', type=int, default=0, metavar='N', + help='v4: 卖出确认评分 (holding_rate≥N才执行推荐卖出,默认0=不过滤)') + parser.add_argument('--trailing-start', type=float, default=0, metavar='PCT', + help='v4: 跟踪止盈激活线 (浮盈≥N%%后开始跟踪,默认0=关闭)') + parser.add_argument('--trailing-gap', type=float, default=0, metavar='PCT', + help='v4: 跟踪止盈回撤幅度 (从峰值回落N%%触发卖出,默认0=关闭)') + parser.add_argument('--ignore-sell', action='store_true', + help='v4.1: 忽略推荐卖出信号(纯技术模式)') + parser.add_argument('--max-hold', type=int, default=0, metavar='DAYS', + help='v4.1: 最大持仓天数 (超过则强制平仓,默认0=不限)') + parser.add_argument('--shares', type=int, default=None, metavar='N', + help='v4.1: 每笔股数 (默认1000)') + parser.add_argument('--pos-amount', type=float, default=None, metavar='AMT', + help='v4.1: 单只上限金额 (默认30000)') + parser.add_argument('--max-concurrent', type=int, default=None, metavar='N', + help='v4.1: 最大并发持仓数 (默认8)') + parser.add_argument('--sell-confirm-days', type=int, default=0, metavar='N', + help='v4.2: 连续N天卖出信号才执行 (默认0=立即)') + parser.add_argument('--use-5min', action='store_true', + help='v5/v7: 使用5分钟K线实时价格(买入用09:35,卖出用13:40,无则用mid)') + parser.add_argument('--buy-time', type=str, default='09:35', metavar='HH:MM', + help='v7: 买入时间点 (默认09:35, 如 10:00)') + parser.add_argument('--sell-time', type=str, default='13:40', metavar='HH:MM', + help='v7: 卖出/估值时间点 (默认13:40, 如 15:00)') + parser.add_argument('--total-capital', type=float, default=0, metavar='AMT', + help='v5.1: 总本金 (>0启用现金追踪)') + parser.add_argument('--position-pct', type=float, default=0, metavar='PCT', + help='v5.2: 单笔仓位占总资金百分比 (>0启用动态仓位, 如5=5%%)') + parser.add_argument('--signal-weight', action='store_true', + help='v5.2: 根据信号强度加权仓位(强信号1.5倍,较强1.2倍)') + parser.add_argument('--start', type=str, default=None, metavar='YYYY-MM-DD') + parser.add_argument('-v', '--verbose', action='store_true', help='输出每日详细操作') + args = parser.parse_args() + + start_date = START_DATE + if args.start: + try: + start_date = datetime.strptime(args.start, '%Y-%m-%d').date() + except ValueError: + print("错误: --start 格式应为 YYYY-MM-DD") + return + + conn = get_db_conn() + end = date.today() + + _shares = args.shares or SHARES_PER_TRADE + _pos = args.pos_amount or MAX_POSITION_AMOUNT + _con = args.max_concurrent or MAX_CONCURRENT_POSITIONS + + print("=" * 90) + print(" 推荐算法回测 v7.0(智能过滤 + 真实资金收益率 + 最优交易时点)") + print("=" * 90) + print(f" 回测区间 : {start_date} ~ {end}") + print(f" 规则 : {args.buy_time} 用 T-1 16:30扫描买入最多{MAX_BUYS_PER_DAY}只") + print(f" {args.sell_time} 用 T 日 11:30扫描做加仓/清仓推荐") + print(f" 股价区间 : {PRICE_MIN}~{PRICE_MAX} 元 单只上限 ¥{_pos:,.0f}") + print(f" 每笔股数 : {_shares} 最大持仓 : {_con} 只 冷却期 {SELL_COOLDOWN_DAYS} 天") + if args.take_profit is not None: + print(f" 止盈 : ≥{args.take_profit}%") + if args.stop_loss is not None: + print(f" 止损 : ≥{args.stop_loss}%") + if args.ignore_sell: + print(f" v4.1 : 🚫 忽略推荐卖出信号(纯技术模式)") + if args.max_hold > 0: + print(f" v4.1 : ⏰ 最大持仓 {args.max_hold} 天") + if args.sell_confirm_days > 0: + print(f" v4.2 : 📅 连续{args.sell_confirm_days}天卖出信号才执行") + if args.use_5min: + print(f" v7 : 📊 使用5分钟K线实时价格(买入@{args.buy_time},卖出@{args.sell_time},无则用mid)") + if args.min_rate > 0: + print(f" v4 买入门槛 : 评分≥{args.min_rate}") + if args.min_triggered > 0: + print(f" v4 最低信号 : 触发数≥{args.min_triggered}") + if args.profit_protect > 0: + print(f" v4 盈利保护 : 浮盈≥{args.profit_protect}%时忽略弱卖出") + if args.trailing_start > 0 and args.trailing_gap > 0: + print(f" v4 跟踪止盈 : 激活线{args.trailing_start}%, 回撤{args.trailing_gap}%触发") + print("-" * 90) + + import time + t0 = time.time() + result = run_backtest(conn, start_date=start_date, end_date=end, + take_profit_pct=args.take_profit, stop_loss_pct=args.stop_loss, + min_buy_rate=args.min_rate, + min_buy_triggered=args.min_triggered, + profit_protect_pct=args.profit_protect, + sell_confirm_rate=args.sell_confirm, + trailing_start_pct=args.trailing_start, + trailing_gap_pct=args.trailing_gap, + ignore_sell_signal=args.ignore_sell, + max_hold_days=args.max_hold, + shares_per_trade=args.shares, + position_amount=args.pos_amount, + max_concurrent=args.max_concurrent, + sell_confirm_days=args.sell_confirm_days, + use_5min_prices=args.use_5min, + total_capital=args.total_capital, + position_pct=args.position_pct, + signal_weight=args.signal_weight, + buy_time=args.buy_time, + sell_time=args.sell_time, + verbose=args.verbose) + elapsed = time.time() - t0 + + if not result: + conn.close() + return + + trades = result['trades'] + stats = result['stats'] + daily_logs = result['daily_logs'] + print(f"\n 回测完成! 耗时 {elapsed:.1f}s") + + # ── 每日交易流水 ── + print("\n" + "=" * 90) + print(" 每日交易流水") + print("=" * 90) + + for log in daily_logs: + if not log['buys'] and not log['sells'] and (not log['holds'] or not args.verbose): + continue + + print(f"\n ─── {log['date']} ───") + + if log['buys']: + print(f" {args.buy_time} 买入:") + for b in log['buys']: + print(f" 🟢 {b['code']} ¥{b['price']:.2f} × {_shares}股 = ¥{b['amount']:,.0f} ({b['reason']})") + + if log['holds']: + print(f" {args.sell_time} 持仓推荐:") + for h in log['holds']: + act = h['action'] + if '清仓' in act or '卖出' in act or '止盈' in act or '平仓' in act: + if '跳过' in act or '忽略' in act: + icon = '🛡️' + else: + icon = '🔴' + elif '加仓' in act: + icon = '🔵' + else: + icon = '⚪' + print(f" {icon} {h['code']} 现价¥{h['close']:.2f} 浮盈{h['pct']:+.1f}% → {act} ({h['reason']})") + + if log['sells']: + print(f" 15:00 执行卖出:") + for s in log['sells']: + p = s.get('profit', 0) + icon = '✅' if p >= 0 else '❌' + print(f" {icon} {s['code']} ¥{s['price']:.2f} 盈亏 ¥{p:+,.0f} ({s['reason']})") + + # 回测截止 + end_holdings = [t for t in trades if t.get('reason') == '回测截止'] + if end_holdings: + print(f"\n ─── 回测截止 ───") + for t in end_holdings: + p = t.get('profit', 0) + icon = '📈' if p >= 0 else '📉' + print(f" {icon} {t['code']} ¥{t['price']:.2f} × {t['shares']}股 浮盈亏 ¥{p:+,.0f}") + + # ── 完整交易明细 ── + if stats.get('closed_trades'): + print("\n" + "=" * 90) + print(" 完整交易明细(买入 → 卖出)") + print("=" * 90) + print(f" {'#':>3} {'代码':<8} {'买入日':>12} {'买入价':>8} {'卖出日':>12} " + f"{'卖出价':>8} {'盈亏':>10} {'天数':>5} {'原因'}") + print(" " + "-" * 85) + for idx, ct in enumerate(stats['closed_trades'], 1): + buy_price = sell_price = 0 + for tr in trades: + if tr['code'] == ct['code'] and tr['action'] == '买入': + d = tr['date'] if isinstance(tr['date'], date) else datetime.strptime(str(tr['date']), '%Y-%m-%d').date() + if d == ct['buy_date']: + buy_price = tr['price'] + break + for tr in trades: + if tr['code'] == ct['code'] and tr['action'] == '清仓': + d = tr['date'] if isinstance(tr['date'], date) else datetime.strptime(str(tr['date']), '%Y-%m-%d').date() + if d == ct['sell_date']: + sell_price = tr['price'] + break + icon = '✅' if ct['profit'] > 0 else ('❌' if ct['profit'] < 0 else '➖') + reason_short = ct['reason'][:24] + print(f" {icon}{idx:>2} {ct['code']:<8} {ct['buy_date']} ¥{buy_price:>6.2f} " + f"{ct['sell_date']} ¥{sell_price:>6.2f} ¥{ct['profit']:>+9,.0f} " + f"{ct['hold_days']:>4}天 {reason_short}") + + # ── 核心统计 ── + print("\n" + "=" * 90) + print(" 回测统计") + print("=" * 90) + print(f" 总投入(周转) : ¥{stats['total_in']:>12,.2f}") + print(f" 总收回 : ¥{stats['total_out']:>12,.2f}") + print(f" 净盈亏 : ¥{stats['profit']:>12,.2f}") + print("-" * 50) + mc = stats.get('max_capital', 0) + cp = stats.get('capital_pct', 0) + ca = stats.get('capital_ann_pct', 0) + print(f" 💰 最大占用资金 : ¥{mc:>10,.0f}") + print(f" 💰 真实收益率 : {cp:>+8.2f}% (盈亏/最大占用资金)") + ann_method = "简单年化" if stats['days'] < 90 else "复利年化(CAGR)" + print(f" 💰 真实年化 : {ca:>+8.2f}% ★★★ 核心指标 ({ann_method}, {stats['days']}天)") + print(f" 📊 周转收益率 : {stats['profit_pct']:>+8.2f}% (盈亏/总周转,参考)") + print(f" 📊 周转年化 : {stats['annualized_pct']:>+8.2f}% (参考)") + print(f" 最大回撤 : ¥{stats['max_drawdown']:>12,.2f} ({stats.get('max_drawdown_pct', 0):.2f}%)") + print("-" * 50) + print(f" 总交易笔数 : {stats['trade_count']}") + print(f" 已平仓笔数 : {stats['closed_count']}") + print(f" 胜 / 负 / 平 : {stats['wins']} / {stats['losses']} / {stats['flat']}") + print(f" 胜率 : {stats['win_rate']:.1f}%") + print(f" 平均持仓天数 : {stats['avg_hold_days']:.1f} 天") + print(f" 平均盈利 : ¥{stats['avg_win']:>10,.2f}") + print(f" 平均亏损 : ¥{stats['avg_loss']:>10,.2f}") + print(f" 盈亏比 : {stats['profit_factor']:.2f}") + print(f" 回测天数 : {stats['days']} 天") + if '5min_coverage' in stats: + print(f" 5分钟数据 : 命中{stats['5min_hit']} 缺失{stats['5min_miss']} 覆盖率{stats['5min_coverage']:.1f}%") + + # ── 个股盈亏 ── + if stats['stock_pnl']: + print("\n" + "=" * 70) + print(" 个股盈亏明细") + print("=" * 70) + sorted_pnl = sorted(stats['stock_pnl'].items(), key=lambda x: x[1]['profit'], reverse=True) + for code, info in sorted_pnl: + wr = (info['wins'] / info['trades'] * 100) if info['trades'] > 0 else 0 + icon = '✅' if info['profit'] > 0 else ('❌' if info['profit'] < 0 else '➖') + print(f" {icon} {code:<8} ¥{info['profit']:>+9,.0f} {info['trades']:>3}笔 胜率{wr:>4.0f}%") + + # ── 保存 JSON ── + out_dir = os.path.join(os.path.dirname(__file__), 'docs') + os.makedirs(out_dir, exist_ok=True) + out_file = os.path.join(out_dir, 'backtest_result.json') + with open(out_file, 'w', encoding='utf-8') as f: + json.dump({ + 'start': str(result['start_date']), 'end': str(result['end_date']), + 'version': 'v5' if args.use_5min else 'v4.2', + 'params': { + 'take_profit_pct': args.take_profit, 'stop_loss_pct': args.stop_loss, + 'min_buy_rate': args.min_rate, + 'min_buy_triggered': args.min_triggered, + 'profit_protect_pct': args.profit_protect, + 'sell_confirm_rate': args.sell_confirm, + 'trailing_start_pct': args.trailing_start, + 'trailing_gap_pct': args.trailing_gap, + 'ignore_sell_signal': args.ignore_sell, + 'max_hold_days': args.max_hold, + 'sell_confirm_days': args.sell_confirm_days, + 'use_5min_prices': args.use_5min, + 'shares_per_trade': _shares, + 'position_amount': _pos, + 'max_concurrent': _con, + 'price_range': [PRICE_MIN, PRICE_MAX], + 'cooldown_days': SELL_COOLDOWN_DAYS, + }, + 'elapsed_seconds': round(elapsed, 2), + 'stats': {k: v for k, v in stats.items() if k not in ('closed_trades', 'stock_pnl')}, + 'stock_pnl': stats['stock_pnl'], + 'trades': [{k: (str(v) if isinstance(v, date) else v) for k, v in tr.items()} for tr in trades], + }, f, ensure_ascii=False, indent=2) + print(f"\n结果已写入 {out_file}") + conn.close() + + +if __name__ == '__main__': + main() diff --git a/stock-html/clean_and_start.sh b/stock-html/clean_and_start.sh new file mode 100755 index 0000000..bde68d5 --- /dev/null +++ b/stock-html/clean_and_start.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +# 清理端口5001(如果被占用) +echo "正在清理端口5001..." +lsof -ti:5001 | xargs kill -9 2>/dev/null && echo "端口5001已清理" || echo "端口5001未被占用" + +# 等待一下确保端口释放 +sleep 1 + +# 激活虚拟环境 +source venv/bin/activate + +# 启动Flask服务 +echo "正在启动服务..." +python app.py diff --git a/stock-html/cleanup_data.py b/stock-html/cleanup_data.py new file mode 100644 index 0000000..1209190 --- /dev/null +++ b/stock-html/cleanup_data.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +""" +数据库维护脚本 +- 不删除任何历史数据 +- 只执行 VACUUM ANALYZE 优化查询性能 +- 统计各表数据量和磁盘占用 +建议通过 crontab 每周日凌晨执行一次 +""" +import os +import sys +import psycopg2 +from datetime import datetime + +# 数据库配置 +DB_CONFIG = { + 'host': os.environ.get('DB_HOST', 'localhost'), + 'port': int(os.environ.get('DB_PORT', 5432)), + 'database': os.environ.get('DB_NAME', 'stock_app'), + 'user': os.environ.get('DB_USER', 'postgres'), + 'password': os.environ.get('DB_PASSWORD', ''), +} + + +def cleanup(): + """数据库维护:统计数据量 + VACUUM ANALYZE 优化性能""" + print(f"{'='*60}") + print(f"[{datetime.now():%Y-%m-%d %H:%M:%S}] 数据库维护开始") + print(f" 策略:保留全部历史数据,仅执行性能优化") + print(f"{'='*60}") + + try: + conn = psycopg2.connect(**DB_CONFIG) + conn.autocommit = True + cur = conn.cursor() + + # 1. 统计各表数据量 + print(f"\n 📊 各表数据量统计:") + cur.execute(""" + SELECT relname as table_name, + n_live_tup as row_count, + pg_size_pretty(pg_total_relation_size(relid)) as total_size, + pg_total_relation_size(relid) as raw_size + FROM pg_catalog.pg_statio_user_tables + ORDER BY pg_total_relation_size(relid) DESC + """) + total_size = 0 + tables = [] + for name, rows, size, raw in cur.fetchall(): + total_size += raw + tables.append(name) + print(f" {name:>30}: {rows:>12,} 行 {size:>10}") + print(f" {'─'*55}") + print(f" {'总计':>30}: {'':>12} {total_size / 1024 / 1024:.0f} MB") + + # 2. VACUUM ANALYZE 优化查询性能 + print(f"\n 🔧 VACUUM ANALYZE (更新统计信息,优化查询)...") + for table in tables: + try: + t0 = datetime.now() + cur.execute(f"VACUUM ANALYZE {table}") + elapsed = (datetime.now() - t0).total_seconds() + if elapsed > 1: + print(f" {table}: {elapsed:.1f}s") + except Exception as e: + print(f" {table}: ⚠ {e}") + + print(f"\n[{datetime.now():%Y-%m-%d %H:%M:%S}] 维护完成 ✅") + print(f"{'='*60}") + + cur.close() + conn.close() + + except Exception as e: + print(f"维护失败: {e}") + sys.exit(1) + + +if __name__ == '__main__': + cleanup() diff --git a/stock-html/config.py b/stock-html/config.py new file mode 100644 index 0000000..6020698 --- /dev/null +++ b/stock-html/config.py @@ -0,0 +1,36 @@ +""" +应用配置 +""" +import os + +class Config: + # Flask 配置 + SECRET_KEY = os.environ.get('SECRET_KEY', 'stock-app-secret-key-2026') + + # 数据库配置 + DB_HOST = os.environ.get('DB_HOST', 'localhost') + DB_PORT = int(os.environ.get('DB_PORT', 5432)) + DB_NAME = os.environ.get('DB_NAME', 'stock_app') + DB_USER = os.environ.get('DB_USER', 'postgres') + DB_PASSWORD = os.environ.get('DB_PASSWORD', '') + + # 文件路径 + BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + STOCK_DATA_CACHE_DIR = os.path.join(BASE_DIR, 'stock_data_cache') + STOCK_NAME_CACHE_FILE = os.path.join(BASE_DIR, 'stock_names.json') + TRADES_FILE = os.path.join(BASE_DIR, 'trades.json') + WATCHLIST_FILE = os.path.join(BASE_DIR, 'watchlist.json') + ALERTS_CACHE_FILE = os.path.join(BASE_DIR, 'alerts_cache.json') + + # 阿里云K线API + ALICLOUD_APPCODE = os.environ.get('ALICLOUD_APPCODE', '50528b6544ac4234a8ccb5c9f2c01607') + ALICLOUD_KLINE_URL = 'https://jmqqgphqcx.market.alicloudapi.com/finance/a-shares-kline' + + # 服务端口 + PORT = int(os.environ.get('PORT', 3333)) + + # 确保缓存目录存在 + @classmethod + def init_app(cls): + if not os.path.exists(cls.STOCK_DATA_CACHE_DIR): + os.makedirs(cls.STOCK_DATA_CACHE_DIR) diff --git a/stock-html/db.py b/stock-html/db.py new file mode 100644 index 0000000..a97efab --- /dev/null +++ b/stock-html/db.py @@ -0,0 +1,587 @@ +""" +数据库连接和用户认证 +""" +import psycopg2 +from psycopg2.extras import RealDictCursor +from werkzeug.security import generate_password_hash, check_password_hash +from flask import session +import functools +from config import Config + + +def get_db(): + """获取数据库连接""" + try: + conn = psycopg2.connect( + host=Config.DB_HOST, + port=Config.DB_PORT, + database=Config.DB_NAME, + user=Config.DB_USER, + password=Config.DB_PASSWORD + ) + return conn + except Exception as e: + print(f"数据库连接失败: {e}") + return None + + +def login_required(f): + """登录验证装饰器""" + @functools.wraps(f) + def decorated_function(*args, **kwargs): + if 'user_id' not in session: + from flask import jsonify + return jsonify({'success': False, 'error': '请先登录'}), 401 + return f(*args, **kwargs) + return decorated_function + + +def get_current_user_id(): + """获取当前登录用户ID""" + return session.get('user_id') + + +def get_current_username(): + """获取当前登录用户名""" + return session.get('username') + + +# ========== 可用资金操作 ========== + +def db_get_available_cash(user_id): + """获取用户可用资金""" + conn = get_db() + if not conn: + return 0 + + try: + cur = conn.cursor(cursor_factory=RealDictCursor) + cur.execute("SELECT available_cash FROM users WHERE id = %s", (user_id,)) + result = cur.fetchone() + return float(result['available_cash'] or 0) if result else 0 + finally: + conn.close() + + +def db_update_available_cash(user_id, amount): + """更新用户可用资金""" + conn = get_db() + if not conn: + return False, '数据库连接失败' + + try: + cur = conn.cursor() + cur.execute("UPDATE users SET available_cash = %s WHERE id = %s", (amount, user_id)) + conn.commit() + return True, None + except Exception as e: + conn.rollback() + return False, str(e) + finally: + conn.close() + + +# ========== 用户操作 ========== + +def create_user(email, password): + """创建用户(使用邮箱)""" + conn = get_db() + if not conn: + return None, '数据库连接失败' + + try: + cur = conn.cursor(cursor_factory=RealDictCursor) + + # 检查邮箱是否已存在 + cur.execute("SELECT id FROM users WHERE email = %s OR username = %s", (email, email)) + if cur.fetchone(): + return None, '该邮箱已注册' + + # 创建用户(username和email都存邮箱) + password_hash = generate_password_hash(password) + cur.execute( + "INSERT INTO users (username, email, password_hash) VALUES (%s, %s, %s) RETURNING id, username, email", + (email, email, password_hash) + ) + user = cur.fetchone() + conn.commit() + + return user, None + except Exception as e: + conn.rollback() + return None, str(e) + finally: + conn.close() + + +def verify_user(email, password): + """验证用户登录(使用邮箱)""" + conn = get_db() + if not conn: + return None, '数据库连接失败' + + try: + cur = conn.cursor(cursor_factory=RealDictCursor) + # 同时检查email和username字段(兼容旧数据) + cur.execute("SELECT * FROM users WHERE email = %s OR username = %s", (email, email)) + user = cur.fetchone() + + if not user or not check_password_hash(user['password_hash'], password): + return None, '邮箱或密码错误' + + return {'id': user['id'], 'username': user.get('email') or user['username']}, None + finally: + conn.close() + + +def change_user_password(user_id, old_password, new_password): + """修改用户密码""" + conn = get_db() + if not conn: + return False, '数据库连接失败' + + try: + cur = conn.cursor(cursor_factory=RealDictCursor) + cur.execute("SELECT * FROM users WHERE id = %s", (user_id,)) + user = cur.fetchone() + + if not user: + return False, '用户不存在' + + if not check_password_hash(user['password_hash'], old_password): + return False, '当前密码错误' + + new_hash = generate_password_hash(new_password) + cur.execute("UPDATE users SET password_hash = %s WHERE id = %s", (new_hash, user_id)) + conn.commit() + return True, None + except Exception as e: + return False, str(e) + finally: + conn.close() + + +# ========== 交易记录操作(数据库版) ========== + +def db_get_trades(user_id): + """从数据库获取用户交易记录""" + conn = get_db() + if not conn: + return [] + + try: + cur = conn.cursor(cursor_factory=RealDictCursor) + cur.execute(""" + SELECT id, stock_code, stock_name, trade_type, price, quantity, + trade_date::text, reason, result, profit_amount, stop_loss_price, notes, + created_at::text + FROM trades + WHERE user_id = %s + ORDER BY trade_date DESC, created_at DESC + """, (user_id,)) + return cur.fetchall() + finally: + conn.close() + + +def db_get_trade(user_id, trade_id): + """获取单条交易记录""" + conn = get_db() + if not conn: + return None + try: + cur = conn.cursor(cursor_factory=RealDictCursor) + cur.execute(""" + SELECT id, stock_code, stock_name, trade_type, price, quantity, + trade_date::text, reason, result, profit_amount, stop_loss_price, notes, + created_at::text + FROM trades WHERE id = %s AND user_id = %s + """, (trade_id, user_id)) + return cur.fetchone() + finally: + conn.close() + + +def db_add_trade(user_id, data): + """添加交易记录到数据库""" + conn = get_db() + if not conn: + return None, '数据库连接失败' + + try: + cur = conn.cursor(cursor_factory=RealDictCursor) + cur.execute(""" + INSERT INTO trades (user_id, stock_code, stock_name, trade_type, price, + quantity, trade_date, reason, result, profit_amount, + stop_loss_price, notes) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id, stock_code, stock_name, trade_type, price, quantity, + trade_date::text, reason, result, profit_amount, stop_loss_price, + notes, created_at::text + """, ( + user_id, + data.get('stock_code'), + data.get('stock_name'), + data.get('trade_type'), + data.get('price'), + data.get('quantity'), + data.get('trade_date'), + data.get('reason'), + data.get('result'), + data.get('profit_amount'), + data.get('stop_loss_price'), + data.get('notes') + )) + trade = cur.fetchone() + conn.commit() + return trade, None + except Exception as e: + conn.rollback() + return None, str(e) + finally: + conn.close() + + +def db_update_trade(user_id, trade_id, data): + """更新交易记录""" + conn = get_db() + if not conn: + return None, '数据库连接失败' + + try: + cur = conn.cursor(cursor_factory=RealDictCursor) + cur.execute(""" + UPDATE trades SET + stock_code = COALESCE(%s, stock_code), + stock_name = COALESCE(%s, stock_name), + trade_type = COALESCE(%s, trade_type), + price = COALESCE(%s, price), + quantity = COALESCE(%s, quantity), + trade_date = COALESCE(%s, trade_date), + reason = COALESCE(%s, reason), + result = COALESCE(%s, result), + profit_amount = COALESCE(%s, profit_amount), + stop_loss_price = COALESCE(%s, stop_loss_price), + notes = COALESCE(%s, notes) + WHERE id = %s AND user_id = %s + RETURNING id, stock_code, stock_name, trade_type, price, quantity, + trade_date::text, reason, result, profit_amount, stop_loss_price, + notes, created_at::text + """, ( + data.get('stock_code'), + data.get('stock_name'), + data.get('trade_type'), + data.get('price'), + data.get('quantity'), + data.get('trade_date'), + data.get('reason'), + data.get('result'), + data.get('profit_amount'), + data.get('stop_loss_price'), + data.get('notes'), + trade_id, + user_id + )) + trade = cur.fetchone() + conn.commit() + return trade, None + except Exception as e: + conn.rollback() + return None, str(e) + finally: + conn.close() + + +def db_delete_trade(user_id, trade_id): + """删除交易记录""" + conn = get_db() + if not conn: + return False + + try: + cur = conn.cursor() + cur.execute("DELETE FROM trades WHERE id = %s AND user_id = %s", (trade_id, user_id)) + conn.commit() + return cur.rowcount > 0 + finally: + conn.close() + + +# ========== 关注列表操作(数据库版) ========== + +def db_get_watchlist(user_id): + """从数据库获取用户关注列表""" + conn = get_db() + if not conn: + return [] + + try: + cur = conn.cursor(cursor_factory=RealDictCursor) + cur.execute(""" + SELECT stock_code as code, stock_name as name, added_time::text + FROM watchlist + WHERE user_id = %s + ORDER BY added_time DESC + """, (user_id,)) + return cur.fetchall() + finally: + conn.close() + + +def db_add_to_watchlist(user_id, code, name): + """添加到关注列表""" + conn = get_db() + if not conn: + return None, '数据库连接失败' + + try: + cur = conn.cursor(cursor_factory=RealDictCursor) + cur.execute(""" + INSERT INTO watchlist (user_id, stock_code, stock_name) + VALUES (%s, %s, %s) + ON CONFLICT (user_id, stock_code) DO NOTHING + RETURNING stock_code as code, stock_name as name + """, (user_id, code, name)) + conn.commit() + return db_get_watchlist(user_id), None + except Exception as e: + conn.rollback() + return None, str(e) + finally: + conn.close() + + +def db_remove_from_watchlist(user_id, code): + """从关注列表移除""" + conn = get_db() + if not conn: + return None + + try: + cur = conn.cursor() + cur.execute("DELETE FROM watchlist WHERE user_id = %s AND stock_code = %s", (user_id, code)) + conn.commit() + return db_get_watchlist(user_id) + finally: + conn.close() + + +# ========== 分析缓存操作(数据库版) ========== + +def db_get_alerts_cache(user_id): + """从数据库获取分析缓存""" + conn = get_db() + if not conn: + return None + + try: + cur = conn.cursor(cursor_factory=RealDictCursor) + cur.execute(""" + SELECT data, updated_at::text as lastUpdate + FROM alerts_cache + WHERE user_id = %s + """, (user_id,)) + result = cur.fetchone() + if result: + return { + 'alerts': result['data'] or [], + 'lastUpdate': result['lastupdate'] + } + return {'alerts': [], 'lastUpdate': None} + finally: + conn.close() + + +def db_save_alerts_cache(user_id, alerts): + """保存分析缓存到数据库""" + conn = get_db() + if not conn: + return False + + try: + import json + cur = conn.cursor() + cur.execute(""" + INSERT INTO alerts_cache (user_id, data, updated_at) + VALUES (%s, %s, NOW()) + ON CONFLICT (user_id) DO UPDATE SET + data = EXCLUDED.data, + updated_at = NOW() + """, (user_id, json.dumps(alerts))) + conn.commit() + return True + except Exception as e: + conn.rollback() + print(f"保存分析缓存失败: {e}") + return False + finally: + conn.close() + + +# ========== 基本面数据操作(数据库版) ========== + +def db_get_fundamental(code): + """从数据库获取基本面数据(当日缓存)""" + conn = get_db() + if not conn: + return None + + try: + from datetime import date + cur = conn.cursor(cursor_factory=RealDictCursor) + cur.execute(""" + SELECT code, name, pe, pb, total_market_cap, industry, + latest_price, change_pct, update_date::text, updated_at::text, + roe, eps, bps, revenue_yoy, profit_yoy, gross_margin, net_margin + FROM stock_fundamental + WHERE code = %s AND update_date = %s + """, (code, date.today())) + return cur.fetchone() + finally: + conn.close() + + +def db_save_fundamental(code, data): + """保存基本面数据到数据库""" + conn = get_db() + if not conn: + return False + + try: + from datetime import date + cur = conn.cursor() + cur.execute(""" + INSERT INTO stock_fundamental + (code, name, pe, pb, total_market_cap, industry, latest_price, change_pct, update_date, + roe, eps, bps, revenue_yoy, profit_yoy, gross_margin, net_margin) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (code) DO UPDATE SET + name = EXCLUDED.name, + pe = EXCLUDED.pe, + pb = EXCLUDED.pb, + total_market_cap = EXCLUDED.total_market_cap, + industry = EXCLUDED.industry, + latest_price = EXCLUDED.latest_price, + change_pct = EXCLUDED.change_pct, + update_date = EXCLUDED.update_date, + roe = EXCLUDED.roe, + eps = EXCLUDED.eps, + bps = EXCLUDED.bps, + revenue_yoy = EXCLUDED.revenue_yoy, + profit_yoy = EXCLUDED.profit_yoy, + gross_margin = EXCLUDED.gross_margin, + net_margin = EXCLUDED.net_margin, + updated_at = NOW() + """, ( + code, + data.get('name') or data.get('stock_name'), + data.get('pe') or data.get('pe_ttm'), + data.get('pb'), + data.get('total_market_cap'), + data.get('industry'), + data.get('latest_price'), + data.get('change_pct'), + date.today(), + data.get('roe'), + data.get('eps'), + data.get('bps'), + data.get('revenue_yoy'), + data.get('profit_yoy'), + data.get('gross_margin'), + data.get('net_margin'), + )) + conn.commit() + return True + except Exception as e: + conn.rollback() + print(f"保存基本面数据失败: {e}") + return False + finally: + conn.close() + + +# ========== 资金流向历史数据操作(数据库版) ========== + +def db_get_fund_flow_history(code): + """获取股票的资金流向历史数据""" + conn = get_db() + if not conn: + return None, None + + try: + cur = conn.cursor(cursor_factory=RealDictCursor) + cur.execute(""" + SELECT code, trade_date::text, close_price, change_pct, + main_net_inflow, main_net_inflow_pct, + super_net_inflow, super_net_inflow_pct, + big_net_inflow, big_net_inflow_pct + FROM stock_fund_flow_history + WHERE code = %s + ORDER BY trade_date DESC + """, (code,)) + rows = cur.fetchall() + + # 获取最新日期 + latest_date = rows[0]['trade_date'] if rows else None + + return [dict(row) for row in rows], latest_date + finally: + conn.close() + + +def db_save_fund_flow_history(code, records): + """保存资金流向历史数据到数据库""" + conn = get_db() + if not conn: + return False + + try: + cur = conn.cursor() + for r in records: + cur.execute(""" + INSERT INTO stock_fund_flow_history + (code, trade_date, close_price, change_pct, + main_net_inflow, main_net_inflow_pct, + super_net_inflow, super_net_inflow_pct, + big_net_inflow, big_net_inflow_pct, + mid_net_inflow, mid_net_inflow_pct, + small_net_inflow, small_net_inflow_pct) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (code, trade_date) DO UPDATE SET + close_price = EXCLUDED.close_price, + change_pct = EXCLUDED.change_pct, + main_net_inflow = EXCLUDED.main_net_inflow, + main_net_inflow_pct = EXCLUDED.main_net_inflow_pct, + super_net_inflow = EXCLUDED.super_net_inflow, + super_net_inflow_pct = EXCLUDED.super_net_inflow_pct, + big_net_inflow = EXCLUDED.big_net_inflow, + big_net_inflow_pct = EXCLUDED.big_net_inflow_pct, + mid_net_inflow = EXCLUDED.mid_net_inflow, + mid_net_inflow_pct = EXCLUDED.mid_net_inflow_pct, + small_net_inflow = EXCLUDED.small_net_inflow, + small_net_inflow_pct = EXCLUDED.small_net_inflow_pct, + updated_at = NOW() + """, ( + code, + r.get('日期') or r.get('trade_date'), + r.get('收盘价') or r.get('close_price'), + r.get('涨跌幅') or r.get('change_pct'), + r.get('主力净流入-净额') or r.get('main_net_inflow'), + r.get('主力净流入-净占比') or r.get('main_net_inflow_pct'), + r.get('超大单净流入-净额') or r.get('super_net_inflow'), + r.get('超大单净流入-净占比') or r.get('super_net_inflow_pct'), + r.get('大单净流入-净额') or r.get('big_net_inflow'), + r.get('大单净流入-净占比') or r.get('big_net_inflow_pct'), + r.get('中单净流入-净额') or r.get('mid_net_inflow'), + r.get('中单净流入-净占比') or r.get('mid_net_inflow_pct'), + r.get('小单净流入-净额') or r.get('small_net_inflow'), + r.get('小单净流入-净占比') or r.get('small_net_inflow_pct') + )) + conn.commit() + return True + except Exception as e: + conn.rollback() + print(f"保存资金流向历史失败: {e}") + return False + finally: + conn.close() diff --git a/stock-html/deploy/DEPLOYMENT_STATUS.md b/stock-html/deploy/DEPLOYMENT_STATUS.md new file mode 100644 index 0000000..a1451d7 --- /dev/null +++ b/stock-html/deploy/DEPLOYMENT_STATUS.md @@ -0,0 +1,131 @@ +# 股票投资分析系统 - 远程服务器部署情况 + +> 最后更新:2026-03-17 + +## 一、服务器概览 + +| 服务器 | IP | 域名 | 用途 | 状态 | +|--------|-----|------|------|------| +| **主服务器(原有)** | 43.135.128.39 | - | 腾讯云,IP直连 | 运行中 | +| **新服务器** | 152.136.182.184 | stock.allbyai.cn | 腾讯云,HTTPS域名 | 已配置 | + +--- + +## 二、新服务器 (stock.allbyai.cn) 部署架构 + +### 2.1 访问方式 +- **HTTPS(推荐)**:https://stock.allbyai.cn +- **IP直连**:http://152.136.182.184:3333 + +### 2.2 技术栈 +- **Web 应用**:Flask (Python) 端口 3333 +- **反向代理**:Nginx + HTTPS (acme.sh 证书) +- **数据库**:PostgreSQL (stock_app) +- **数据采集**:stock-data-service (systemd 后台服务) + +### 2.3 部署路径 +- 应用目录:`/opt/stock-app` +- Nginx 配置:`/etc/nginx/sites-available/stock.allbyai.cn` +- SSL 证书:`/etc/nginx/ssl/stock.allbyai.cn.crt` / `.key` + +--- + +## 三、部署脚本清单 + +| 脚本 | 用途 | 执行位置 | +|------|------|----------| +| `deploy/deploy-to-new-server.sh` | 一键完整部署(同步+初始化+Nginx+重启) | 本地 | +| `deploy/sync-to-new-server.sh` | 快速同步代码并重启(日常更新) | 本地 | +| `deploy/setup-server.sh` | 服务器环境初始化(首次部署) | 服务器 | +| `deploy/setup-ssl.sh` | SSL 证书申请/安装(acme.sh) | 服务器 | +| `setup_cron_scan.sh` | 配置全景扫描定时任务 | 本地→服务器 | + +--- + +## 四、部署流程 + +### 4.1 首次部署新服务器 + +```bash +cd /Users/freedak/Documents/go-new/stock/stock-html + +# 1. 执行一键部署(会同步代码、配置 Nginx、重启服务) +./deploy/deploy-to-new-server.sh + +# 2. 首次部署需取消 deploy-to-new-server.sh 第43行注释,执行服务器初始化 +# ssh ubuntu@152.136.182.184 "${APP_DIR}/deploy/setup-server.sh" + +# 3. 若需 SSL,在服务器上执行 +# ssh ubuntu@152.136.182.184 +# 先配置 acme.sh + DNS TXT 记录,再运行: +# /opt/stock-app/deploy/setup-ssl.sh + +# 4. 配置定时任务(全景扫描、K线采集、资金流向) +./setup_cron_scan.sh +# 或手动指定:STOCK_SERVER=ubuntu@152.136.182.184 STOCK_APP_DIR=/opt/stock-app ./setup_cron_scan.sh +``` + +### 4.2 日常代码更新 + +```bash +cd /Users/freedak/Documents/go-new/stock/stock-html +./deploy/sync-to-new-server.sh +``` + +--- + +## 五、服务管理命令 + +### 5.1 新服务器 (152.136.182.184) + +```bash +# Web 应用 +ssh ubuntu@152.136.182.184 "systemctl start stock-app" +ssh ubuntu@152.136.182.184 "systemctl stop stock-app" +ssh ubuntu@152.136.182.184 "systemctl restart stock-app" +ssh ubuntu@152.136.182.184 "systemctl status stock-app" + +# 数据采集服务 +ssh ubuntu@152.136.182.184 "systemctl start stock-data-service" +ssh ubuntu@152.136.182.184 "systemctl restart stock-data-service" +ssh ubuntu@152.136.182.184 "systemctl status stock-data-service" + +# 查看日志 +ssh ubuntu@152.136.182.184 "journalctl -u stock-app -f" +ssh ubuntu@152.136.182.184 "journalctl -u stock-data-service -f" +``` + +### 5.2 SSL 证书续期 + +- **自动**:acme.sh 已配置 cron 自动续期 +- **手动**:`/home/ubuntu/.acme.sh/acme.sh --renew -d stock.allbyai.cn --ecc --force` + +--- + +## 六、注意事项与待办 + +### 6.1 已知差异 +- `sync-to-new-server.sh` 仅重启 `stock-app`,不重启 `stock-data-service` +- 完整部署时 `deploy-to-new-server.sh` 会重启两个服务 + +### 6.2 同步排除项 +部署时排除:`.git`、`venv`、`__pycache__`、`stock_data_cache`、`*.pyc`、`.DS_Store`、`stock_names.json`、`alerts_cache.json`、`trades.json`、`watchlist.json`、`*.log`、`.playwright-mcp`、根目录 `/app.js`、`/index.html`、`/main.css`、`/css`、`/js`、`.windsurfrules` + +### 6.3 定时任务(新服务器需单独配置) +- 11:50 午休扫描 +- 16:30 收盘扫描 +- 17:30 5分钟K线采集 +- 18:00 资金流向采集 + +使用 `setup_cron_scan.sh` 或按 `run.md` 手动配置 crontab。 + +--- + +## 七、快速参考 + +| 操作 | 命令 | +|------|------| +| 同步并重启新服务器 | `./deploy/sync-to-new-server.sh` | +| 完整部署新服务器 | `./deploy/deploy-to-new-server.sh` | +| 查看服务状态 | `ssh ubuntu@152.136.182.184 "systemctl status stock-app stock-data-service"` | +| 访问地址 | https://stock.allbyai.cn | diff --git a/stock-html/deploy/deploy-to-new-server.sh b/stock-html/deploy/deploy-to-new-server.sh new file mode 100755 index 0000000..65cd31f --- /dev/null +++ b/stock-html/deploy/deploy-to-new-server.sh @@ -0,0 +1,73 @@ +#!/bin/bash +# 一键部署脚本 - 从本地部署到新服务器 152.136.182.184 (stock.allbyai.cn) +# 在本地执行此脚本 + +set -e + +# 配置 +NEW_SERVER="ubuntu@152.136.182.184" +APP_DIR="/opt/stock-app" +LOCAL_DIR="/Users/freedak/Documents/go-new/stock/stock-html" + +echo "==========================================" +echo "部署到新服务器: 152.136.182.184" +echo "域名: stock.allbyai.cn" +echo "==========================================" + +# 1. 同步代码(使用 sudo 写入 /opt/stock-app) +echo "[1/5] 同步代码到服务器..." +rsync -avz --progress --rsync-path="sudo rsync" ${LOCAL_DIR}/ ${NEW_SERVER}:${APP_DIR}/ \ + --exclude='.git' \ + --exclude='venv' \ + --exclude='__pycache__' \ + --exclude='stock_data_cache' \ + --exclude='*.pyc' \ + --exclude='.DS_Store' \ + --exclude='stock_names.json' \ + --exclude='alerts_cache.json' \ + --exclude='trades.json' \ + --exclude='watchlist.json' \ + --exclude='*.log' \ + --exclude='.playwright-mcp' \ + --exclude='/app.js' \ + --exclude='/index.html' \ + --exclude='/main.css' \ + --exclude='/css' \ + --exclude='/js' \ + --exclude='/.windsurfrules' + +# 2. 在服务器上执行初始化(首次部署时需要) +echo "[2/5] 执行服务器初始化..." +ssh ${NEW_SERVER} "sudo chmod +x ${APP_DIR}/deploy/setup-server.sh" +# 如果是首次部署,取消下行注释 +# ssh ${NEW_SERVER} "sudo ${APP_DIR}/deploy/setup-server.sh" + +# 3. 配置Nginx +echo "[3/5] 配置Nginx..." +ssh ${NEW_SERVER} "sudo cp ${APP_DIR}/deploy/nginx-stock.conf /etc/nginx/sites-available/stock.allbyai.cn" +ssh ${NEW_SERVER} "sudo ln -sf /etc/nginx/sites-available/stock.allbyai.cn /etc/nginx/sites-enabled/" +ssh ${NEW_SERVER} "sudo nginx -t && sudo systemctl reload nginx" + +# 4. 重启服务 +echo "[4/5] 重启应用服务..." +ssh ${NEW_SERVER} "sudo systemctl restart stock-app" +ssh ${NEW_SERVER} "sudo systemctl restart stock-data-service" + +# 5. 检查状态 +echo "[5/5] 检查服务状态..." +ssh ${NEW_SERVER} "sudo systemctl status stock-app --no-pager" + +echo "" +echo "==========================================" +echo "部署完成!" +echo "" +echo "访问地址:" +echo " - IP直连: http://152.136.182.184:3333" +echo " - HTTPS访问: https://stock.allbyai.cn" +echo "" +echo "SSL证书信息:" +echo " - 证书路径: /etc/nginx/ssl/stock.allbyai.cn.crt" +echo " - 密钥路径: /etc/nginx/ssl/stock.allbyai.cn.key" +echo " - 自动续期: 已配置 (acme.sh cron)" +echo " - 手动续期: /home/ubuntu/.acme.sh/acme.sh --renew -d stock.allbyai.cn --ecc --force" +echo "==========================================" diff --git a/stock-html/deploy/fix-db-connection.sh b/stock-html/deploy/fix-db-connection.sh new file mode 100755 index 0000000..304a0c4 --- /dev/null +++ b/stock-html/deploy/fix-db-connection.sh @@ -0,0 +1,168 @@ +#!/bin/bash +# 修复 stock.allbyai.cn 数据库连接问题 +# 在服务器上执行: sudo bash /opt/stock-app/deploy/fix-db-connection.sh + +DB_PASS="stock_password_2025" +APP_DIR="/opt/stock-app" + +echo "==========================================" +echo "诊断并修复 PostgreSQL 数据库连接" +echo "==========================================" + +# 0. 诊断:收集当前状态 +echo "" +echo "[诊断] 检查 PostgreSQL 服务状态..." +systemctl status postgresql --no-pager 2>&1 | head -10 +echo "" + +echo "[诊断] 检查 PostgreSQL 版本和集群..." +pg_lsclusters 2>/dev/null || echo "pg_lsclusters 不可用" +echo "" + +echo "[诊断] 检查 PostgreSQL 监听端口..." +ss -tlnp | grep 5432 || netstat -tlnp 2>/dev/null | grep 5432 || echo "未检测到5432端口监听" +echo "" + +echo "[诊断] 检查 pg_hba.conf 配置..." +PG_HBA=$(find /etc/postgresql -name pg_hba.conf 2>/dev/null | head -1) +if [ -n "$PG_HBA" ]; then + echo "文件位置: $PG_HBA" + echo "--- 当前认证配置 ---" + grep -v '^#' "$PG_HBA" | grep -v '^$' + echo "---" +else + echo "未找到 pg_hba.conf" +fi +echo "" + +echo "[诊断] 检查 stock-app 服务环境变量..." +systemctl show stock-app --property=Environment 2>/dev/null || echo "无法读取服务配置" +echo "" + +echo "[诊断] 尝试 peer 认证连接..." +sudo -u postgres psql -c "SELECT 1 as peer_auth_ok;" 2>&1 || echo "peer 认证失败" +echo "" + +echo "[诊断] 检查 stock_app 数据库是否存在..." +sudo -u postgres psql -c "SELECT datname FROM pg_database WHERE datname='stock_app';" 2>&1 +echo "" + +# 1. 确保 PostgreSQL 服务运行 +echo "==========================================" +echo "[1/6] 确保 PostgreSQL 服务运行..." +systemctl start postgresql 2>/dev/null || true +systemctl enable postgresql 2>/dev/null || true + +# 2. 重置 postgres 用户密码 +echo "[2/6] 重置 postgres 用户密码..." +sudo -u postgres psql -c "ALTER USER postgres PASSWORD '${DB_PASS}';" 2>/dev/null || { + echo "尝试使用 peer 认证重置密码..." + sudo -u postgres psql -c "ALTER USER postgres PASSWORD '${DB_PASS}';" || { + echo "❌ 密码重置失败,尝试重启 PostgreSQL 后重试..." + systemctl restart postgresql + sleep 2 + sudo -u postgres psql -c "ALTER USER postgres PASSWORD '${DB_PASS}';" + } +} +echo "✅ 密码已重置" + +# 3. 确保数据库存在 +echo "[3/6] 确保 stock_app 数据库存在..." +sudo -u postgres createdb stock_app 2>/dev/null || echo "数据库已存在" + +# 4. 修复 pg_hba.conf 认证配置 +echo "[4/6] 检查并修复 pg_hba.conf..." +if [ -n "$PG_HBA" ]; then + if ! grep -q "host.*all.*all.*127.0.0.1/32.*md5\|host.*all.*all.*127.0.0.1/32.*scram-sha-256" "$PG_HBA"; then + echo "添加 localhost md5 认证规则..." + cp "$PG_HBA" "${PG_HBA}.bak.$(date +%Y%m%d%H%M%S)" + + # 在文件末尾前插入规则(确保在其他 host 规则之前或文件末尾) + if ! grep -q "^host.*all.*all.*127.0.0.1/32" "$PG_HBA"; then + echo "host all all 127.0.0.1/32 md5" >> "$PG_HBA" + fi + if ! grep -q "^host.*all.*all.*::1/128" "$PG_HBA"; then + echo "host all all ::1/128 md5" >> "$PG_HBA" + fi + + echo "✅ pg_hba.conf 已更新,重启 PostgreSQL..." + systemctl restart postgresql + sleep 2 + else + echo "✅ pg_hba.conf 认证配置正常" + fi +else + echo "⚠️ 未找到 pg_hba.conf,跳过" +fi + +# 5. 测试数据库连接 +echo "[5/6] 测试数据库连接..." +PGPASSWORD="${DB_PASS}" psql -h localhost -U postgres -d stock_app -c "SELECT 1 as ok;" 2>&1 +if [ $? -eq 0 ]; then + echo "✅ 数据库连接测试通过" +else + echo "❌ 连接测试失败!" + echo "" + echo "尝试替代修复方案..." + + # 尝试将所有 host 认证改为 md5 + if [ -n "$PG_HBA" ]; then + echo "将 scram-sha-256 改为 md5..." + sed -i 's/scram-sha-256/md5/g' "$PG_HBA" + systemctl restart postgresql + sleep 2 + + # 重新设置密码(用 md5 格式) + sudo -u postgres psql -c "ALTER USER postgres PASSWORD '${DB_PASS}';" + + PGPASSWORD="${DB_PASS}" psql -h localhost -U postgres -d stock_app -c "SELECT 1 as ok;" 2>&1 + if [ $? -eq 0 ]; then + echo "✅ 替代方案成功" + else + echo "❌ 仍然失败,请手动检查 PostgreSQL 日志:" + echo " journalctl -u postgresql -n 50" + echo " cat /var/log/postgresql/*.log | tail -50" + exit 1 + fi + fi +fi + +# 6. 重启应用服务 +echo "[6/6] 重启应用服务..." +systemctl restart stock-app +systemctl restart stock-data-service +sleep 3 + +# 验证应用是否正常 +echo "" +echo "验证应用状态..." +systemctl status stock-app --no-pager | head -5 +echo "" + +# 用 Python 测试应用级 DB 连接 +${APP_DIR}/venv/bin/python -c " +import sys +sys.path.insert(0, '${APP_DIR}') +import os +os.environ['DB_HOST'] = 'localhost' +os.environ['DB_PORT'] = '5432' +os.environ['DB_NAME'] = 'stock_app' +os.environ['DB_USER'] = 'postgres' +os.environ['DB_PASSWORD'] = '${DB_PASS}' +from db import get_db +conn = get_db() +if conn: + cur = conn.cursor() + cur.execute('SELECT COUNT(*) FROM users') + count = cur.fetchone()[0] + print(f'✅ Python 应用级 DB 连接成功!用户数: {count}') + conn.close() +else: + print('❌ Python 应用级 DB 连接失败') + sys.exit(1) +" 2>&1 + +echo "" +echo "==========================================" +echo "修复完成!请访问 https://stock.allbyai.cn 验证" +echo "==========================================" diff --git a/stock-html/deploy/nginx-stock.conf b/stock-html/deploy/nginx-stock.conf new file mode 100644 index 0000000..00a085d --- /dev/null +++ b/stock-html/deploy/nginx-stock.conf @@ -0,0 +1,49 @@ +# Nginx配置 - stock.allbyai.cn (HTTPS) +# 部署路径: /etc/nginx/sites-available/stock.allbyai.cn +# SSL证书由 acme.sh 管理,自动续期 + +# HTTP 重定向到 HTTPS +server { + listen 80; + server_name stock.allbyai.cn; + return 301 https://$server_name$request_uri; +} + +# HTTPS 配置 +server { + listen 443 ssl http2; + server_name stock.allbyai.cn; + + # SSL 证书 + ssl_certificate /etc/nginx/ssl/stock.allbyai.cn.crt; + ssl_certificate_key /etc/nginx/ssl/stock.allbyai.cn.key; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; + ssl_prefer_server_ciphers off; + + # 日志 + access_log /var/log/nginx/stock.allbyai.cn.access.log; + error_log /var/log/nginx/stock.allbyai.cn.error.log; + + # 代理到Flask应用 + location / { + proxy_pass http://127.0.0.1:3333; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + proxy_read_timeout 300s; + proxy_connect_timeout 75s; + } + + # 静态文件缓存 + location /static/ { + alias /opt/stock-app/static/; + expires 7d; + add_header Cache-Control "public, immutable"; + } +} diff --git a/stock-html/deploy/run-fix-db.sh b/stock-html/deploy/run-fix-db.sh new file mode 100755 index 0000000..03485e0 --- /dev/null +++ b/stock-html/deploy/run-fix-db.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# 从本地执行:同步修复脚本到服务器并执行 +# 用法: ./deploy/run-fix-db.sh + +set -e + +NEW_SERVER="ubuntu@152.136.182.184" +APP_DIR="/opt/stock-app" +LOCAL_DIR="$(cd "$(dirname "$0")/.." && pwd)" + +echo "==========================================" +echo "修复 stock.allbyai.cn 数据库连接" +echo "==========================================" + +echo "[1/2] 同步修复脚本到服务器..." +rsync -avz "${LOCAL_DIR}/deploy/fix-db-connection.sh" ${NEW_SERVER}:${APP_DIR}/deploy/ + +echo "[2/2] 在服务器上执行修复..." +ssh ${NEW_SERVER} "sudo bash ${APP_DIR}/deploy/fix-db-connection.sh" + +echo "" +echo "验证: curl -s https://stock.allbyai.cn/api/db/data_status" diff --git a/stock-html/deploy/setup-server.sh b/stock-html/deploy/setup-server.sh new file mode 100755 index 0000000..4c5110f --- /dev/null +++ b/stock-html/deploy/setup-server.sh @@ -0,0 +1,118 @@ +#!/bin/bash +# 新服务器初始化脚本 - 152.136.182.184 (stock.allbyai.cn) +# 在服务器上执行此脚本完成环境配置 + +set -e + +echo "==========================================" +echo "股票投资分析系统 - 服务器初始化" +echo "服务器: 152.136.182.184" +echo "域名: stock.allbyai.cn" +echo "==========================================" + +# 更新系统 +echo "[1/8] 更新系统包..." +apt-get update && apt-get upgrade -y + +# 安装依赖 +echo "[2/8] 安装系统依赖..." +apt-get install -y python3 python3-pip python3-venv nginx postgresql postgresql-contrib + +# 配置PostgreSQL +echo "[3/8] 配置PostgreSQL..." +sudo -u postgres psql -c "ALTER USER postgres PASSWORD 'stock_password_2025';" || true +sudo -u postgres createdb stock_app || echo "数据库已存在" + +# 创建应用目录 +echo "[4/8] 创建应用目录..." +mkdir -p /opt/stock-app +chown -R root:root /opt/stock-app + +# 创建Python虚拟环境 +echo "[5/8] 创建Python虚拟环境..." +cd /opt/stock-app +python3 -m venv venv +source venv/bin/activate +pip install --upgrade pip + +# 安装Python依赖 +echo "[6/8] 安装Python依赖..." +if [ -f requirements.txt ]; then + pip install -r requirements.txt +else + pip install flask flask-cors akshare pandas numpy schedule psycopg2-binary +fi + +# 初始化数据库 +echo "[7/8] 初始化数据库..." +if [ -f init_db.sql ]; then + sudo -u postgres psql -d stock_app -f init_db.sql || true +fi +if [ -f init_sim_trade.sql ]; then + sudo -u postgres psql -d stock_app -f init_sim_trade.sql || true +fi +if [ -f init_smart_trade.sql ]; then + sudo -u postgres psql -d stock_app -f init_smart_trade.sql || true +fi +if [ -f init_stock_data_db.sql ]; then + sudo -u postgres psql -d stock_app -f init_stock_data_db.sql || true +fi + +# 配置systemd服务 +echo "[8/8] 配置systemd服务..." +cat > /etc/systemd/system/stock-app.service << 'EOF' +[Unit] +Description=Stock Investment Analysis Web Application +After=network.target postgresql.service + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/stock-app +Environment="DB_HOST=localhost" +Environment="DB_PORT=5432" +Environment="DB_NAME=stock_app" +Environment="DB_USER=postgres" +Environment="DB_PASSWORD=stock_password_2025" +ExecStart=/opt/stock-app/venv/bin/python app.py +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target +EOF + +cat > /etc/systemd/system/stock-data-service.service << 'EOF' +[Unit] +Description=Stock Data Collection Service +After=network.target postgresql.service + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/stock-app +Environment="DB_HOST=localhost" +Environment="DB_PORT=5432" +Environment="DB_NAME=stock_app" +Environment="DB_USER=postgres" +Environment="DB_PASSWORD=stock_password_2025" +ExecStart=/opt/stock-app/venv/bin/python stock_data_service.py daemon +Restart=always +RestartSec=30 + +[Install] +WantedBy=multi-user.target +EOF + +systemctl daemon-reload +systemctl enable stock-app +systemctl enable stock-data-service + +echo "==========================================" +echo "初始化完成!" +echo "" +echo "后续步骤:" +echo "1. 从本地同步代码到服务器" +echo "2. 配置Nginx反向代理" +echo "3. 启动服务" +echo "==========================================" diff --git a/stock-html/deploy/setup-ssl.sh b/stock-html/deploy/setup-ssl.sh new file mode 100644 index 0000000..fb943d1 --- /dev/null +++ b/stock-html/deploy/setup-ssl.sh @@ -0,0 +1,115 @@ +#!/bin/bash +# SSL证书申请脚本 - stock.allbyai.cn +# 使用 acme.sh + DNS 验证方式申请免费SSL证书 +# 适用于 DNSPod 有 Web 防护导致 HTTP 验证失败的情况 +# 在服务器上执行此脚本 + +set -e + +DOMAIN="stock.allbyai.cn" +SSL_DIR="/etc/nginx/ssl" +ACME_HOME="/home/ubuntu/.acme.sh" + +echo "==========================================" +echo "为 ${DOMAIN} 完成SSL证书安装" +echo "==========================================" + +# 检查 acme.sh 是否已安装 +if [ ! -f "${ACME_HOME}/acme.sh" ]; then + echo "错误: acme.sh 未安装,请先运行初始化" + exit 1 +fi + +# 检查 DNS TXT 记录 +echo "[1/4] 检查 DNS TXT 记录..." +TXT_RECORD=$(dig +short TXT _acme-challenge.stock.allbyai.cn 2>/dev/null || echo "") +if [ -z "$TXT_RECORD" ]; then + echo "" + echo "错误: DNS TXT 记录未找到!" + echo "" + echo "请在 DNSPod 添加以下 TXT 记录:" + echo " 主机记录: _acme-challenge.stock" + echo " 记录值: cM9lSA4-uKk-yjrvA38HYtmA1h7E3DzgIXVJq8IsSNo" + echo "" + echo "添加后等待 1-2 分钟,然后重新运行此脚本" + exit 1 +fi +echo "DNS TXT 记录已找到: $TXT_RECORD" + +# 创建 SSL 目录 +echo "[2/4] 创建 SSL 目录..." +mkdir -p ${SSL_DIR} + +# 完成证书申请 +echo "[3/4] 完成证书申请..." +${ACME_HOME}/acme.sh --renew -d ${DOMAIN} --yes-I-know-dns-manual-mode-enough-go-ahead-please + +# 安装证书 +echo "[4/4] 安装证书到 Nginx..." +${ACME_HOME}/acme.sh --install-cert -d ${DOMAIN} \ + --key-file ${SSL_DIR}/${DOMAIN}.key \ + --fullchain-file ${SSL_DIR}/${DOMAIN}.crt \ + --reloadcmd "systemctl reload nginx" + +# 更新 Nginx 配置 +echo "[5/5] 更新 Nginx 配置..." +cat > /etc/nginx/sites-available/${DOMAIN} << 'NGINX_CONF' +# Nginx配置 - stock.allbyai.cn (HTTPS) +server { + listen 80; + server_name stock.allbyai.cn; + return 301 https://$server_name$request_uri; +} + +server { + listen 443 ssl http2; + server_name stock.allbyai.cn; + + ssl_certificate /etc/nginx/ssl/stock.allbyai.cn.crt; + ssl_certificate_key /etc/nginx/ssl/stock.allbyai.cn.key; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; + ssl_prefer_server_ciphers off; + + access_log /var/log/nginx/stock.allbyai.cn.access.log; + error_log /var/log/nginx/stock.allbyai.cn.error.log; + + location / { + proxy_pass http://127.0.0.1:3333; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + proxy_read_timeout 300s; + proxy_connect_timeout 75s; + } + + location /static/ { + alias /opt/stock-app/static/; + expires 7d; + add_header Cache-Control "public, immutable"; + } +} +NGINX_CONF + +# 重载 Nginx +nginx -t && systemctl reload nginx + +echo "" +echo "==========================================" +echo "SSL证书安装完成!" +echo "" +echo "访问地址: https://${DOMAIN}" +echo "" +echo "证书信息:" +echo " - 证书路径: ${SSL_DIR}/${DOMAIN}.crt" +echo " - 密钥路径: ${SSL_DIR}/${DOMAIN}.key" +echo " - 有效期: 90天" +echo " - 自动续期: 已配置 (acme.sh cron)" +echo "" +echo "手动续期命令: ${ACME_HOME}/acme.sh --renew -d ${DOMAIN} --force" +echo "==========================================" diff --git a/stock-html/deploy/stock-app.service b/stock-html/deploy/stock-app.service new file mode 100644 index 0000000..43f27df --- /dev/null +++ b/stock-html/deploy/stock-app.service @@ -0,0 +1,19 @@ +[Unit] +Description=Stock Investment Analysis Web Application +After=network.target postgresql.service + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/stock-app +Environment="DB_HOST=localhost" +Environment="DB_PORT=5432" +Environment="DB_NAME=stock_app" +Environment="DB_USER=postgres" +Environment="DB_PASSWORD=stock_password_2025" +ExecStart=/opt/stock-app/venv/bin/python app.py +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target diff --git a/stock-html/deploy/sync-to-new-server.sh b/stock-html/deploy/sync-to-new-server.sh new file mode 100755 index 0000000..bec3e7b --- /dev/null +++ b/stock-html/deploy/sync-to-new-server.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# 快速同步代码到新服务器并重启 - 152.136.182.184 (stock.allbyai.cn) +# 用于日常代码更新 + +set -e + +NEW_SERVER="ubuntu@152.136.182.184" +APP_DIR="/opt/stock-app" +LOCAL_DIR="/Users/freedak/Documents/go-new/stock/stock-html" + +echo "同步代码到 stock.allbyai.cn (152.136.182.184)..." + +rsync -avz ${LOCAL_DIR}/ ${NEW_SERVER}:${APP_DIR}/ \ + --exclude='.git' \ + --exclude='venv' \ + --exclude='__pycache__' \ + --exclude='stock_data_cache' \ + --exclude='*.pyc' \ + --exclude='.DS_Store' \ + --exclude='stock_names.json' \ + --exclude='alerts_cache.json' \ + --exclude='trades.json' \ + --exclude='watchlist.json' \ + --exclude='*.log' \ + --exclude='.playwright-mcp' \ + --exclude='/app.js' \ + --exclude='/index.html' \ + --exclude='/main.css' \ + --exclude='/css' \ + --exclude='/js' \ + --exclude='/.windsurfrules' + +ssh ${NEW_SERVER} "systemctl restart stock-app && echo '✅ 服务已重启'" + +echo "" +echo "访问: http://stock.allbyai.cn" diff --git a/stock-html/docs/algo_search_results.md b/stock-html/docs/algo_search_results.md new file mode 100644 index 0000000..e54603a --- /dev/null +++ b/stock-html/docs/algo_search_results.md @@ -0,0 +1,91 @@ +# 🔍 系统性算法搜索结果 (v5.3 内存回测引擎) + +> 生成时间: 2026-02-26 08:19 + +## 搜索配置 + +| 项目 | 值 | +|------|----| +| 本金 | ¥200,000 | +| Phase 1 筛选期 | 2025-07-01 ~ 2025-09-30 | +| Phase 2 验证期 | 2025-01-02 ~ 2026-02-25 | +| 组合总数 | 70,400 → 剪枝后 21,120 | +| Phase 1 耗时 | 913s (23.1次/秒) | +| Phase 2 耗时 | 8s | +| 总耗时 | 922s (15.4分钟) | + +## 🏆 全期间 Top 30 + +| 排名 | 全期盈利 | Q3盈利 | 真实收益 | 年化 | 胜率 | 盈亏比 | 回撤 | 交易 | 持仓天 | 策略 | +|------|---------|-------|---------|------|------|--------|------|------|--------|------| +| 🏆 | ¥+68,330 | ¥+43,258 | +25.6% | +22.0% | 44.6% | 1.80 | 8.6% | 488 | 16d | `TP12|SL6|delay3|h≤30|10%|SW` | +| 🥈 | ¥+68,330 | ¥+43,258 | +25.6% | +22.0% | 44.6% | 1.80 | 8.6% | 488 | 16d | `TP12|SL6|trig≥1|delay3|h≤30|10%|SW` | +| 🥉 | ¥+57,452 | ¥+42,404 | +22.0% | +18.9% | 46.3% | 1.62 | 11.2% | 414 | 16d | `TP12|SL6|delay3|h≤30|15%|SW` | +| #4 | ¥+57,452 | ¥+42,404 | +22.0% | +18.9% | 46.3% | 1.62 | 11.2% | 414 | 16d | `TP12|SL6|trig≥1|delay3|h≤30|15%|SW` | +| #5 | ¥+48,554 | ¥+44,714 | +19.4% | +16.7% | 54.7% | 1.60 | 14.6% | 324 | 32d | `TP10|SL8|ign|15%|SW` | +| #6 | ¥+48,554 | ¥+44,714 | +19.4% | +16.7% | 54.7% | 1.60 | 14.6% | 324 | 32d | `TP10|SL8|trig≥1|ign|15%|SW` | +| #7 | ¥+36,958 | ¥+44,291 | +14.0% | +12.1% | 39.4% | 1.88 | 15.4% | 421 | 16d | `TP12|SL6|h≤60|15%|SW` | +| #8 | ¥+36,958 | ¥+44,291 | +14.0% | +12.1% | 39.4% | 1.88 | 15.4% | 421 | 16d | `TP12|SL6|delay1|h≤60|15%|SW` | +| #9 | ¥+36,958 | ¥+44,291 | +14.0% | +12.1% | 39.4% | 1.88 | 15.4% | 421 | 16d | `TP12|SL6|trig≥1|h≤60|15%|SW` | +| #10 | ¥+36,958 | ¥+44,291 | +14.0% | +12.1% | 39.4% | 1.88 | 15.4% | 421 | 16d | `TP12|SL6|trig≥1|delay1|h≤60|15%|SW` | +| #11 | ¥+34,113 | ¥+42,664 | +14.1% | +12.2% | 45.9% | 1.47 | 9.5% | 470 | 17d | `TP12|SL8|delay3|h≤30|10%|SW` | +| #12 | ¥+34,113 | ¥+42,664 | +14.1% | +12.2% | 45.9% | 1.47 | 9.5% | 470 | 17d | `TP12|SL8|trig≥1|delay3|h≤30|10%|SW` | +| #13 | ¥+24,766 | ¥+44,310 | +9.8% | +8.5% | 40.1% | 1.64 | 16.1% | 419 | 15d | `TP12|SL8|h≤60|15%|SW` | +| #14 | ¥+24,766 | ¥+44,310 | +9.8% | +8.5% | 40.1% | 1.64 | 16.1% | 419 | 15d | `TP12|SL8|delay1|h≤60|15%|SW` | +| #15 | ¥+24,766 | ¥+44,310 | +9.8% | +8.5% | 40.1% | 1.64 | 16.1% | 419 | 15d | `TP12|SL8|trig≥1|h≤60|15%|SW` | +| #16 | ¥+24,766 | ¥+44,310 | +9.8% | +8.5% | 40.1% | 1.64 | 16.1% | 419 | 15d | `TP12|SL8|trig≥1|delay1|h≤60|15%|SW` | +| #17 | ¥+24,481 | ¥+44,291 | +9.7% | +8.4% | 41.5% | 1.71 | 19.0% | 414 | 15d | `TP12|SL6|15%|SW` | +| #18 | ¥+24,481 | ¥+44,291 | +9.7% | +8.4% | 41.5% | 1.71 | 19.0% | 414 | 15d | `TP12|SL6|delay1|15%|SW` | +| #19 | ¥+24,481 | ¥+44,291 | +9.7% | +8.4% | 41.5% | 1.71 | 19.0% | 414 | 15d | `TP12|SL6|trig≥1|15%|SW` | +| #20 | ¥+24,481 | ¥+44,291 | +9.7% | +8.4% | 41.5% | 1.71 | 19.0% | 414 | 15d | `TP12|SL6|trig≥1|delay1|15%|SW` | +| #21 | ¥+24,300 | ¥+43,692 | +10.7% | +9.2% | 45.5% | 1.31 | 16.3% | 322 | 26d | `TP12|SL6|ign|h≤60|15%|SW` | +| #22 | ¥+24,300 | ¥+43,692 | +10.7% | +9.2% | 45.5% | 1.31 | 16.3% | 322 | 26d | `TP12|SL6|trig≥1|ign|h≤60|15%|SW` | +| #23 | ¥+23,782 | ¥+46,040 | +10.4% | +9.1% | 61.5% | 1.41 | 11.3% | 381 | 31d | `T8/3|SL12|ign|h≤60|8%|SW` | +| #24 | ¥+23,782 | ¥+46,040 | +10.4% | +9.1% | 61.5% | 1.41 | 11.3% | 381 | 31d | `T8/3|SL12|trig≥1|ign|h≤60|8%|SW` | +| #25 | ¥+22,348 | ¥+43,048 | +9.9% | +8.6% | 45.8% | 1.31 | 13.2% | 271 | 32d | `TP12|SL6|ign|15%|SW` | +| #26 | ¥+22,348 | ¥+43,048 | +9.9% | +8.6% | 45.8% | 1.31 | 13.2% | 271 | 32d | `TP12|SL6|trig≥1|ign|15%|SW` | +| #27 | ¥+17,570 | ¥+44,310 | +7.2% | +6.2% | 42.1% | 1.55 | 19.6% | 437 | 15d | `TP12|SL8|15%|SW` | +| #28 | ¥+17,570 | ¥+44,310 | +7.2% | +6.2% | 42.1% | 1.55 | 19.6% | 437 | 15d | `TP12|SL8|delay1|15%|SW` | +| #29 | ¥+17,570 | ¥+44,310 | +7.2% | +6.2% | 42.1% | 1.55 | 19.6% | 437 | 15d | `TP12|SL8|trig≥1|15%|SW` | +| #30 | ¥+17,570 | ¥+44,310 | +7.2% | +6.2% | 42.1% | 1.55 | 19.6% | 437 | 15d | `TP12|SL8|trig≥1|delay1|15%|SW` | + +## 新冠军 vs 之前冠军 + +| 指标 | 之前冠军 | 新冠军 | +|------|---------|-------| +| 策略 | `v5.2|忽略卖出+TP10+SL8+信号加权` | `TP12|SL6|delay3|h≤30|10%|SW` | +| 全期盈利 | ¥+56,375 | ¥+68,330.5 | +| 真实收益 | +21.5% | +25.6% | +| 年化 | +18.5% | +22.0% | +| 胜率 | 61.2% | 44.6% | +| 盈亏比 | 2.30 | 1.80 | +| 回撤 | - | 8.6% | + +🎉 **新纪录!** + +## 维度影响分析 + +### 仓位比例 + +| 仓位 | 数量 | 平均盈利 | 最优盈利 | +|------|------|---------|--------| +| 8% | 12 | ¥+15,474 | ¥+23,782 | +| 10% | 10 | ¥+24,178 | ¥+68,330 | +| 15% | 38 | ¥+22,253 | ¥+57,452 | + +### 信号加权 + +| 模式 | 数量 | 平均盈利 | 最优盈利 | +|------|------|---------|--------| +| 等权 | 12 | ¥+9,208 | ¥+12,809 | +| 加权 | 48 | ¥+24,220 | ¥+68,330 | + +### 止损线 + +| 止损 | 数量 | 平均盈利 | 最优盈利 | +|------|------|---------|--------| +| 6% | 16 | ¥+36,914 | ¥+68,330 | +| 8% | 14 | ¥+24,955 | ¥+48,554 | +| 10% | 18 | ¥+11,668 | ¥+17,066 | +| 12% | 12 | ¥+10,254 | ¥+23,782 | + diff --git a/stock-html/docs/backtest_2025_vs_2026_analysis.md b/stock-html/docs/backtest_2025_vs_2026_analysis.md new file mode 100644 index 0000000..90e4f11 --- /dev/null +++ b/stock-html/docs/backtest_2025_vs_2026_analysis.md @@ -0,0 +1,187 @@ +# 2025年 vs 2026年 行情对比分析 + +> 生成时间: 2026-02-25 +> 回测算法: Top 3 最挣钱算法 × 日线/5分钟定价 + +--- + +## 一、各季度利润分布(最优算法 v4|触发≥2+止盈10+损8) + +| 季度 | 盈亏(元) | 真实收益 | 胜率 | 交易数 | 盈亏比 | 回撤% | +|------|---------|---------|------|-------|--------|-------| +| 2025-Q1 | +195 | +4.8% | 33.3% | 6 | 1.83 | 5.7% | +| 2025-Q2 | 0 | 0% | - | 0 | - | - | +| 2025-Q3 | +5,415 | +4.2% | 39.5% | 76 | 1.46 | 4.4% | +| 2025-Q4 | +6,450 | +4.0% | 47.5% | 80 | 1.41 | 5.1% | +| **2026-Q1(32天)** | **+20,720** | **+15.5%** | **57.7%** | **52** | **4.02** | **2.8%** | + +> **亮点**: 2026年32天盈利(+20,720) > 2025年全年243天盈利(+20,600) + +--- + +## 二、2025全年 vs 2026开年 关键指标对比 + +| 指标 | 2025全年(243个交易日) | 2026开年(32个交易日) | 差异 | +|------|---------------------|---------------------|------| +| 总盈利 | +¥20,600 | **+¥20,720** | 32天 > 243天 ✨ | +| 真实收益率 | 13.4% | **15.5%** | +2.1% | +| 年化收益(CAGR) | 13.5% | **111.2%**(简单) | 效率差8倍 | +| 胜率 | 44.9% | **57.7%** | +12.8% | +| 盈亏比 | 1.73 | **4.02** | 2.3倍 | +| 最大回撤 | 4.3% | **2.8%** | 更安全 | +| 日均盈利 | ¥85/天 | **¥648/天** | **7.6倍** | +| 交易次数 | 156笔 | 52笔 | - | + +--- + +## 三、Top 3 算法全部数据 + +### 3.1 完整期间(2025-01-02 ~ 2026-02-25, 420天) + +| 算法 | 定价 | 盈亏(元) | 占用资金 | 真实收益 | 年化(CAGR) | 胜率 | 回撤% | 盈亏比 | 交易 | +|------|------|---------|---------|---------|-----------|------|-------|--------|------| +| 🏆 v4\|触发≥2+止盈10+损8 | 日线 | +34,285 | ¥153,490 | +22.3% | **+19.2%** | 46.5% | 4.3% | 1.98 | 202 | +| 🏆 v4\|触发≥2+止盈10+损8 | 5分钟 | +22,860 | ¥160,740 | +14.2% | **+12.3%** | 42.5% | 4.2% | 1.70 | 188 | +| 🥈 v3\|止盈10+损8 | 日线 | +18,575 | ¥158,655 | +11.7% | +10.1% | 40.0% | 5.0% | 1.60 | 210 | +| 🥈 v3\|止盈10+损8 | 5分钟 | +13,555 | ¥160,740 | +8.4% | +7.3% | 39.0% | 6.3% | 1.50 | 190 | +| 🥉 v4.2-K1\|延迟2天+止盈10+损8 | 日线 | +24,700 | ¥158,655 | +15.6% | +13.4% | 43.1% | 6.9% | 1.71 | 204 | +| 🥉 v4.2-K1\|延迟2天+止盈10+损8 | 5分钟 | +13,015 | ¥161,995 | +8.0% | +7.0% | 44.1% | 5.7% | 1.46 | 186 | + +### 3.2 仅2025年(2025-01-02 ~ 2025-12-31, 243个交易日) + +| 算法 | 定价 | 盈亏(元) | 占用资金 | 真实收益 | 年化(CAGR) | 胜率 | 回撤% | 盈亏比 | 交易 | +|------|------|---------|---------|---------|-----------|------|-------|--------|------| +| 🏆 v4\|触发≥2+止盈10+损8 | 日线 | +20,600 | ¥153,490 | +13.4% | +13.5% | 44.9% | 4.3% | 1.73 | 156 | +| 🏆 v4\|触发≥2+止盈10+损8 | 5分钟 | +9,765 | ¥160,740 | +6.1% | +6.1% | 43.1% | 4.2% | 1.38 | 144 | +| 🥈 v3\|止盈10+损8 | 日线 | +6,380 | ¥158,655 | +4.0% | +4.0% | 36.2% | 5.0% | 1.23 | 160 | +| 🥈 v3\|止盈10+损8 | 5分钟 | +2,230 | ¥160,740 | +1.4% | +1.4% | 37.3% | 6.3% | 1.10 | 150 | +| 🥉 v4.2-K1\|延迟2天+止盈10+损8 | 日线 | +7,280 | ¥158,655 | +4.6% | +4.6% | 38.5% | 6.9% | 1.23 | 156 | +| 🥉 v4.2-K1\|延迟2天+止盈10+损8 | 5分钟 | +7,515 | ¥161,995 | +4.6% | +4.7% | 41.9% | 5.7% | 1.31 | 148 | + +### 3.3 仅2026年(2026-01-05 ~ 2026-02-25, 32个交易日) + +| 算法 | 定价 | 盈亏(元) | 占用资金 | 真实收益 | 年化(简单) | 胜率 | 回撤% | 盈亏比 | 交易 | +|------|------|---------|---------|---------|-----------|------|-------|--------|------| +| 🏆 v4\|触发≥2+止盈10+损8 | 日线 | +20,720 | ¥133,345 | +15.5% | +111.2% | 57.7% | 2.8% | 4.02 | 52 | +| 🏆 v4\|触发≥2+止盈10+损8 | 5分钟 | +14,230 | ¥131,700 | +10.8% | +77.3% | 48.0% | 3.0% | 3.00 | 50 | +| 🥈 v3\|止盈10+损8 | 日线 | +21,590 | ¥136,470 | +15.8% | +113.2% | 60.7% | 2.6% | 6.03 | 56 | +| 🥈 v3\|止盈10+损8 | 5分钟 | +13,290 | ¥139,570 | +9.5% | +68.2% | 56.0% | 3.2% | 4.02 | 50 | +| 🥉 v4.2-K1\|延迟2天+止盈10+损8 | 日线 | **+23,340** | ¥136,470 | **+17.1%** | **+122.4%** | **65.4%** | 3.2% | **7.72** | 52 | +| 🥉 v4.2-K1\|延迟2天+止盈10+损8 | 5分钟 | **+16,835** | ¥136,500 | **+12.3%** | **+88.3%** | **65.2%** | 3.2% | **6.23** | 46 | + +> **注意**: 2026年仅32个交易日(约51天),使用**简单年化**(收益率×365/天数),数值偏高仅供参考 + +--- + +## 四、月度市场表现(全市场平均日涨跌幅) + +| 月份 | 平均日涨跌% | 趋势 | 平均收盘价 | 覆盖股票数 | +|------|-----------|------|-----------|-----------| +| 2025-01 | +0.30% | 🟢 | ¥3.97 | 26 | +| 2025-02 | +0.42% | 🟢 | ¥3.60 | 25 | +| 2025-03 | +0.40% | 🟢 | ¥3.21 | 28 | +| 2025-04 | +0.45% | 🟢 | ¥4.08 | 31 | +| 2025-05 | +0.13% | 🟢 | ¥19.98 | 5,142 | +| 2025-06 | +0.37% | 🟢 | ¥20.81 | 5,156 | +| 2025-07 | +0.24% | 🟢 | ¥22.19 | 5,425 | +| 2025-08 | +0.36% | 🟢 | ¥24.74 | 5,427 | +| 2025-09 | +0.04% | 🟢 | ¥26.02 | 5,438 | +| 2025-10 | +0.23% | 🟢 | ¥26.13 | 5,444 | +| 2025-11 | +0.02% | 🟢 | ¥26.03 | 5,454 | +| 2025-12 | +0.11% | 🟢 | ¥26.30 | 5,472 | +| **2026-01** | **+0.40%** | 🟢 | **¥29.38** | 5,478 | +| **2026-02** | **+0.15%** | 🟢 | **¥29.50** | 5,484 | + +--- + +## 五、季度市场表现 + +| 季度 | 交易日 | 日均涨跌% | 趋势 | 波动率 | +|------|-------|----------|------|--------| +| 2025-Q1 | 57 | +0.370% | 🟢 强 | 4.066 | +| 2025-Q2 | 60 | +0.336% | 🟢 中 | 2.536 | +| 2025-Q3 | 66 | +0.212% | 🟢 弱 | 2.612 | +| 2025-Q4 | 60 | +0.115% | 🟢 最弱 | 2.671 | +| **2026-Q1(至今)** | **32** | **+0.304%** | **🟢 强** | **2.823** | + +--- + +## 六、扫描信号分布对比 + +| 年份 | 总信号 | 买入信号 | 买入占比 | 强买(≥2触发) | 卖出信号 | 卖出占比 | +|------|-------|---------|---------|-------------|---------|---------| +| 2025 | 1,429,106 | 3,601 | 0.3% | 1,151 | 674,076 | **47.2%** | +| 2026 | 369,614 | 682 | 0.2% | 218 | 148,141 | **40.1%** | + +> 2026年卖出信号占比下降7.1个百分点 → 市场做多环境改善 + +--- + +## 七、为什么2026年开年行情远好于2025年? + +### 1. 🟢 市场整体回暖 + +2025年Q4是全年最弱季度(日均+0.115%),经过调整后,2026年1月日均涨幅反弹至 **+0.400%**,是Q4的 **3.5倍**。这是典型的**春季行情**特征: + +- 年初资金回流 +- 政策利好预期(两会前) +- 前期调整充分,估值修复 + +### 2. 📈 胜率暴增 12.8% + +| 指标 | 2025全年 | 2026年 | +|------|---------|--------| +| 胜率 | 44.9% | **57.7%** | + +当市场整体趋势向上时,买入信号本身的成功概率天然更高。这不是算法改变了,而是**市场环境配合了算法**。 + +### 3. 📊 盈亏比从 1.73 飙升到 4.02 + +- 赚钱时赚得更多(趋势延续性好,更容易触发止盈) +- 亏钱时亏得更少(回调幅度小,止损线更不容易被触及) + +### 4. 🔻 卖出信号减少 → 做多空间更大 + +- 2025年:47.2% 信号是卖出 → 市场偏空 +- 2026年:40.1% 信号是卖出 → 做多环境明显改善 + +### 5. 💡 延迟卖出策略在2026年最优 + +2026年 v4.2-K1(延迟2天卖出)成为最挣钱算法: +- +23,340(17.1%)vs 2025年仅 +7,280(4.6%) +- 说明2026年趋势更强,延迟卖出可以捕获更多上涨空间 + +--- + +## 八、结论与展望 + +### ✅ 核心结论 + +1. **2026年开年行情确实远好于2025年**,32天盈利超过2025全年 +2. 同一算法在不同市场环境下表现差异巨大:牛市年化100%+ vs 熊市年化4% +3. 算法的核心价值不在于"预测涨跌",而在于**在好行情中放大收益,在差行情中控制亏损** + +### 📊 算法表现的市场敏感度 + +| 市场环境 | 代表期间 | 最优算法年化 | 胜率 | +|---------|---------|------------|------| +| 强趋势(日均>+0.3%) | 2026-Q1 | **111%+** | 57%+ | +| 中等趋势(日均+0.2-0.3%) | 2025-Q3 | ~10% | ~40% | +| 弱势/震荡(日均<+0.2%) | 2025-Q4 | ~4% | ~47% | + +### 🔮 2026全年展望 + +- 如果维持Q1水平 → 年化可达 **80-120%** +- 如果回落到2025平均水平 → 年化约 **15-20%** +- **保守预期**: 全年年化 **20-30%**(仍是银行存款2.5%的 8-12倍) + +--- + +## 附录:年化计算方法说明 + +| 回测天数 | 年化方法 | 公式 | +|---------|---------|------| +| < 90天 | **简单年化** | `收益率 × 365 / 天数` | +| ≥ 90天 | **复利年化(CAGR)** | `(1 + 收益率)^(365/天数) - 1` | + +> 短期(<90天)使用简单年化避免复利效应过度放大;长期(≥90天)使用CAGR更准确反映实际复利增长。 diff --git a/stock-html/docs/backtest_comparison.md b/stock-html/docs/backtest_comparison.md new file mode 100644 index 0000000..b257c56 --- /dev/null +++ b/stock-html/docs/backtest_comparison.md @@ -0,0 +1,42 @@ +# 回测场景对比 v4.2(真实资金收益率版) + +回测区间: 2025-01-02 ~ 2026-02-25 + +> ⚠️ **真实收益率** = 盈亏 / 最大同时占用资金(非总周转金额) + +## 对比结果 + +| 场景 | 盈亏(元) | 占用资金 | 真实收益 | 真实年化 | 胜率 | 回撤% | 持仓天 | 盈亏比 | 交易 | 标注 | +|------|---------|---------|---------|---------|------|-------|--------|--------|------|------| +| v3|仅信号 | +6,310 | ¥155,430 | +4.1% | +3.5% | 40.5% | 6.4% | 20天 | 1.22 | 178 | | +| v3|止盈10+损8 | +49,500 | ¥174,120 | +28.4% | +24.4% | 47.8% | 3.3% | 14天 | 2.65 | 222 | 🛡️回撤最小 | +| v4|触发≥2+止盈10+损8 | +53,100 | ¥160,410 | +33.1% | +28.3% | 47.2% | 3.8% | 13天 | 2.42 | 212 | 🏆收益最高 | +| v4|触发≥2+跟踪6-3+损5 | +22,250 | ¥153,260 | +14.5% | +12.5% | 43.5% | 4.3% | 12天 | 1.65 | 216 | | +| v4.1|忽略卖出+止盈10+损8 | +23,670 | ¥130,180 | +18.2% | +15.7% | 57.9% | 6.0% | 31天 | 2.09 | 118 | 🎯胜率最高 | +| v4.1|忽略+跟踪8-3+损5+20天 | +33,170 | ¥152,540 | +21.8% | +18.7% | 49.6% | 4.8% | 13天 | 1.98 | 243 | | +| v4.2-K1|延迟2天+止盈10+损8 | +48,920 | ¥174,120 | +28.1% | +24.1% | 50.0% | 3.3% | 16天 | 2.78 | 208 | ⚖️盈亏比最佳 | +| v4.2-K2|延迟2天+触发≥2+止盈10+损8 | +35,260 | ¥160,950 | +21.9% | +18.8% | 47.5% | 4.0% | 14天 | 1.93 | 202 | | +| v4.2-K3|延迟2天+跟踪8-3+损5 | -550 | ¥158,690 | -0.3% | -0.3% | 41.1% | 10.9% | 14天 | 0.99 | 224 | | +| v4.2-K4|延迟2天+跟踪6-3+损5 | +10,660 | ¥158,690 | +6.7% | +5.8% | 49.1% | 6.8% | 14天 | 1.32 | 220 | | +| v4.2-K5|延迟2天+触发≥2+跟踪8-3+损5 | +21,420 | ¥153,260 | +14.0% | +12.1% | 42.9% | 4.6% | 13天 | 1.52 | 210 | | +| v4.2-K6|延迟2天+触发≥2+跟踪6-3+损8 | +22,470 | ¥155,140 | +14.5% | +12.5% | 52.0% | 5.5% | 14天 | 1.68 | 196 | | +| v4.2-L1|延迟3天+止盈10+损8 | +20,020 | ¥136,730 | +14.6% | +12.6% | 48.9% | 7.8% | 18天 | 1.66 | 180 | | +| v4.2-L2|延迟3天+触发≥2+止盈10+损8 | +36,940 | ¥160,950 | +22.9% | +19.7% | 49.5% | 4.2% | 16天 | 2.02 | 186 | | +| v4.2-L3|延迟3天+跟踪8-3+损5 | +1,680 | ¥136,840 | +1.2% | +1.1% | 44.4% | 14.4% | 17天 | 1.04 | 198 | | +| v4.2-L4|延迟3天+触发≥2+跟踪6-3+损5 | +30,540 | ¥153,260 | +19.9% | +17.1% | 50.0% | 7.3% | 13天 | 1.89 | 208 | | +| v4.2-M1|延迟2天+盈保5%+止盈10+损8 | +44,330 | ¥174,120 | +25.5% | +21.9% | 49.0% | 3.3% | 16天 | 2.55 | 208 | | +| v4.2-M2|延迟2天+盈保5%+触发≥2+止盈10+损8 | +24,860 | ¥160,950 | +15.4% | +13.3% | 46.5% | 4.0% | 14天 | 1.67 | 198 | | +| v4.2-M3|延迟3天+跟踪8-3+损5+30天 | +7,850 | ¥153,260 | +5.1% | +4.5% | 50.0% | 8.3% | 14天 | 1.20 | 228 | | +| v4.2-M4|延迟2天+触发≥2+跟踪8-3+损5+30天 | +25,020 | ¥153,260 | +16.3% | +14.1% | 45.8% | 4.5% | 12天 | 1.65 | 214 | | + +## 指标说明 + +| 指标 | 说明 | +|------|------| +| 占用资金 | 回测期间最大同时持仓成本 | +| 真实收益 | 盈亏 / 最大占用资金 × 100% | +| 真实年化 | 按持续期折算年化(复利公式) | +| 回撤% | 最大回撤 / 最大占用资金 × 100% | +| 盈亏比 | 总盈利金额 / 总亏损金额 | +| 延迟N天 | 连续N天推荐卖出才执行卖出 | + diff --git a/stock-html/docs/backtest_quarterly_200k.md b/stock-html/docs/backtest_quarterly_200k.md new file mode 100644 index 0000000..28a06e3 --- /dev/null +++ b/stock-html/docs/backtest_quarterly_200k.md @@ -0,0 +1,195 @@ +# 💰 20万本金 × 按季度投资 × 多算法对比回测 + +> 生成时间: 2026-02-25 21:09 + +## 回测配置 (v5.2 动态仓位) + +| 参数 | 值 | +|------|----| +| 本金 | ¥200,000 (唯一约束) | +| 单只上限 | 无(受总资金约束) | +| 最大持仓 | 无(受总资金约束) | +| 每笔仓位 | 动态: 总资金×5% = ¥10,000/笔 | +| 每笔股数 | 动态(根据股价自动计算,取整到100股) | +| 股价区间 | 无 | +| 每日最多买入 | 无 | +| 冷却期 | 3天 | +| 年化方法 | <90天用简单(S),≥90天用复利CAGR(C) | + +## 算法说明 + +| # | 算法 | 参数说明 | +|---|------|--------| +| 1 | v3|基线(TP10+SL8) | take_profit_pct=10, stop_loss_pct=8 | +| 2 | v4|触发≥2+TP10+SL8 | min_buy_triggered=2, take_profit_pct=10, stop_loss_pct=8 | +| 3 | v4.2|延迟2天+TP10+SL8 | sell_confirm_days=2, take_profit_pct=10, stop_loss_pct=8 | +| 4 | v4|忽略卖出+TP10+SL8 | ignore_sell_signal=True, take_profit_pct=10, stop_loss_pct=8 | +| 5 | v4|触发≥2+TP15+SL8 | min_buy_triggered=2, take_profit_pct=15, stop_loss_pct=8 | +| 6 | v4|触发≥2+TP10+SL5 | min_buy_triggered=2, take_profit_pct=10, stop_loss_pct=5 | +| 7 | v4.2|延迟2天+触发≥2+TP10+SL8 | sell_confirm_days=2, min_buy_triggered=2, take_profit_pct=10, stop_loss_pct=8 | +| 8 | v4.1|跟踪止盈8/3+SL5 | ignore_sell_signal=True, trailing_start_pct=8, trailing_gap_pct=3, stop_loss_pct=5 | +| 9 | v4.1|跟踪止盈8/3+触发≥2+SL5 | ignore_sell_signal=True, trailing_start_pct=8, trailing_gap_pct=3, stop_loss_pct=5, min_buy_triggered=2 | +| 10 | v4|触发≥2+TP10+SL8+持仓≤30天 | min_buy_triggered=2, take_profit_pct=10, stop_loss_pct=8, max_hold_days=30 | +| 11 | v5.2|延迟2天+触发≥2+TP10+SL8+信号加权 | sell_confirm_days=2, min_buy_triggered=2, take_profit_pct=10, stop_loss_pct=8, signal_weight=True | +| 12 | v5.2|忽略卖出+TP10+SL8+信号加权 | ignore_sell_signal=True, take_profit_pct=10, stop_loss_pct=8, signal_weight=True | +| 13 | v5.2|跟踪止盈8/3+SL5+信号加权 | ignore_sell_signal=True, trailing_start_pct=8, trailing_gap_pct=3, stop_loss_pct=5, signal_weight=True | + +## 一、盈亏对比(元) + +| 算法 | 2025-Q1 | 2025-Q2 | 2025-Q3 | 2025-Q4 | 2026-Q1 | 全期间 | +|------|--------|--------|--------|--------|--------|--------| +| v3|基线(TP10+SL8) | -5,413 | +726 | +8,865 | -23,015 | +19,566 | +10,128 | +| v4|触发≥2+TP10+SL8 | +235 | +0 | +9,380 | +3,954 | +15,138 | +38,214 | +| v4.2|延迟2天+TP10+SL8 | -7,253 | +726 | +8,510 | -14,404 | +20,594 | +24,818 | +| v4|忽略卖出+TP10+SL8 | -7,469 | +726 | +18,660 | -12,454 | +21,573 | +46,303 | +| v4|触发≥2+TP15+SL8 | +235 | +0 | +7,064 | +95 | +13,126 | +31,840 | +| v4|触发≥2+TP10+SL5 | +235 | +0 | +4,319 | +3,500 | +9,442 | +28,637 | +| v4.2|延迟2天+触发≥2+TP10+SL8 | -1,605 | +0 | +12,402 | +8 | +13,774 | +34,754 | +| v4.1|跟踪止盈8/3+SL5 | -4,934 | +726 | +4,990 | -4,034 | +13,361 | -18,546 | +| v4.1|跟踪止盈8/3+触发≥2+SL5 | -2,609 | +0 | +20,002 | +2,733 | +7,244 | +43,784 | +| v4|触发≥2+TP10+SL8+持仓≤30天 | +235 | +0 | +8,976 | +4,491 | +15,318 | +36,754 | +| v5.2|延迟2天+触发≥2+TP10+SL8+信号加权 | -2,010 | +0 | +17,160 | -713 | +22,334 | +44,039 | +| v5.2|忽略卖出+TP10+SL8+信号加权 | -8,369 | +726 | +29,224 | -16,560 | +20,517 | +56,375 | +| v5.2|跟踪止盈8/3+SL5+信号加权 | -5,591 | +726 | +12,802 | -6,054 | +10,585 | -14,492 | + +## 二、真实收益率(%) + +| 算法 | 2025-Q1 | 2025-Q2 | 2025-Q3 | 2025-Q4 | 2026-Q1 | 全期间 | +|------|--------|--------|--------|--------|--------|--------| +| v3|基线(TP10+SL8) | -18.2% | +3.6% | +4.2% | -11.2% | +9.0% | +4.4% | +| v4|触发≥2+TP10+SL8 | +2.4% | +0.0% | +5.4% | +1.9% | +7.8% | +17.2% | +| v4.2|延迟2天+TP10+SL8 | -24.4% | +3.6% | +4.0% | -7.0% | +9.6% | +10.5% | +| v4|忽略卖出+TP10+SL8 | -25.1% | +3.6% | +8.2% | -5.9% | +9.7% | +18.5% | +| v4|触发≥2+TP15+SL8 | +2.4% | +0.0% | +3.9% | +0.1% | +6.3% | +14.8% | +| v4|触发≥2+TP10+SL5 | +2.4% | +0.0% | +2.8% | +1.7% | +5.2% | +13.2% | +| v4.2|延迟2天+触发≥2+TP10+SL8 | -16.1% | +0.0% | +6.8% | +0.0% | +6.5% | +15.4% | +| v4.1|跟踪止盈8/3+SL5 | -16.6% | +3.6% | +2.3% | -2.0% | +6.4% | -8.9% | +| v4.1|跟踪止盈8/3+触发≥2+SL5 | -26.2% | +0.0% | +10.2% | +1.4% | +3.5% | +18.1% | +| v4|触发≥2+TP10+SL8+持仓≤30天 | +2.4% | +0.0% | +5.2% | +2.2% | +7.9% | +16.5% | +| v5.2|延迟2天+触发≥2+TP10+SL8+信号加权 | -13.6% | +0.0% | +8.6% | -0.3% | +10.7% | +19.1% | +| v5.2|忽略卖出+TP10+SL8+信号加权 | -26.4% | +3.6% | +12.5% | -7.8% | +9.3% | +21.5% | +| v5.2|跟踪止盈8/3+SL5+信号加权 | -18.9% | +3.6% | +5.8% | -3.0% | +5.1% | -6.8% | + +## 三、年化收益率(%) + +> (S)=简单年化(<90天),(C)=复利CAGR(≥90天) + +| 算法 | 2025-Q1 | 2025-Q2 | 2025-Q3 | 2025-Q4 | 2026-Q1 | 全期间 | +|------|--------|--------|--------|--------|--------|--------| +| v3|基线(TP10+SL8) | -75.6%(S) | +15.6%(C) | +17.9%(C) | -37.9%(C) | +64.5%(S) | +3.9%(C) | +| v4|触发≥2+TP10+SL8 | +9.8%(S) | +0.0%(C) | +23.6%(C) | +8.0%(C) | +56.1%(S) | +14.8%(C) | +| v4.2|延迟2天+TP10+SL8 | -101.3%(S) | +15.6%(C) | +17.1%(C) | -25.3%(C) | +68.7%(S) | +9.1%(C) | +| v4|忽略卖出+TP10+SL8 | -104.3%(S) | +15.6%(C) | +37.1%(C) | -21.7%(C) | +69.7%(S) | +15.9%(C) | +| v4|触发≥2+TP15+SL8 | +9.8%(S) | +0.0%(C) | +16.5%(C) | +0.2%(C) | +45.1%(S) | +12.8%(C) | +| v4|触发≥2+TP10+SL5 | +9.8%(S) | +0.0%(C) | +11.7%(C) | +7.1%(C) | +37.5%(S) | +11.4%(C) | +| v4.2|延迟2天+触发≥2+TP10+SL8 | -66.9%(S) | +0.0%(C) | +30.1%(C) | +0.0%(C) | +46.7%(S) | +13.3%(C) | +| v4.1|跟踪止盈8/3+SL5 | -69.0%(S) | +15.6%(C) | +9.6%(C) | -7.8%(C) | +45.6%(S) | -7.8%(C) | +| v4.1|跟踪止盈8/3+触发≥2+SL5 | -108.8%(S) | +0.0%(C) | +47.6%(C) | +5.5%(C) | +25.3%(S) | +15.6%(C) | +| v4|触发≥2+TP10+SL8+持仓≤30天 | +9.8%(S) | +0.0%(C) | +22.6%(C) | +9.2%(C) | +56.8%(S) | +14.2%(C) | +| v5.2|延迟2天+触发≥2+TP10+SL8+信号加权 | -56.4%(S) | +0.0%(C) | +39.1%(C) | -1.4%(C) | +76.2%(S) | +16.4%(C) | +| v5.2|忽略卖出+TP10+SL8+信号加权 | -109.6%(S) | +15.6%(C) | +60.4%(C) | -27.9%(C) | +66.4%(S) | +18.5%(C) | +| v5.2|跟踪止盈8/3+SL5+信号加权 | -78.2%(S) | +15.6%(C) | +25.3%(C) | -11.4%(C) | +36.2%(S) | -5.9%(C) | + +## 四、胜率(%) + +| 算法 | 2025-Q1 | 2025-Q2 | 2025-Q3 | 2025-Q4 | 2026-Q1 | 全期间 | +|------|--------|--------|--------|--------|--------|--------| +| v3|基线(TP10+SL8) | 11.1% | 100.0% | 42.4% | 41.9% | 53.1% | 45.0% | +| v4|触发≥2+TP10+SL8 | 25.0% | 0.0% | 42.6% | 44.8% | 49.3% | 44.9% | +| v4.2|延迟2天+TP10+SL8 | 11.1% | 100.0% | 45.5% | 40.8% | 56.7% | 47.8% | +| v4|忽略卖出+TP10+SL8 | 11.1% | 100.0% | 53.1% | 40.7% | 67.1% | 59.8% | +| v4|触发≥2+TP15+SL8 | 25.0% | 0.0% | 42.6% | 38.2% | 45.5% | 41.2% | +| v4|触发≥2+TP10+SL5 | 25.0% | 0.0% | 38.2% | 41.7% | 44.4% | 41.2% | +| v4.2|延迟2天+触发≥2+TP10+SL8 | 25.0% | 0.0% | 47.1% | 43.2% | 50.8% | 44.3% | +| v4.1|跟踪止盈8/3+SL5 | 30.0% | 100.0% | 46.8% | 39.8% | 61.2% | 43.3% | +| v4.1|跟踪止盈8/3+触发≥2+SL5 | 25.0% | 0.0% | 54.7% | 45.1% | 48.4% | 48.5% | +| v4|触发≥2+TP10+SL8+持仓≤30天 | 25.0% | 0.0% | 42.6% | 44.6% | 49.3% | 46.1% | +| v5.2|延迟2天+触发≥2+TP10+SL8+信号加权 | 25.0% | 0.0% | 47.0% | 43.3% | 52.5% | 44.8% | +| v5.2|忽略卖出+TP10+SL8+信号加权 | 11.1% | 100.0% | 57.5% | 42.9% | 66.1% | 61.2% | +| v5.2|跟踪止盈8/3+SL5+信号加权 | 30.0% | 100.0% | 45.6% | 39.6% | 56.2% | 44.2% | + +## 五、盈亏比 + +| 算法 | 2025-Q1 | 2025-Q2 | 2025-Q3 | 2025-Q4 | 2026-Q1 | 全期间 | +|------|--------|--------|--------|--------|--------|--------| +| v3|基线(TP10+SL8) | 0.26 | 999.99 | 1.36 | 1.14 | 3.16 | 1.43 | +| v4|触发≥2+TP10+SL8 | 1.15 | 999.99 | 1.72 | 1.14 | 2.12 | 1.66 | +| v4.2|延迟2天+TP10+SL8 | 0.21 | 999.99 | 1.32 | 1.23 | 3.25 | 1.55 | +| v4|忽略卖出+TP10+SL8 | 0.20 | 999.99 | 2.22 | 1.27 | 4.53 | 2.16 | +| v4|触发≥2+TP15+SL8 | 1.15 | 999.99 | 1.54 | 1.00 | 2.02 | 1.58 | +| v4|触发≥2+TP10+SL5 | 1.15 | 999.99 | 1.30 | 1.12 | 1.64 | 1.48 | +| v4.2|延迟2天+触发≥2+TP10+SL8 | 0.54 | 999.99 | 1.93 | 1.00 | 1.91 | 1.56 | +| v4.1|跟踪止盈8/3+SL5 | 0.35 | 999.99 | 1.20 | 0.87 | 2.48 | 1.03 | +| v4.1|跟踪止盈8/3+触发≥2+SL5 | 0.27 | 999.99 | 2.51 | 1.12 | 1.54 | 1.76 | +| v4|触发≥2+TP10+SL8+持仓≤30天 | 1.15 | 999.99 | 1.69 | 1.16 | 2.14 | 1.63 | +| v5.2|延迟2天+触发≥2+TP10+SL8+信号加权 | 0.53 | 999.99 | 2.07 | 0.98 | 2.87 | 1.62 | +| v5.2|忽略卖出+TP10+SL8+信号加权 | 0.19 | 999.99 | 3.29 | 1.19 | 4.08 | 2.30 | +| v5.2|跟踪止盈8/3+SL5+信号加权 | 0.34 | 999.99 | 1.69 | 0.82 | 1.94 | 1.10 | + +## 六、最大占用资金(元) + +| 算法 | 2025-Q1 | 2025-Q2 | 2025-Q3 | 2025-Q4 | 2026-Q1 | 全期间 | +|------|--------|--------|--------|--------|--------|--------| +| v3|基线(TP10+SL8) | ¥29,692 | ¥19,956 | ¥210,978 | ¥205,770 | ¥217,079 | ¥228,420 | +| v4|触发≥2+TP10+SL8 | ¥9,950 | ¥0 | ¥172,570 | ¥203,834 | ¥193,078 | ¥222,459 | +| v4.2|延迟2天+TP10+SL8 | ¥29,692 | ¥19,956 | ¥211,691 | ¥205,436 | ¥214,508 | ¥235,524 | +| v4|忽略卖出+TP10+SL8 | ¥29,692 | ¥19,956 | ¥227,716 | ¥210,810 | ¥221,473 | ¥250,123 | +| v4|触发≥2+TP15+SL8 | ¥9,950 | ¥0 | ¥181,564 | ¥200,470 | ¥208,478 | ¥215,200 | +| v4|触发≥2+TP10+SL5 | ¥9,950 | ¥0 | ¥154,230 | ¥202,104 | ¥180,278 | ¥217,594 | +| v4.2|延迟2天+触发≥2+TP10+SL8 | ¥9,950 | ¥0 | ¥182,934 | ¥201,774 | ¥211,020 | ¥226,260 | +| v4.1|跟踪止盈8/3+SL5 | ¥29,668 | ¥19,956 | ¥215,702 | ¥202,549 | ¥209,646 | ¥208,376 | +| v4.1|跟踪止盈8/3+触发≥2+SL5 | ¥9,950 | ¥0 | ¥196,167 | ¥202,462 | ¥204,924 | ¥241,644 | +| v4|触发≥2+TP10+SL8+持仓≤30天 | ¥9,950 | ¥0 | ¥172,570 | ¥203,454 | ¥193,078 | ¥222,686 | +| v5.2|延迟2天+触发≥2+TP10+SL8+信号加权 | ¥14,778 | ¥0 | ¥200,032 | ¥204,190 | ¥209,762 | ¥230,860 | +| v5.2|忽略卖出+TP10+SL8+信号加权 | ¥31,682 | ¥19,956 | ¥233,843 | ¥211,248 | ¥221,145 | ¥262,252 | +| v5.2|跟踪止盈8/3+SL5+信号加权 | ¥29,668 | ¥19,956 | ¥221,058 | ¥203,087 | ¥209,122 | ¥214,326 | + +## 七、🏆 各季度最优算法 + +| 季度 | 最优算法 | 盈利(元) | 真实收益 | 年化 | 胜率 | 盈亏比 | +|------|---------|---------|---------|------|------|--------| +| 2025-Q1 | **v4|触发≥2+TP10+SL8** | +235 | +2.4% | +9.8%(S) | 25.0% | 1.15 | +| 2025-Q2 | **v3|基线(TP10+SL8)** | +726 | +3.6% | +15.6%(C) | 100.0% | 999.99 | +| 2025-Q3 | **v5.2|忽略卖出+TP10+SL8+信号加权** | +29,224 | +12.5% | +60.4%(C) | 57.5% | 3.29 | +| 2025-Q4 | **v4|触发≥2+TP10+SL8+持仓≤30天** | +4,491 | +2.2% | +9.2%(C) | 44.6% | 1.16 | +| 2026-Q1 | **v5.2|延迟2天+触发≥2+TP10+SL8+信号加权** | +22,334 | +10.7% | +76.2%(S) | 52.5% | 2.87 | +| 全期间 | **v5.2|忽略卖出+TP10+SL8+信号加权** | +56,375 | +21.5% | +18.5%(C) | 61.2% | 2.30 | + +## 八、算法全期间总收益排名 + +| 排名 | 算法 | 全期间盈利 | 真实收益 | 年化(CAGR) | 胜率 | 盈亏比 | 最大回撤 | 占用资金 | +|------|------|----------|---------|-----------|------|--------|---------|--------| +| 🏆 | v5.2|忽略卖出+TP10+SL8+信号加权 | +56,375 | +21.5% | +18.5% | 61.2% | 2.30 | 7.9% | ¥262,252 | +| 🥈 | v4|忽略卖出+TP10+SL8 | +46,303 | +18.5% | +15.9% | 59.8% | 2.16 | 7.7% | ¥250,123 | +| 🥉 | v5.2|延迟2天+触发≥2+TP10+SL8+信号加权 | +44,039 | +19.1% | +16.4% | 44.8% | 1.62 | 8.4% | ¥230,860 | +| #4 | v4.1|跟踪止盈8/3+触发≥2+SL5 | +43,784 | +18.1% | +15.6% | 48.5% | 1.76 | 5.0% | ¥241,644 | +| #5 | v4|触发≥2+TP10+SL8 | +38,214 | +17.2% | +14.8% | 44.9% | 1.66 | 4.9% | ¥222,459 | +| #6 | v4|触发≥2+TP10+SL8+持仓≤30天 | +36,754 | +16.5% | +14.2% | 46.1% | 1.63 | 4.5% | ¥222,686 | +| #7 | v4.2|延迟2天+触发≥2+TP10+SL8 | +34,754 | +15.4% | +13.3% | 44.3% | 1.56 | 7.0% | ¥226,260 | +| #8 | v4|触发≥2+TP15+SL8 | +31,840 | +14.8% | +12.8% | 41.2% | 1.58 | 4.9% | ¥215,200 | +| #9 | v4|触发≥2+TP10+SL5 | +28,637 | +13.2% | +11.4% | 41.2% | 1.48 | 5.0% | ¥217,594 | +| #10 | v4.2|延迟2天+TP10+SL8 | +24,818 | +10.5% | +9.1% | 47.8% | 1.55 | 10.5% | ¥235,524 | +| #11 | v3|基线(TP10+SL8) | +10,128 | +4.4% | +3.9% | 45.0% | 1.43 | 15.3% | ¥228,420 | +| #12 | v5.2|跟踪止盈8/3+SL5+信号加权 | -14,492 | -6.8% | -5.9% | 44.2% | 1.10 | 20.7% | ¥214,326 | +| #13 | v4.1|跟踪止盈8/3+SL5 | -18,546 | -8.9% | -7.8% | 43.3% | 1.03 | 21.9% | ¥208,376 | + +## 九、分析结论 + +### 🏆 全期间最优算法: v5.2|忽略卖出+TP10+SL8+信号加权 + +- 总盈利: **¥+56,375** +- 真实收益率: **+21.5%** +- 年化收益率: **+18.5%** +- 胜率: **61.2%** +- 盈亏比: **2.30** +- 最大回撤: **7.9%** +- 最大占用资金: **¥262,252**(131%本金利用率) + +### 回报对比 + +| 投资方式 | 年化收益 | 20万本金一年收益 | +|---------|---------|----------------| +| 银行定存 | 2.5% | ¥5,000 | +| 余额宝 | 1.8% | ¥3,600 | +| **本算法** | **+18.5%** | **¥+56,375**(实际) | + diff --git a/stock-html/docs/backtest_result.json b/stock-html/docs/backtest_result.json new file mode 100644 index 0000000..0685f1e --- /dev/null +++ b/stock-html/docs/backtest_result.json @@ -0,0 +1,1274 @@ +{ + "start": "2026-01-02", + "end": "2026-02-26", + "version": "v5", + "params": { + "take_profit_pct": 12.0, + "stop_loss_pct": 6.0, + "min_buy_rate": 0, + "min_buy_triggered": 0, + "profit_protect_pct": 0, + "sell_confirm_rate": 0, + "trailing_start_pct": 0, + "trailing_gap_pct": 0, + "ignore_sell_signal": true, + "max_hold_days": 30, + "sell_confirm_days": 3, + "use_5min_prices": true, + "shares_per_trade": 1000, + "position_amount": 30000, + "max_concurrent": 8, + "price_range": [ + 2.0, + 100.0 + ], + "cooldown_days": 3 + }, + "elapsed_seconds": 7.16, + "stats": { + "total_in": 613587.5, + "total_out": 637160.0, + "profit": 23572.5, + "profit_pct": 3.84, + "annualized_pct": 25.5, + "annualized_method": "simple", + "max_capital": 223192.0, + "capital_pct": 10.56, + "capital_ann_pct": 70.09, + "capital_ann_method": "simple", + "trade_count": 93, + "closed_count": 46, + "wins": 27, + "losses": 15, + "flat": 4, + "win_rate": 58.7, + "avg_hold_days": 15.6, + "max_drawdown": 10405.5, + "max_drawdown_pct": 4.66, + "avg_win": 1165.7, + "avg_loss": -510.77, + "profit_factor": 4.11, + "days": 55, + "5min_hit": 66, + "5min_miss": 185, + "5min_coverage": 26.3, + "total_capital": 200000.0, + "final_cash": 223572.5, + "capital_utilization": 111.6, + "dynamic_position": true, + "position_pct": 10.0, + "signal_weight": true, + "buy_time": "09:35", + "sell_time": "13:40" + }, + "stock_pnl": { + "000516": { + "profit": 4600.0, + "trades": 1, + "wins": 1 + }, + "688663": { + "profit": 3047.5, + "trades": 1, + "wins": 1 + }, + "002859": { + "profit": 4056.0, + "trades": 1, + "wins": 1 + }, + "600325": { + "profit": -27.999999999999943, + "trades": 1, + "wins": 0 + }, + "300344": { + "profit": 18.499999999999986, + "trades": 1, + "wins": 1 + }, + "002679": { + "profit": 516.0, + "trades": 1, + "wins": 1 + }, + "301307": { + "profit": -2036.0, + "trades": 1, + "wins": 0 + }, + "301358": { + "profit": -433.0, + "trades": 1, + "wins": 0 + }, + "000838": { + "profit": -37.00000000000003, + "trades": 1, + "wins": 0 + }, + "600491": { + "profit": 2952.0000000000036, + "trades": 1, + "wins": 1 + }, + "600196": { + "profit": -576.0, + "trades": 1, + "wins": 0 + }, + "600267": { + "profit": 506.0000000000073, + "trades": 1, + "wins": 1 + }, + "688180": { + "profit": -785.9999999999964, + "trades": 1, + "wins": 0 + }, + "688197": { + "profit": -323.9999999999927, + "trades": 1, + "wins": 0 + }, + "000514": { + "profit": 700.0, + "trades": 1, + "wins": 1 + }, + "000525": { + "profit": 43.0, + "trades": 1, + "wins": 1 + }, + "688336": { + "profit": -1658.0, + "trades": 1, + "wins": 0 + }, + "600814": { + "profit": 3640.0, + "trades": 1, + "wins": 1 + }, + "300839": { + "profit": 461.9999999999991, + "trades": 1, + "wins": 1 + }, + "300511": { + "profit": 173.00000000000023, + "trades": 1, + "wins": 1 + }, + "002485": { + "profit": 4624.0, + "trades": 1, + "wins": 1 + }, + "301004": { + "profit": 2894.0, + "trades": 1, + "wins": 1 + }, + "300147": { + "profit": 504.0, + "trades": 1, + "wins": 1 + }, + "300883": { + "profit": 768.5000000000036, + "trades": 1, + "wins": 1 + }, + "002717": { + "profit": 55.0, + "trades": 1, + "wins": 1 + }, + "300158": { + "profit": -156.0, + "trades": 1, + "wins": 0 + }, + "688793": { + "profit": 61.5, + "trades": 1, + "wins": 1 + }, + "000564": { + "profit": -10.000000000000028, + "trades": 1, + "wins": 0 + }, + "300290": { + "profit": -6.0, + "trades": 1, + "wins": 0 + }, + "002210": { + "profit": 9.0, + "trades": 1, + "wins": 1 + }, + "688366": { + "profit": -522.0000000000036, + "trades": 1, + "wins": 0 + }, + "920748": { + "profit": -900.0, + "trades": 1, + "wins": 0 + }, + "000679": { + "profit": 459.0, + "trades": 1, + "wins": 1 + }, + "000695": { + "profit": 574.0, + "trades": 1, + "wins": 1 + }, + "000753": { + "profit": 6.0, + "trades": 1, + "wins": 1 + }, + "002287": { + "profit": 110.0, + "trades": 1, + "wins": 1 + }, + "002105": { + "profit": 29.0, + "trades": 1, + "wins": 1 + }, + "603366": { + "profit": -98.0, + "trades": 1, + "wins": 0 + }, + "688099": { + "profit": 500.99999999999636, + "trades": 1, + "wins": 1 + }, + "920106": { + "profit": -91.5, + "trades": 1, + "wins": 0 + }, + "688805": { + "profit": 108.0, + "trades": 1, + "wins": 1 + }, + "300587": { + "profit": 57.0, + "trades": 1, + "wins": 1 + }, + "300328": { + "profit": 0.0, + "trades": 1, + "wins": 0 + }, + "920725": { + "profit": 0.0, + "trades": 1, + "wins": 0 + }, + "300437": { + "profit": 0.0, + "trades": 1, + "wins": 0 + }, + "600408": { + "profit": 0.0, + "trades": 1, + "wins": 0 + } + }, + "trades": [ + { + "date": "2026-01-06", + "time": "09:35", + "action": "买入", + "code": "000516", + "price": 5.19, + "shares": 4600, + "amount": 23874.0, + "reason": "龙抬头出现 → 短线起爆点,执行买入" + }, + { + "date": "2026-01-06", + "time": "09:35", + "action": "买入", + "code": "002859", + "price": 28.9, + "shares": 800, + "amount": 23120.0, + "reason": "龙抬头出现 → 短线起爆点,执行买入" + }, + { + "date": "2026-01-06", + "time": "09:35", + "action": "买入", + "code": "600196", + "price": 27.32, + "shares": 800, + "amount": 21856.0, + "reason": "龙抬头出现 → 短线起爆点,执行买入" + }, + { + "date": "2026-01-06", + "time": "09:35", + "action": "买入", + "code": "600267", + "price": 10.239999999999998, + "shares": 2300, + "amount": 23551.999999999996, + "reason": "龙抬头出现 → 短线起爆点,执行买入" + }, + { + "date": "2026-01-06", + "time": "09:35", + "action": "买入", + "code": "600491", + "price": 2.92, + "shares": 8200, + "amount": 23944.0, + "reason": "龙抬头出现 → 短线起爆点,执行买入" + }, + { + "date": "2026-01-06", + "time": "09:35", + "action": "买入", + "code": "688180", + "price": 36.57, + "shares": 600, + "amount": 21942.0, + "reason": "龙抬头出现 → 短线起爆点,执行买入" + }, + { + "date": "2026-01-06", + "time": "09:35", + "action": "买入", + "code": "688197", + "price": 37.254999999999995, + "shares": 600, + "amount": 22352.999999999996, + "reason": "龙抬头出现 → 短线起爆点,执行买入" + }, + { + "date": "2026-01-06", + "time": "09:35", + "action": "买入", + "code": "688663", + "price": 41.22, + "shares": 500, + "amount": 20610.0, + "reason": "龙抬头出现 → 短线起爆点,执行买入" + }, + { + "date": "2026-01-06", + "time": "09:35", + "action": "买入", + "code": "000514", + "price": 5.03, + "shares": 3500, + "amount": 17605.0, + "reason": "龙抬头出现 → 短线起爆点,执行买入" + }, + { + "date": "2026-01-06", + "time": "09:35", + "action": "买入", + "code": "000525", + "price": 5.6, + "shares": 100, + "amount": 560.0, + "reason": "龙抬头出现 → 短线起爆点,执行买入" + }, + { + "date": "2026-01-06", + "time": "09:35", + "action": "买入", + "code": "600325", + "price": 4.35, + "shares": 100, + "amount": 434.99999999999994, + "reason": "龙抬头出现 → 短线起爆点,执行买入" + }, + { + "date": "2026-01-14", + "time": "13:40", + "action": "清仓", + "code": "000516", + "price": 6.19, + "shares": 4600, + "amount": 28474.0, + "reason": "止盈(浮盈19.3%≥12.0%)", + "profit": 4600.0 + }, + { + "date": "2026-01-15", + "time": "09:35", + "action": "买入", + "code": "300147", + "price": 9.31, + "shares": 2100, + "amount": 19551.0, + "reason": "龙抬头出现 → 短线起爆点,执行买入" + }, + { + "date": "2026-01-15", + "time": "13:40", + "action": "清仓", + "code": "688663", + "price": 47.315, + "shares": 500, + "amount": 23657.5, + "reason": "止盈(浮盈14.8%≥12.0%)", + "profit": 3047.5 + }, + { + "date": "2026-01-19", + "time": "09:35", + "action": "买入", + "code": "301307", + "price": 37.010000000000005, + "shares": 800, + "amount": 29608.000000000004, + "reason": "龙抬头出现 → 短线起爆点,执行买入" + }, + { + "date": "2026-01-19", + "time": "13:40", + "action": "清仓", + "code": "002859", + "price": 33.97, + "shares": 800, + "amount": 27176.0, + "reason": "止盈(浮盈17.5%≥12.0%)", + "profit": 4056.0 + }, + { + "date": "2026-01-19", + "time": "13:40", + "action": "清仓", + "code": "600325", + "price": 4.07, + "shares": 100, + "amount": 407.0, + "reason": "止损(浮亏-6.4%≥6.0%)", + "profit": -27.999999999999943 + }, + { + "date": "2026-01-20", + "time": "09:35", + "action": "买入", + "code": "300883", + "price": 6.779999999999999, + "shares": 2900, + "amount": 19661.999999999996, + "reason": "龙抬头出现 → 短线起爆点,执行买入" + }, + { + "date": "2026-01-21", + "time": "09:35", + "action": "买入", + "code": "301358", + "price": 66.61, + "shares": 100, + "amount": 6661.0, + "reason": "龙抬头出现 → 短线起爆点,执行买入" + }, + { + "date": "2026-01-21", + "time": "09:35", + "action": "买入", + "code": "002679", + "price": 11.43, + "shares": 300, + "amount": 3429.0, + "reason": "龙抬头出现 → 短线起爆点,执行买入" + }, + { + "date": "2026-01-21", + "time": "09:35", + "action": "买入", + "code": "002717", + "price": 1.61, + "shares": 500, + "amount": 805.0, + "reason": "龙抬头出现 → 短线起爆点,执行买入" + }, + { + "date": "2026-01-21", + "time": "09:35", + "action": "买入", + "code": "300344", + "price": 0.955, + "shares": 100, + "amount": 95.5, + "reason": "龙抬头出现 → 短线起爆点,执行买入" + }, + { + "date": "2026-01-22", + "time": "13:40", + "action": "清仓", + "code": "300344", + "price": 1.14, + "shares": 100, + "amount": 113.99999999999999, + "reason": "止盈(浮盈19.4%≥12.0%)", + "profit": 18.499999999999986 + }, + { + "date": "2026-01-23", + "time": "13:40", + "action": "清仓", + "code": "002679", + "price": 13.15, + "shares": 300, + "amount": 3945.0, + "reason": "止盈(浮盈15.0%≥12.0%)", + "profit": 516.0 + }, + { + "date": "2026-01-26", + "time": "09:35", + "action": "买入", + "code": "300839", + "price": 12.38, + "shares": 300, + "amount": 3714.0000000000005, + "reason": "龙抬头出现 → 短线起爆点,执行买入" + }, + { + "date": "2026-01-26", + "time": "09:35", + "action": "买入", + "code": "000838", + "price": 2.92, + "shares": 100, + "amount": 292.0, + "reason": "龙抬头出现 → 短线起爆点,执行买入" + }, + { + "date": "2026-01-27", + "time": "13:40", + "action": "清仓", + "code": "301307", + "price": 34.465, + "shares": 800, + "amount": 27572.000000000004, + "reason": "止损(浮亏-6.9%≥6.0%)", + "profit": -2036.0 + }, + { + "date": "2026-01-28", + "time": "09:35", + "action": "买入", + "code": "688336", + "price": 62.67, + "shares": 400, + "amount": 25068.0, + "reason": "龙抬头出现 → 短线起爆点,执行买入" + }, + { + "date": "2026-01-28", + "time": "09:35", + "action": "买入", + "code": "688793", + "price": 22.385, + "shares": 100, + "amount": 2238.5, + "reason": "龙抬头出现 → 短线起爆点,执行买入" + }, + { + "date": "2026-01-30", + "time": "09:35", + "action": "买入", + "code": "000564", + "price": 2.43, + "shares": 100, + "amount": 243.00000000000003, + "reason": "龙抬头出现 → 短线起爆点,执行买入" + }, + { + "date": "2026-02-02", + "time": "13:40", + "action": "清仓", + "code": "301358", + "price": 62.28, + "shares": 100, + "amount": 6228.0, + "reason": "止损(浮亏-6.5%≥6.0%)", + "profit": -433.0 + }, + { + "date": "2026-02-02", + "time": "13:40", + "action": "清仓", + "code": "000838", + "price": 2.55, + "shares": 100, + "amount": 254.99999999999997, + "reason": "止损(浮亏-12.7%≥6.0%)", + "profit": -37.00000000000003 + }, + { + "date": "2026-02-04", + "time": "09:35", + "action": "买入", + "code": "300290", + "price": 17.485, + "shares": 300, + "amount": 5245.5, + "reason": "龙抬头出现 → 短线起爆点,执行买入" + }, + { + "date": "2026-02-04", + "time": "13:40", + "action": "清仓", + "code": "600491", + "price": 3.2800000000000002, + "shares": 8200, + "amount": 26896.000000000004, + "reason": "止盈(浮盈12.3%≥12.0%)", + "profit": 2952.0000000000036 + }, + { + "date": "2026-02-05", + "time": "09:35", + "action": "买入", + "code": "600814", + "price": 9.48, + "shares": 2800, + "amount": 26544.0, + "reason": "龙抬头+主升浪 → 强势买入信号" + }, + { + "date": "2026-02-05", + "time": "09:35", + "action": "买入", + "code": "300511", + "price": 6.1850000000000005, + "shares": 200, + "amount": 1237.0, + "reason": "龙抬头出现 → 短线起爆点,执行买入" + }, + { + "date": "2026-02-05", + "time": "09:35", + "action": "买入", + "code": "002210", + "price": 3.19, + "shares": 100, + "amount": 319.0, + "reason": "龙抬头出现 → 短线起爆点,执行买入" + }, + { + "date": "2026-02-05", + "time": "13:40", + "action": "清仓", + "code": "600196", + "price": 26.6, + "shares": 800, + "amount": 21280.0, + "reason": "超时平仓(持仓30天≥30天)", + "profit": -576.0 + }, + { + "date": "2026-02-05", + "time": "13:40", + "action": "清仓", + "code": "600267", + "price": 10.46, + "shares": 2300, + "amount": 24058.000000000004, + "reason": "超时平仓(持仓30天≥30天)", + "profit": 506.0000000000073 + }, + { + "date": "2026-02-05", + "time": "13:40", + "action": "清仓", + "code": "688180", + "price": 35.260000000000005, + "shares": 600, + "amount": 21156.000000000004, + "reason": "超时平仓(持仓30天≥30天)", + "profit": -785.9999999999964 + }, + { + "date": "2026-02-05", + "time": "13:40", + "action": "清仓", + "code": "688197", + "price": 36.715, + "shares": 600, + "amount": 22029.000000000004, + "reason": "超时平仓(持仓30天≥30天)", + "profit": -323.9999999999927 + }, + { + "date": "2026-02-05", + "time": "13:40", + "action": "清仓", + "code": "000514", + "price": 5.23, + "shares": 3500, + "amount": 18305.0, + "reason": "超时平仓(持仓30天≥30天)", + "profit": 700.0 + }, + { + "date": "2026-02-05", + "time": "13:40", + "action": "清仓", + "code": "000525", + "price": 6.03, + "shares": 100, + "amount": 603.0, + "reason": "超时平仓(持仓30天≥30天)", + "profit": 43.0 + }, + { + "date": "2026-02-05", + "time": "13:40", + "action": "清仓", + "code": "688336", + "price": 58.525, + "shares": 400, + "amount": 23410.0, + "reason": "止损(浮亏-6.6%≥6.0%)", + "profit": -1658.0 + }, + { + "date": "2026-02-06", + "time": "09:35", + "action": "买入", + "code": "002485", + "price": 4.37, + "shares": 6800, + "amount": 29716.0, + "reason": "龙抬头出现 → 短线起爆点,执行买入" + }, + { + "date": "2026-02-06", + "time": "09:35", + "action": "买入", + "code": "688366", + "price": 47.215, + "shares": 600, + "amount": 28329.000000000004, + "reason": "龙抬头出现 → 短线起爆点,执行买入" + }, + { + "date": "2026-02-06", + "time": "09:35", + "action": "买入", + "code": "301004", + "price": 53.925, + "shares": 400, + "amount": 21570.0, + "reason": "龙抬头出现 → 短线起爆点,执行买入" + }, + { + "date": "2026-02-06", + "time": "09:35", + "action": "买入", + "code": "920748", + "price": 44.265, + "shares": 500, + "amount": 22132.5, + "reason": "龙抬头出现 → 短线起爆点,执行买入" + }, + { + "date": "2026-02-06", + "time": "09:35", + "action": "买入", + "code": "000679", + "price": 7.25, + "shares": 2700, + "amount": 19575.0, + "reason": "龙抬头出现 → 短线起爆点,执行买入" + }, + { + "date": "2026-02-06", + "time": "09:35", + "action": "买入", + "code": "000695", + "price": 12.38, + "shares": 700, + "amount": 8666.0, + "reason": "龙抬头出现 → 短线起爆点,执行买入" + }, + { + "date": "2026-02-06", + "time": "09:35", + "action": "买入", + "code": "000753", + "price": 7.17, + "shares": 100, + "amount": 717.0, + "reason": "龙抬头出现 → 短线起爆点,执行买入" + }, + { + "date": "2026-02-06", + "time": "13:40", + "action": "清仓", + "code": "600814", + "price": 10.78, + "shares": 2800, + "amount": 30184.0, + "reason": "止盈(浮盈13.7%≥12.0%)", + "profit": 3640.0 + }, + { + "date": "2026-02-09", + "time": "09:35", + "action": "买入", + "code": "002287", + "price": 24.62, + "shares": 1100, + "amount": 27082.0, + "reason": "龙抬头出现 → 短线起爆点,执行买入" + }, + { + "date": "2026-02-09", + "time": "09:35", + "action": "买入", + "code": "300158", + "price": 6.48, + "shares": 400, + "amount": 2592.0, + "reason": "龙抬头出现 → 短线起爆点,执行买入" + }, + { + "date": "2026-02-09", + "time": "09:35", + "action": "买入", + "code": "002105", + "price": 7.29, + "shares": 100, + "amount": 729.0, + "reason": "龙抬头出现 → 短线起爆点,执行买入" + }, + { + "date": "2026-02-10", + "time": "13:40", + "action": "清仓", + "code": "300839", + "price": 13.92, + "shares": 300, + "amount": 4176.0, + "reason": "止盈(浮盈12.4%≥12.0%)", + "profit": 461.9999999999991 + }, + { + "date": "2026-02-11", + "time": "09:35", + "action": "买入", + "code": "603366", + "price": 9.98, + "shares": 400, + "amount": 3992.0, + "reason": "龙抬头+主升浪 → 强势买入信号" + }, + { + "date": "2026-02-11", + "time": "09:35", + "action": "买入", + "code": "002231", + "price": 0.6, + "shares": 400, + "amount": 240.0, + "reason": "龙抬头出现 → 短线起爆点,执行买入" + }, + { + "date": "2026-02-11", + "time": "13:40", + "action": "清仓", + "code": "300511", + "price": 7.050000000000001, + "shares": 200, + "amount": 1410.0000000000002, + "reason": "止盈(浮盈14.0%≥12.0%)", + "profit": 173.00000000000023 + }, + { + "date": "2026-02-11", + "time": "13:40", + "action": "清仓", + "code": "002485", + "price": 5.05, + "shares": 6800, + "amount": 34340.0, + "reason": "止盈(浮盈15.6%≥12.0%)", + "profit": 4624.0 + }, + { + "date": "2026-02-11", + "time": "13:40", + "action": "清仓", + "code": "301004", + "price": 61.16, + "shares": 400, + "amount": 24464.0, + "reason": "止盈(浮盈13.4%≥12.0%)", + "profit": 2894.0 + }, + { + "date": "2026-02-13", + "time": "09:35", + "action": "买入", + "code": "688099", + "price": 94.06, + "shares": 300, + "amount": 28218.0, + "reason": "龙抬头出现 → 短线起爆点,执行买入" + }, + { + "date": "2026-02-13", + "time": "09:35", + "action": "买入", + "code": "920106", + "price": 78.28, + "shares": 300, + "amount": 23484.0, + "reason": "龙抬头出现 → 短线起爆点,执行买入" + }, + { + "date": "2026-02-13", + "time": "09:35", + "action": "买入", + "code": "688805", + "price": 44.185, + "shares": 100, + "amount": 4418.5, + "reason": "龙抬头出现 → 短线起爆点,执行买入" + }, + { + "date": "2026-02-24", + "time": "09:35", + "action": "买入", + "code": "300587", + "price": 6.495, + "shares": 600, + "amount": 3897.0, + "reason": "龙抬头出现 → 短线起爆点,执行买入" + }, + { + "date": "2026-02-24", + "time": "13:40", + "action": "清仓", + "code": "300147", + "price": 9.55, + "shares": 2100, + "amount": 20055.0, + "reason": "超时平仓(持仓40天≥30天)", + "profit": 504.0 + }, + { + "date": "2026-02-24", + "time": "13:40", + "action": "清仓", + "code": "300883", + "price": 7.045, + "shares": 2900, + "amount": 20430.5, + "reason": "超时平仓(持仓35天≥30天)", + "profit": 768.5000000000036 + }, + { + "date": "2026-02-24", + "time": "13:40", + "action": "清仓", + "code": "002717", + "price": 1.72, + "shares": 500, + "amount": 860.0, + "reason": "超时平仓(持仓34天≥30天)", + "profit": 55.0 + }, + { + "date": "2026-02-24", + "time": "13:40", + "action": "清仓", + "code": "300158", + "price": 6.09, + "shares": 400, + "amount": 2436.0, + "reason": "止损(浮亏-6.0%≥6.0%)", + "profit": -156.0 + }, + { + "date": "2026-02-25", + "time": "09:35", + "action": "买入", + "code": "300328", + "price": 18.575, + "shares": 1600, + "amount": 29720.0, + "reason": "龙抬头出现 → 短线起爆点,执行买入" + }, + { + "date": "2026-02-25", + "time": "09:35", + "action": "买入", + "code": "920725", + "price": 36.78, + "shares": 300, + "amount": 11034.0, + "reason": "龙抬头出现 → 短线起爆点,执行买入" + }, + { + "date": "2026-02-25", + "time": "09:35", + "action": "买入", + "code": "300437", + "price": 17.465, + "shares": 100, + "amount": 1746.5, + "reason": "龙抬头出现 → 短线起爆点,执行买入" + }, + { + "date": "2026-02-25", + "time": "09:35", + "action": "买入", + "code": "600408", + "price": 3.885, + "shares": 300, + "amount": 1165.5, + "reason": "龙抬头出现 → 短线起爆点,执行买入" + }, + { + "date": "2026-02-25", + "time": "回测结束", + "action": "清仓", + "code": "688793", + "price": 23.0, + "shares": 100, + "amount": 2300.0, + "reason": "回测截止", + "profit": 61.5 + }, + { + "date": "2026-02-25", + "time": "回测结束", + "action": "清仓", + "code": "000564", + "price": 2.33, + "shares": 100, + "amount": 233.0, + "reason": "回测截止", + "profit": -10.000000000000028 + }, + { + "date": "2026-02-25", + "time": "回测结束", + "action": "清仓", + "code": "300290", + "price": 17.465, + "shares": 300, + "amount": 5239.5, + "reason": "回测截止", + "profit": -6.0 + }, + { + "date": "2026-02-25", + "time": "回测结束", + "action": "清仓", + "code": "002210", + "price": 3.28, + "shares": 100, + "amount": 328.0, + "reason": "回测截止", + "profit": 9.0 + }, + { + "date": "2026-02-25", + "time": "回测结束", + "action": "清仓", + "code": "688366", + "price": 46.345, + "shares": 600, + "amount": 27807.0, + "reason": "回测截止", + "profit": -522.0000000000036 + }, + { + "date": "2026-02-25", + "time": "回测结束", + "action": "清仓", + "code": "920748", + "price": 42.465, + "shares": 500, + "amount": 21232.5, + "reason": "回测截止", + "profit": -900.0 + }, + { + "date": "2026-02-25", + "time": "回测结束", + "action": "清仓", + "code": "000679", + "price": 7.42, + "shares": 2700, + "amount": 20034.0, + "reason": "回测截止", + "profit": 459.0 + }, + { + "date": "2026-02-25", + "time": "回测结束", + "action": "清仓", + "code": "000695", + "price": 13.2, + "shares": 700, + "amount": 9240.0, + "reason": "回测截止", + "profit": 574.0 + }, + { + "date": "2026-02-25", + "time": "回测结束", + "action": "清仓", + "code": "000753", + "price": 7.23, + "shares": 100, + "amount": 723.0, + "reason": "回测截止", + "profit": 6.0 + }, + { + "date": "2026-02-25", + "time": "回测结束", + "action": "清仓", + "code": "002287", + "price": 24.72, + "shares": 1100, + "amount": 27192.0, + "reason": "回测截止", + "profit": 110.0 + }, + { + "date": "2026-02-25", + "time": "回测结束", + "action": "清仓", + "code": "002105", + "price": 7.58, + "shares": 100, + "amount": 758.0, + "reason": "回测截止", + "profit": 29.0 + }, + { + "date": "2026-02-25", + "time": "回测结束", + "action": "清仓", + "code": "603366", + "price": 9.735, + "shares": 400, + "amount": 3894.0, + "reason": "回测截止", + "profit": -98.0 + }, + { + "date": "2026-02-25", + "time": "回测结束", + "action": "清仓", + "code": "688099", + "price": 95.72999999999999, + "shares": 300, + "amount": 28718.999999999996, + "reason": "回测截止", + "profit": 500.99999999999636 + }, + { + "date": "2026-02-25", + "time": "回测结束", + "action": "清仓", + "code": "920106", + "price": 77.975, + "shares": 300, + "amount": 23392.5, + "reason": "回测截止", + "profit": -91.5 + }, + { + "date": "2026-02-25", + "time": "回测结束", + "action": "清仓", + "code": "688805", + "price": 45.265, + "shares": 100, + "amount": 4526.5, + "reason": "回测截止", + "profit": 108.0 + }, + { + "date": "2026-02-25", + "time": "回测结束", + "action": "清仓", + "code": "300587", + "price": 6.59, + "shares": 600, + "amount": 3954.0, + "reason": "回测截止", + "profit": 57.0 + }, + { + "date": "2026-02-25", + "time": "回测结束", + "action": "清仓", + "code": "300328", + "price": 18.575, + "shares": 1600, + "amount": 29720.0, + "reason": "回测截止", + "profit": 0.0 + }, + { + "date": "2026-02-25", + "time": "回测结束", + "action": "清仓", + "code": "920725", + "price": 36.78, + "shares": 300, + "amount": 11034.0, + "reason": "回测截止", + "profit": 0.0 + }, + { + "date": "2026-02-25", + "time": "回测结束", + "action": "清仓", + "code": "300437", + "price": 17.465, + "shares": 100, + "amount": 1746.5, + "reason": "回测截止", + "profit": 0.0 + }, + { + "date": "2026-02-25", + "time": "回测结束", + "action": "清仓", + "code": "600408", + "price": 3.885, + "shares": 300, + "amount": 1165.5, + "reason": "回测截止", + "profit": 0.0 + } + ] +} \ No newline at end of file diff --git a/stock-html/docs/backtest_top3_5min.md b/stock-html/docs/backtest_top3_5min.md new file mode 100644 index 0000000..cab59c1 --- /dev/null +++ b/stock-html/docs/backtest_top3_5min.md @@ -0,0 +1,22 @@ +# Top 3 算法 × 5分钟实时价格 回测对比 + +回测区间: 2025-01-02 ~ 2025-12-31 + +## 定价模式 + +| 模式 | 买入价 | 卖出价 | 说明 | +|------|--------|--------|------| +| 日线(原版) | 当日开盘价 | 当日收盘价 | 原始基准 | +| 5分钟实时 | 10:00 5min收盘 | 15:00 5min收盘 | 有5min数据用5min, 无则用mid=(开盘+收盘)/2 | + +## 对比结果 + +| 算法 | 定价 | 盈亏(元) | 占用资金 | 真实收益 | 真实年化 | 胜率 | 回撤% | 盈亏比 | 交易 | 5min覆盖 | +|------|------|---------|---------|---------|---------|------|-------|--------|------|----------| +| 🏆 v4|触发≥2+止盈10+损8 | 日线(原版) | +20,600 | ¥153,490 | +13.4% | +13.5% | 44.9% | 4.3% | 1.73 | 156 | - | +| 🏆 v4|触发≥2+止盈10+损8 | 5分钟实时价 | +9,765 | ¥160,740 | +6.1% | +6.1% | 43.1% | 4.2% | 1.38 | 144 | 0% | +| 🥈 v3|止盈10+损8 | 日线(原版) | +6,380 | ¥158,655 | +4.0% | +4.0% | 36.2% | 5.0% | 1.23 | 160 | - | +| 🥈 v3|止盈10+损8 | 5分钟实时价 | +2,230 | ¥160,740 | +1.4% | +1.4% | 37.3% | 6.3% | 1.10 | 150 | 1% | +| 🥉 v4.2-K1|延迟2天+止盈10+损8 | 日线(原版) | +7,280 | ¥158,655 | +4.6% | +4.6% | 38.5% | 6.9% | 1.23 | 156 | - | +| 🥉 v4.2-K1|延迟2天+止盈10+损8 | 5分钟实时价 | +7,515 | ¥161,995 | +4.6% | +4.7% | 41.9% | 5.7% | 1.31 | 148 | 4% | + diff --git a/stock-html/docs/timing_search_results.md b/stock-html/docs/timing_search_results.md new file mode 100644 index 0000000..fb3bfcc --- /dev/null +++ b/stock-html/docs/timing_search_results.md @@ -0,0 +1,176 @@ +# ⏰ v7.0 交易时点网格搜索结果 + +> 生成时间: 2026-02-26 09:53 + +## 搜索配置 + +| 项目 | 值 | +|------|----| +| 本金 | ¥200,000 | +| 回测区间 | 2025-11-28 ~ 2026-02-26 | +| 5分钟数据 | 2025-11-28 ~ 2026-02-25 (56天) | +| 时间点 | 48 个 | +| 组合数 | 2,304 × 3 算法 = 6,912 | +| 耗时 | 797.8s (8.7次/s) | +| 有效结果 | 6,912 | + +## 🏆 全局 Top 20 + +| 排名 | 算法 | 买入 | 卖出 | 盈亏 | 收益% | 年化% | 胜率 | 回撤% | 交易 | 5min% | +|------|------|------|------|------|-------|-------|------|-------|------|-------| +| 🏆 | 🥈TP12|SL6|d3|h30|15%SW | 09:35 | 13:40 | ¥+46,130 | +18.8% | +101.1% | 59.4% | 2.8% | 139 | 21% | +| 🥈 | 🥈TP12|SL6|d3|h30|15%SW | 09:35 | 13:35 | ¥+45,910 | +18.7% | +100.6% | 60.9% | 2.9% | 139 | 21% | +| 🥉 | 🥈TP12|SL6|d3|h30|15%SW | 09:45 | 13:40 | ¥+45,850 | +18.7% | +100.6% | 57.4% | 2.7% | 137 | 21% | +| #4 | 🥈TP12|SL6|d3|h30|15%SW | 09:45 | 14:15 | ¥+45,556 | +18.7% | +100.4% | 56.3% | 2.8% | 143 | 21% | +| #5 | 🥈TP12|SL6|d3|h30|15%SW | 09:40 | 14:15 | ¥+45,436 | +18.7% | +100.2% | 59.7% | 2.9% | 135 | 21% | +| #6 | 🥈TP12|SL6|d3|h30|15%SW | 09:45 | 13:35 | ¥+45,425 | +18.6% | +99.5% | 56.1% | 2.9% | 133 | 21% | +| #7 | 🥈TP12|SL6|d3|h30|15%SW | 09:45 | 13:45 | ¥+45,418 | +18.7% | +100.1% | 58.2% | 2.8% | 135 | 21% | +| #8 | 🥈TP12|SL6|d3|h30|15%SW | 09:45 | 14:10 | ¥+45,328 | +18.6% | +99.8% | 58.0% | 2.9% | 139 | 21% | +| #9 | 🥈TP12|SL6|d3|h30|15%SW | 09:35 | 13:45 | ¥+45,255 | +18.5% | +99.1% | 60.0% | 2.8% | 141 | 21% | +| #10 | 🥈TP12|SL6|d3|h30|15%SW | 14:35 | 13:35 | ¥+45,204 | +18.5% | +98.9% | 57.5% | 2.9% | 147 | 21% | +| #11 | 🥈TP12|SL6|d3|h30|15%SW | 13:20 | 13:35 | ¥+45,202 | +18.5% | +98.8% | 57.8% | 2.9% | 143 | 21% | +| #12 | 🥈TP12|SL6|d3|h30|15%SW | 13:20 | 13:40 | ¥+45,160 | +18.4% | +98.7% | 58.0% | 2.7% | 139 | 21% | +| #13 | 🥈TP12|SL6|d3|h30|15%SW | 14:35 | 13:40 | ¥+45,142 | +18.5% | +98.8% | 55.6% | 2.7% | 145 | 21% | +| #14 | 🥈TP12|SL6|d3|h30|15%SW | 09:45 | 14:25 | ¥+45,139 | +18.6% | +99.4% | 58.6% | 3.2% | 141 | 21% | +| #15 | 🥈TP12|SL6|d3|h30|15%SW | 14:30 | 13:40 | ¥+45,103 | +18.4% | +98.6% | 62.3% | 2.7% | 139 | 21% | +| #16 | 🥈TP12|SL6|d3|h30|15%SW | 09:45 | 14:35 | ¥+45,088 | +18.5% | +99.1% | 58.2% | 3.1% | 135 | 21% | +| #17 | 🥈TP12|SL6|d3|h30|15%SW | 09:45 | 13:55 | ¥+45,087 | +18.5% | +99.3% | 55.7% | 3.0% | 141 | 21% | +| #18 | 🥈TP12|SL6|d3|h30|15%SW | 09:40 | 13:40 | ¥+45,075 | +18.4% | +98.5% | 56.7% | 2.8% | 135 | 21% | +| #19 | 🥈TP12|SL6|d3|h30|15%SW | 09:45 | 14:20 | ¥+45,046 | +18.5% | +99.0% | 57.4% | 3.1% | 137 | 21% | +| #20 | 🥈TP12|SL6|d3|h30|15%SW | 09:40 | 13:35 | ¥+44,999 | +18.4% | +98.6% | 58.2% | 2.9% | 135 | 21% | + +## 📊 每算法最优时间点 + +| 算法 | 最优买入 | 最优卖出 | 最优盈利 | 默认盈利(10:00/15:00) | 提升 | +|------|---------|---------|---------|---------------------|------| +| 🏆TP12|SL6|d3|h30|10%SW | 09:45 | 13:40 | ¥+30,258 | ¥+22,006 | ¥+8,252 | +| 🥈TP12|SL6|d3|h30|15%SW | 09:35 | 13:40 | ¥+46,130 | ¥+34,504 | ¥+11,625 | +| 🥉TP10|SL8|ign|15%SW | 14:35 | 10:05 | ¥+34,890 | ¥+19,705 | ¥+15,184 | + +## 📈 买入时间排名 (卖出固定@15:00) + +| 排名 | 买入时间 | 平均盈利 | 最高盈利 | 最低盈利 | +|------|---------|---------|---------|----------| +| 🏆 | 09:35 | ¥+28,213 | ¥+40,034 | ¥+19,618 | +| #2 | 09:45 | ¥+28,066 | ¥+39,810 | ¥+19,456 | +| #3 | 14:35 | ¥+27,881 | ¥+38,760 | ¥+20,066 | +| #4 | 09:40 | ¥+27,867 | ¥+39,487 | ¥+19,422 | +| #5 | 14:30 | ¥+27,806 | ¥+38,814 | ¥+20,090 | +| #6 | 10:35 | ¥+27,738 | ¥+38,905 | ¥+20,073 | +| #7 | 13:35 | ¥+27,713 | ¥+38,984 | ¥+19,888 | +| #8 | 13:30 | ¥+27,662 | ¥+38,724 | ¥+19,876 | +| #9 | 14:40 | ¥+27,624 | ¥+38,769 | ¥+20,104 | +| #10 | 13:40 | ¥+27,602 | ¥+38,372 | ¥+20,062 | +| #11 | 13:25 | ¥+27,602 | ¥+38,601 | ¥+19,903 | +| #12 | 13:20 | ¥+27,486 | ¥+38,613 | ¥+19,920 | +| #13 | 14:50 | ¥+27,461 | ¥+38,129 | ¥+20,044 | +| #14 | 13:45 | ¥+27,425 | ¥+38,350 | ¥+20,072 | +| #15 | 10:30 | ¥+27,402 | ¥+38,782 | ¥+19,882 | +| #16 | 09:50 | ¥+27,363 | ¥+38,290 | ¥+19,890 | +| #17 | 13:15 | ¥+27,316 | ¥+38,326 | ¥+19,880 | +| #18 | 14:15 | ¥+26,302 | ¥+35,799 | ¥+20,059 | +| #19 | 13:10 | ¥+26,246 | ¥+35,840 | ¥+20,035 | +| #20 | 14:10 | ¥+26,177 | ¥+35,631 | ¥+20,080 | +| #21 | 14:05 | ¥+26,102 | ¥+35,546 | ¥+20,068 | +| #22 | 14:20 | ¥+26,042 | ¥+35,728 | ¥+20,068 | +| #23 | 10:40 | ¥+26,036 | ¥+35,531 | ¥+19,918 | +| #24 | 13:55 | ¥+25,991 | ¥+35,035 | ¥+20,074 | +| #25 | 13:50 | ¥+25,985 | ¥+34,956 | ¥+20,096 | +| #26 | 10:45 | ¥+25,984 | ¥+35,174 | ¥+19,876 | +| #27 | 13:05 | ¥+25,965 | ¥+35,660 | ¥+19,916 | +| #28 | 10:25 | ¥+25,957 | ¥+35,309 | ¥+19,731 | +| #29 | 14:25 | ¥+25,949 | ¥+35,449 | ¥+20,098 | +| #30 | 14:00 | ¥+25,896 | ¥+35,378 | ¥+20,046 | +| #31 | 10:15 | ¥+25,838 | ¥+35,464 | ¥+19,651 | +| #32 | 15:00 | ¥+25,809 | ¥+35,078 | ¥+20,436 | +| #33 | 10:20 | ¥+25,785 | ¥+34,903 | ¥+19,729 | +| #34 | 11:05 | ¥+25,748 | ¥+34,926 | ¥+20,012 | +| #35 | 14:55 | ¥+25,703 | ¥+34,855 | ¥+20,064 | +| #36 | 10:10 | ¥+25,684 | ¥+35,283 | ¥+19,509 | +| #37 | 14:45 | ¥+25,592 | ¥+34,740 | ¥+20,057 | +| #38 | 10:05 | ¥+25,562 | ¥+34,987 | ¥+19,462 | +| #39 | 11:30 | ¥+25,510 | ¥+35,096 | ¥+19,857 | +| #40 | 11:25 | ¥+25,442 | ¥+34,946 | ¥+19,848 | +| #41 | 10:55 | ¥+25,434 | ¥+34,964 | ¥+19,826 | +| #42 | 10:00 | ¥+25,405 | ¥+34,504 | ¥+19,705 | +| #43 | 11:00 | ¥+25,396 | ¥+34,818 | ¥+19,999 | +| #44 | 10:50 | ¥+25,221 | ¥+34,489 | ¥+19,846 | +| #45 | 11:15 | ¥+25,037 | ¥+33,496 | ¥+20,027 | +| #46 | 11:10 | ¥+24,934 | ¥+33,214 | ¥+20,005 | +| #47 | 11:20 | ¥+24,921 | ¥+33,684 | ¥+20,043 | +| #48 | 09:55 | ¥+21,316 | ¥+27,112 | ¥+17,130 | + +## 📉 卖出时间排名 (买入固定@10:00) + +| 排名 | 卖出时间 | 平均盈利 | 最高盈利 | 最低盈利 | +|------|---------|---------|---------|----------| +| 🏆 | 13:30 | ¥+27,183 | ¥+34,910 | ¥+22,328 | +| #2 | 13:35 | ¥+27,122 | ¥+34,952 | ¥+22,090 | +| #3 | 13:25 | ¥+27,061 | ¥+34,880 | ¥+22,080 | +| #4 | 13:40 | ¥+27,058 | ¥+34,984 | ¥+22,296 | +| #5 | 13:20 | ¥+26,946 | ¥+34,682 | ¥+21,936 | +| #6 | 14:15 | ¥+26,912 | ¥+34,924 | ¥+22,553 | +| #7 | 13:45 | ¥+26,864 | ¥+34,479 | ¥+22,234 | +| #8 | 14:00 | ¥+26,784 | ¥+35,058 | ¥+22,394 | +| #9 | 13:55 | ¥+26,764 | ¥+34,634 | ¥+22,247 | +| #10 | 14:05 | ¥+26,720 | ¥+34,598 | ¥+22,480 | +| #11 | 13:50 | ¥+26,692 | ¥+34,131 | ¥+22,274 | +| #12 | 14:20 | ¥+26,683 | ¥+34,528 | ¥+22,264 | +| #13 | 14:30 | ¥+26,563 | ¥+34,292 | ¥+22,156 | +| #14 | 14:25 | ¥+26,555 | ¥+34,281 | ¥+22,106 | +| #15 | 10:05 | ¥+26,464 | ¥+29,920 | ¥+19,744 | +| #16 | 14:50 | ¥+26,399 | ¥+33,882 | ¥+21,874 | +| #17 | 14:40 | ¥+26,273 | ¥+33,675 | ¥+21,886 | +| #18 | 14:45 | ¥+26,263 | ¥+33,710 | ¥+21,788 | +| #19 | 10:15 | ¥+26,212 | ¥+30,418 | ¥+19,129 | +| #20 | 09:50 | ¥+26,188 | ¥+29,928 | ¥+19,044 | +| #21 | 13:05 | ¥+26,184 | ¥+33,670 | ¥+20,984 | +| #22 | 14:55 | ¥+26,174 | ¥+33,702 | ¥+21,540 | +| #23 | 09:40 | ¥+25,956 | ¥+29,410 | ¥+19,280 | +| #24 | 14:10 | ¥+25,426 | ¥+35,278 | ¥+18,697 | +| #25 | 15:00 | ¥+25,405 | ¥+34,504 | ¥+19,705 | +| #26 | 09:35 | ¥+25,268 | ¥+29,105 | ¥+18,964 | +| #27 | 14:35 | ¥+25,067 | ¥+34,774 | ¥+18,658 | +| #28 | 10:00 | ¥+24,864 | ¥+29,896 | ¥+19,030 | +| #29 | 09:55 | ¥+24,688 | ¥+29,404 | ¥+18,889 | +| #30 | 13:10 | ¥+23,100 | ¥+34,476 | ¥+12,914 | +| #31 | 13:15 | ¥+22,580 | ¥+33,689 | ¥+12,689 | +| #32 | 10:35 | ¥+22,309 | ¥+32,507 | ¥+13,788 | +| #33 | 10:55 | ¥+22,217 | ¥+33,049 | ¥+12,914 | +| #34 | 10:40 | ¥+22,216 | ¥+32,393 | ¥+13,516 | +| #35 | 10:50 | ¥+22,140 | ¥+32,744 | ¥+13,336 | +| #36 | 10:45 | ¥+22,136 | ¥+32,626 | ¥+13,316 | +| #37 | 10:30 | ¥+22,115 | ¥+32,128 | ¥+13,716 | +| #38 | 11:00 | ¥+22,032 | ¥+32,746 | ¥+13,318 | +| #39 | 11:10 | ¥+21,993 | ¥+32,580 | ¥+12,784 | +| #40 | 11:05 | ¥+21,958 | ¥+32,818 | ¥+13,135 | +| #41 | 11:20 | ¥+21,856 | ¥+32,472 | ¥+12,688 | +| #42 | 11:25 | ¥+21,856 | ¥+32,743 | ¥+12,422 | +| #43 | 11:30 | ¥+21,796 | ¥+32,470 | ¥+12,558 | +| #44 | 09:45 | ¥+21,518 | ¥+26,647 | ¥+15,167 | +| #45 | 11:15 | ¥+21,290 | ¥+30,784 | ¥+12,688 | +| #46 | 10:10 | ¥+21,228 | ¥+29,897 | ¥+14,395 | +| #47 | 10:25 | ¥+16,713 | ¥+22,278 | ¥+13,735 | +| #48 | 10:20 | ¥+16,172 | ¥+21,536 | ¥+13,410 | + +## 🔥 最优买卖时间组合 Top 10 + +| 排名 | 买入 | 卖出 | 平均盈利 | +|------|------|------|----------| +| 🏆 | 14:35 | 13:40 | ¥+33,906 | +| #2 | 14:30 | 13:40 | ¥+33,799 | +| #3 | 15:00 | 13:40 | ¥+33,798 | +| #4 | 13:35 | 13:40 | ¥+33,713 | +| #5 | 13:20 | 13:35 | ¥+33,707 | +| #6 | 09:35 | 13:45 | ¥+33,658 | +| #7 | 14:20 | 13:40 | ¥+33,645 | +| #8 | 14:40 | 13:40 | ¥+33,633 | +| #9 | 14:35 | 13:35 | ¥+33,618 | +| #10 | 14:15 | 13:40 | ¥+33,612 | + +## 💡 结论 + +1. **全局最优**: 🥈TP12|SL6|d3|h30|15%SW 买@09:35 卖@13:40 → ¥+46,130 +2. **默认(10:00/15:00)平均盈利**: ¥+25,405 +3. **最优时间组合(跨算法平均)**: 买@14:35 卖@13:40 → 平均¥+33,906 +4. **时点优化潜在提升**: ¥+8,501 diff --git a/stock-html/docs/价格与刷新说明.md b/stock-html/docs/价格与刷新说明.md new file mode 100644 index 0000000..fdbfdd7 --- /dev/null +++ b/stock-html/docs/价格与刷新说明.md @@ -0,0 +1,58 @@ +# 提醒和交易中的刷新 — 如何获取股票现价 + +## 一、提醒(自动提醒)刷新 + +### 哪些股票会参与 +- **关注列表**:`searchHistory`(用户添加的关注股票) +- **持有股票**:`holdingStocks`(从交易记录里计算出的当前持仓代码) +- 两者合并去重后得到 `allStocks`,只对这些股票请求信号与价格。 + +### 价格从哪里来 +1. **主流程**(`POST /api/signal_alerts`) + - 后端从 **`stock_signal_scan`** 取当日扫描结果(信号、推荐等)。 + - 从 **`stock_realtime_price`** 表按 `code IN (请求的股票)` 取 `price`、`change_pct` 作为初值。 + - 接口返回后,前端会**再跑一遍实时价**:调用 **`refreshAlertPricesFromRealtime()`**,对每条提醒分批请求 `GET /api/realtime_price/{code}`,用实时接口返回的价格覆盖显示。 + - 因此**最终展示的现价为实时价**(麦蕊等),不是表里旧数据。 + +2. **回退流程**(批量失败、逐个分析且 `forceRefresh`) + - 前端对每只股票再请求 `GET /api/realtime_price/{code}` 更新价格,同样是实时接口。 + +### 小结 +- 刷新提醒:**股票范围** = 关注 + 持仓;**现价** = 先来自表,随后由 **实时 API** 后台更新为最新价。 + +--- + +## 二、交易中的刷新 + +### 哪些股票会参与 +- 仅 **当前持仓**:`holdingPositions` 里 `quantity > 0` 的股票(即 `holdingStocks` 对应的持仓)。 + +### 价格从哪里来 +1. **先从不发请求的缓存更新** + - `updateHoldingPricesFromAlerts()`:用 **`stockAlerts`** 里对应 `code` 的 `alert.price` 更新持仓的 `currentPrice`。 + - 即:若之前加载过提醒,交易里会先用提醒结果里的价格(该价格本身来自 `stock_realtime_price` 或单只实时 API)。 + +2. **用户点击「刷新」**(`refreshHoldingPrices()`) + - 对每个持仓 `code` 调用 `GET /api/realtime_price/{code}`。 + - 后端 `realtime_price` 使用 **实时 API**(如 `get_realtime_price` → 麦蕊智数等),**不读** `stock_realtime_price` 表。 + - 拿到的价格会写回: + - `holdingPositions[code].currentPrice` + - 以及 `stockAlerts` 里该 code 的 `price`(并可能触发保存提醒缓存)。 + +### 小结 +- 交易中的「现价」: + - 未点刷新时:来自 **提醒缓存**(提醒的数据又来自 `stock_realtime_price` 或单只实时 API)。 + - 点击刷新后:来自 **实时 API**(`/api/realtime_price/:code`),与数据库表无关。 + +--- + +## 三、实施价格(现价)来源汇总 + +| 场景 | 股票范围 | 现价/实施价格来源 | +|----------------|--------------|-------------------| +| 提醒主流程 | 关注 + 持仓 | 先表后 **实时 API** 覆盖(`refreshAlertPricesFromRealtime`) | +| 提醒回退+强制刷新 | 同上,逐只 | `GET /api/realtime_price/:code` | +| 交易-不点刷新 | 持仓 | 提醒缓存 `stockAlerts[].price`(已含实时价) | +| 交易-点刷新 | 持仓 | `GET /api/realtime_price/:code`(**与提醒刷新为同一接口**) | + +**说明**:提醒里更新现价与交易里「刷新价格」都调用 **同一个接口** `GET /api/realtime_price/:code`,后端均为 `get_realtime_price(stock_code)`(如麦蕊智数等)。 diff --git a/stock-html/docs/全景扫描算法梳理.md b/stock-html/docs/全景扫描算法梳理.md new file mode 100644 index 0000000..1fe83b1 --- /dev/null +++ b/stock-html/docs/全景扫描算法梳理.md @@ -0,0 +1,202 @@ +# 全景扫描算法梳理 + +## 一、整体流程 + +全景扫描是对**全市场股票**做一次**技术信号全量检测**,结果写入 `stock_signal_scan` 表,供前端「全景扫描」页、策略建议、提醒、模拟交易等使用。 + +### 1.1 触发方式 + +| 方式 | 说明 | +|------|------| +| 用户点击 | 前端「全景扫描」按钮 → 后台执行 `full_signal_scan.py`(可选) | +| 定时任务 | crontab 配置,如 11:50、16:30 或凌晨 01:00(`0 1 * * 1-5`) | +| 管理员 | 管理后台「启动全景扫描」触发 | + +### 1.2 入口与脚本 + +- **脚本**:`full_signal_scan.py` +- **扫描日期**:`scan_date = date.today()`,支持按日断点续扫 +- **环境变量**:`FORCE_RESCAN=1` 时先清空当日 `stock_signal_scan` 再扫 + +### 1.3 数据源 + +| 数据类型 | 来源 | 说明 | +|----------|------|------| +| 股票列表 | 表 `stock_realtime_price` | `SELECT code, name ORDER BY code`,全市场 | +| K 线 | 多级回退 | 见下文「K 线获取顺序」 | +| 结果存储 | 表 `stock_signal_scan` | 不写价格;现价由 `stock_realtime_price` 等更新 | + +**K 线获取顺序**(`get_kline_data()`): + +1. **本地 DB**:`get_kline_from_local_db_threaded(code, days)`(`stock_kline_daily`) +2. **阿里云 K 线 API**:北交所跳过 +3. **腾讯 K 线 API**:全市场(含北交所) +4. **麦蕊智数**:`get_kline(period='d', days=120)` +5. **AKShare**:`stock_zh_a_hist` 日 K + +取数长度:**K_DAYS = 120** 日。 + +--- + +## 二、单只股票扫描流程 + +对每只待扫描股票(`pending = 全市场 - 当日已扫`): + +``` +1. get_kline_data(code, days=120) → DataFrame (date, open, high, low, close, volume) +2. 若 df 为空或 len(df) < 30 → 跳过,记失败 +3. detect_all_signals(df, lookback=LOOKBACK) → 7 个信号 + 指标 + signal_status +4. 汇总 triggered_count、signal_status、indicators、latest_signals +5. 写入内存缓冲;满 BATCH_SAVE_SIZE 条后批量 INSERT/UPDATE stock_signal_scan +``` + +**并发与批参数**(`full_signal_scan.py`): + +- `WORKERS = 8` +- `BATCH_SIZE = 80`(任务批) +- `BATCH_SAVE_SIZE = 40`(每 40 条写一次库) +- `LOOKBACK = 5`(检测最近 5 天内的信号) + +--- + +## 三、核心算法:detect_all_signals + +位置:`services/signal_detector.py` — `detect_all_signals(df, lookback=5)`。 + +### 3.1 输入输出 + +- **输入**:`df` 需含列 `date, open, high, low, close, volume`;`lookback` 默认 5。 +- **输出**: + `signals`、`latest_signals`、`signal_summary`、`indicators`、**`signal_status`**(用于推荐与展示)。 + +### 3.2 内部步骤 + +1. **列类型**:必要列缺失则返回错误;对 `open/high/low/close/volume` 若非 float 则转成 float64。 +2. **指标计算**:`calc_all_indicators(df)`,得到 MACD、SKDJ、KDJ、EMA、MA 等。 +3. **7 个信号检测**(均在 `lookback` 窗口内): + - `detect_main_rising_wave(df, lookback)` + - `detect_daily_bottom_divergence(df, lookback)` + - `detect_dragon_head(df, lookback)` + - `detect_true_dragon(df, lookback)` + - `detect_short_bottom_divergence(df, lookback)` + - `detect_rat_trading(df, lookback)` + - `detect_rebound(df, lookback)` +4. **汇总**: + - 所有信号按 `(strength 降序, date)` 排序; + - 取 `latest_date` 当天的信号为 `latest_signals`; + - 统计 `signal_summary`; + - 取最后一根 K 的指标 → `indicators`(macd/skdj/kdj/ema/ma); + - **`_check_all_signal_status(df)`** → `signal_status`(每条为 7 个信号的 `triggered/description/readiness` 等)。 + +全景扫描**写库**用的是 `signal_status` 与 `triggered_count`(`signal_status` 中 `triggered==True` 的个数),推荐与策略建议也用同一套 `signal_status` + `compute_recommend`。 + +--- + +## 四、七个信号的判定条件(与 _check_all_signal_status 一致) + +| 序号 | 信号名 | 强度 | 判定逻辑概要(当前 K / 最近窗口) | +|------|--------|------|----------------------------------| +| 1 | **主升浪** | 85% | DIF>0 且 DEA>0,且前一根 DIF≤DEA、当前 DIF>DEA(零上金叉) | +| 2 | **日线底背离** | 80% | 20 日内:收盘价在窗口新低附近(≤1.01×min);当前是窗口最低点;DIF 高于前低且 DIF<0 | +| 3 | **龙抬头** | 75% | SKDJ 曾超跌(K<20 或 K<30);K 上穿 D;最近 3 根 K 的 K 值标准差<15(稳定) | +| 4 | **真龙** | 70% | 4 条件中≥3 且必须含「价格>MA20」:价格>MA20、MA5>MA20、MACD 翻红(柱>0)、放量(>10 日均量 1.2 倍) | +| 5 | **短底背离** | 65% | 10 日内:收盘在窗口新低附近;当前是窗口最低点;DIF 高于 10 日内 DIF 前低 | +| 6 | **老鼠仓** | 60% | 盘中跌幅 (low-open)/open < -3%;回收率 (close-low)/(high-low) > 60%;收盘较开盘跌幅 > -1%;成交量 > 10 日均量 1.3 倍 | +| 7 | **反弹** | 55% | EMA3 前根 ≤ EMA21,当前 EMA3 > EMA21(金叉) | + +- **detect_*** 系列:在 `lookback` 天内逐日检测,若有满足条件的 K 线则生成一条信号(含 date/type/strength/description 等)。 +- **\_check_all_signal_status**:只对**最后一根 K 线**判断 7 个信号是否「当前触发」,并给出描述与就绪度;全景扫描的「是否触发」以 `signal_status[].triggered` 为准。 + +--- + +## 五、结果落库与表结构 + +- **表名**:`stock_signal_scan` +- **写入字段**:`code, name, scan_date, triggered_count, signal_status, indicators, latest_signals` +- **唯一约束**:`(code, scan_date)`,重复写入时 `ON CONFLICT DO UPDATE`。 +- **说明**:脚本不写 `stock_realtime_price`;列表页现价、涨跌幅等来自该表或实时接口。 + +--- + +## 六、与推荐算法的一致性 + +- **统一推荐函数**:`services/stock_algorithms.compute_recommend(signal_status, indicators, triggered_count, is_holding)` + - 被「全景扫描结果列表」「策略建议」「提醒」「模拟交易自动买卖」共用。 +- **体系最强战法**(suanfa.md)在推荐中的体现: + - 日线底背离 → 纳入关注 + - 龙抬头 → 买入(核心买点) + - 真龙/主升浪 → 持有/加仓 + - 不见主升浪 → 可出场(如 MACD 死叉且无主升浪 → 卖出) + +即:**全景扫描只负责「全量信号检测 + 落库」;「买/卖/加仓/持有/关注/观望」由同一套 `compute_recommend` 基于 `signal_status` 计算,保证与策略建议、提醒、模拟交易一致。** + +--- + +## 七、小结 + +| 项目 | 内容 | +|------|------| +| 入口 | `full_signal_scan.py`,按日断点续扫 | +| 股票池 | `stock_realtime_price` 全表 code+name | +| K 线 | 120 日,本地 DB → 阿里 → 腾讯 → 麦蕊 → AKShare | +| 核心函数 | `detect_all_signals(df, lookback=5)` → 7 信号 + `signal_status` | +| 7 信号 | 主升浪(85%)、日线底背离(80%)、龙抬头(75%)、真龙(70%)、短底背离(65%)、老鼠仓(60%)、反弹(55%) | +| 落库 | `stock_signal_scan`,不写价格 | +| 推荐 | 与策略/提醒/模拟交易共用 `compute_recommend(signal_status, ...)` | + +如需调整「哪些算触发」,只需改 `signal_detector.py` 中对应 `detect_*` 与 `_check_all_signal_status`;如需调整买卖建议,只需改 `stock_algorithms.compute_recommend`。 + +--- + +## 八、全景扫描结果的「每日推荐买入」是如何获取的? + +### 8.1 数据来源 + +- **每日推荐**依赖的是**当日(或最近一次)全景扫描**的结果表 **`stock_signal_scan`**。 +- 若当天没有跑全量扫描,接口会**自动回退**到「最近一次有数据的 `scan_date`」(见 8.2)。 + +### 8.2 接口与参数 + +- **接口**:`GET /api/scan_results`(`routes/analysis.py` — `get_scan_results()`)。 +- **关键参数**: + - `date`:扫描日期,默认当天;若该日无数据且未显式传 `date`,则用 `MAX(scan_date)` 回退。 + - `recommend_text`:推荐文案筛选,例如 **`买入`**、`加仓`、`持有`、`关注`、`观察`、`观望`、`卖出`。 + - `holding_codes`:当前用户持仓 code 列表(逗号分隔),用于区分「持仓 / 非持仓」下的推荐。 + +### 8.3 「推荐买入」的获取流程(recommend_text=买入) + +1. **按日期取扫描数据** + 从 `stock_signal_scan` 中取出 `scan_date` 当天的**全部**记录: + `code, signal_status, indicators, triggered_count`。 + +2. **逐条计算推荐** + 对每条记录调用统一推荐函数(`_compute_recommend` → `stock_algorithms.compute_recommend`): + ```text + _compute_recommend(signal_status, indicators, triggered_count, is_holding) + ``` + - `is_holding = (code in holding_codes)` + 返回 `(signal_type, display_text, reason, recommend_rate)`,其中 **display_text** 即「买入 / 加仓 / 持有 / 关注 / 观察 / 观望 / 卖出」。 + +3. **筛选「买入」** + 只保留 **`display_text == recommend_text`**(例如 `display_text == '买入'`)的股票,得到「每日推荐买入」列表。 + +4. **排序与分页** + 按 `triggered_count` 降序、其次 `code` 升序排序;再按 `page`、`per_page` 分页,取当前页的 code 列表。 + +5. **补全当前页展示数据** + 用当前页的 code 再查 `stock_signal_scan` + `stock_realtime_price`,对每条再次调用 `_compute_recommend`,得到 `recommend_text`、`recommend_reason`、现价等,返回前端。 + +### 8.4 何时会得到「买入」? + +由 **`compute_recommend`**(体系最强战法)决定,**非持仓**时出现「买入」的条件包括: + +- **最佳买入**:日线底背离 + 龙抬头,且 MACD 未死叉 → `display_text = '买入'`(评分 95)。 +- **龙抬头买入**:有龙抬头且 MACD 金叉或暂无数据 → `display_text = '买入'`(评分 80);若同时有主升浪且 MACD 正常 → 也是「买入」(评分 90)。 +- 若 MACD 死叉则会被降级为「关注」等,不会出现在「推荐买入」筛选中。 + +因此:**「每日推荐买入」= 当日(或回退日)`stock_signal_scan` 中,在给定 `holding_codes` 下经 `compute_recommend` 计算得到 `display_text == '买入'` 的股票列表,经排序分页后返回。** + +### 8.5 与策略建议的关系 + +- **策略建议**(`GET /api/scan_strategy`)同样读 `stock_signal_scan` 当日数据,对每条记录调用同一个 `_compute_recommend`,再按 `display_text` 分成 4 档(立即买入、持仓加仓、关注、纳入关注)。 +- 「立即买入」档即 **`display_text == '买入'`**,与全景扫描里筛选 `recommend_text=买入` 的列表**算法一致**,只是接口与展示形式不同(一为分档统计,一为分页列表)。 diff --git a/stock-html/docs/应用算法总结.md b/stock-html/docs/应用算法总结.md new file mode 100644 index 0000000..c803817 --- /dev/null +++ b/stock-html/docs/应用算法总结.md @@ -0,0 +1,207 @@ +# 本应用全部算法总结 + +## 一、全景扫描与策略建议 + +### 1.1 全景扫描(全量信号扫描) + +**触发**:用户点击「全景扫描」或定时任务(如凌晨 01:00,crontab `0 1 * * 1-5`)。 + +**数据源**: +- 股票列表:从表 **`stock_realtime_price`** 读取全部 `code, name`(即全市场股票)。 +- K 线:优先 **麦蕊智数** `get_kline(period='d', days=120)`,备用 **AKShare** 日 K。 + +**算法流程**: +1. 脚本 `full_signal_scan.py` 按日 `scan_date` 运行,支持断点续扫(已扫过的 code 跳过)。 +2. 每只股票:取约 120 日 K 线 → 调用 **`detect_all_signals(df, lookback=5)`**(`services/signal_detector.py`)检测 7 个信号。 +3. 7 个信号:主升浪(85%)、日线底背离(80%)、龙抬头(75%)、真龙(70%)、短底背离(65%)、老鼠仓(60%)、反弹(55%)。每个信号得到是否触发、描述等。 +4. 结果写入表 **`stock_signal_scan`**:`code, name, scan_date, triggered_count, signal_status, indicators, latest_signals`。 + (**注意**:本脚本不写入价格;表 `stock_realtime_price` 由其他定时任务或接口更新。) + +**并发**:`WORKERS=3`,`BATCH_SIZE=30`,多线程按批处理。 + +--- + +### 1.2 策略建议(分档买卖建议) + +**触发**:用户点击「策略建议」。 + +**接口**:`GET /api/scan_strategy`,可选 `date`、`holding_codes`(逗号分隔)。 + +**算法**: +1. 从 **`stock_signal_scan`** 读取当日 `scan_date` 全部记录(`signal_status, indicators, triggered_count`)。 +2. 对每条记录调用 **统一推荐函数 `_compute_recommend(signal_status, indicators, triggered_count, is_holding)`**(与扫描结果、提醒共用): + - **持仓**:MACD 死叉且无主升浪 → 卖出;有主升浪 → 加仓;有真龙 → 持有;否则 → 观望。 + - **非持仓**:底背离+龙抬头 → 买入;主升浪 → 加仓;真龙 → 关注;MACD 死叉 → 卖出;仅底背离 → 关注;仅龙抬头 → 关注;其他触发 → 观察;无 → 观望。 +3. 按推荐文案分档: + - **档1 立即买入**:`disp == '买入'`(底背离+龙抬头)。 + - **档2 持仓加仓**:`disp in ('加仓','持有')`。 + - **档3 关注**:`disp == '关注'` 且龙抬头触发、无底背离。 + - **档4 纳入关注**:`disp == '关注'` 且其余(如仅底背离)。 +4. 返回 4 档的 `action/condition/desc/count/stocks`,前端展示。 + +**与全景扫描结果**:使用同一套 `_compute_recommend`,算法一致。 + +--- + +### 1.3 扫描结果列表(GET /api/scan_results) + +**触发**:全景扫描完成后前端拉取或切换筛选/分页。 + +**接口**:`GET /api/scan_results`,参数:`date, min_triggered, signal_type, holding_codes, recommend_text, page, per_page, sort`。 + +**算法**: +1. 从 **`stock_signal_scan`** 按 `scan_date` 筛选,可选按 `triggered_count`、信号类型过滤。 +2. 若有 **`recommend_text`**(如「买入」「关注」):全量读出当日扫描,逐条 `_compute_recommend`,统计各推荐数量,筛出 `disp == recommend_text` 的 code,再分页。 +3. 否则:按 `triggered_count` 等排序分页,LEFT JOIN **`stock_realtime_price`** 取 `price, change_pct`。 +4. 对当前页每条记录再算一次 **`_compute_recommend`**(带入 `holding_codes`),得到 `recommend_type/recommend_text` 等返回前端。 +5. 列表中的**现价**来自表 **`stock_realtime_price`**(与全量扫描脚本无直接关系,需另有时效性更新)。 + +--- + +## 二、检查信号与批量扫描关注 + +### 2.1 单只检查信号(技术信号详情) + +**触发**:在「技术信号」里输入/选择股票并查询,或从扫描列表点击某只股票。 + +**接口**:`GET /api/technical_signals/?lookback=5&days=120`。 + +**算法**: +1. 用 **`_get_kline_data(stock_code, days)`** 取 K 线(优先麦蕊智数,备用 AKShare)。 +2. 调用 **`detect_all_signals(kline_df, lookback=5)`**,得到 7 个信号的触发情况、指标、说明。 +3. 返回 `signals, latest_signals, signal_summary, indicators, signal_status`。 + **不读** `stock_signal_scan`,**不读** `stock_realtime_price`,纯实时 K 线+本地计算。 + +--- + +### 2.2 批量扫描关注 + +**触发**:用户点击「批量扫描关注」。 + +**接口**:`POST /api/batch_technical_signals`,body:`{ codes: [关注列表的 code], lookback: 3, days: 120 }`。 + +**算法**: +1. 对 `codes` 中每只(最多 20 只):取 K 线 → **`detect_all_signals`** → 得到 `signal_status, triggered_count, latest_signals, indicators`。 +2. 结果仅用于当前页展示,**不写入** `stock_signal_scan`,**不写入** `stock_realtime_price`。 +3. 即:批量扫描关注 = 多只股票各自走一遍「单只检查信号」逻辑,无持久化。 + +--- + +## 三、模拟交易及查看时的现价 + +### 3.1 模拟持仓与统计中的现价 + +**数据来源**: +- 表 **`sim_positions`** 存有每只持仓的 **`current_price`**(上次更新时的现价)。 +- 列表/统计接口(如 `GET /api/sim/positions`、`GET /api/sim/stats`)直接读该字段,**不在此处调实时接口**。 + +**现价何时更新**: +- **手动刷新**:在「分析 → 模拟交易」页的 **当前持仓** 区块,点击 **「刷新现价」** 按钮时,会调用 **`updateSimPrices()`**: + - 对每条持仓并行请求 **`GET /api/realtime_price/`**(12s 超时); + - 拿到价格后写回前端展示,并 **`POST /api/sim/update_prices`**,将 `{ stock_code: price }` 写入 **`sim_positions.current_price`**,并刷新统计。 +- 进入模拟交易子页(`loadSimData()`)时**不会**自动拉实时价,只读库中的 `current_price`。 +- 后端定时任务(如 scheduler)也可按配置更新 `sim_positions.current_price`(若已实现)。 + +**结论**:模拟交易「查看」时的现价 = 库中 **`sim_positions.current_price`**;**只有用户点击「刷新现价」**(或定时任务)时,才通过 **实时 API** 经 **`/api/realtime_price`** 再经 **`/api/sim/update_prices`** 写入。 + +--- + +## 四、提醒 tab 下的刷新 + +**触发**:进入提醒 tab 或用户点击刷新(含强制刷新)。 + +**涉及股票**:**关注列表**(searchHistory)+ **当前持仓**(holdingStocks),合并去重得到 `allStocks`。 + +**算法流程**: +1. **优先读缓存**(未强制刷新时):`GET /api/alerts_cache`,若缓存存在且为当日且版本匹配,则用缓存填充 `stockAlerts`,并 `updateHoldingPricesFromAlerts()`,对新加入的股票做增量分析;然后**直接结束**,不再请求信号接口。 +2. **主流程**: + **`POST /api/signal_alerts`**,body:`{ stocks: allStocks, holding_codes: this.holdingStocks }`。 + - 后端从 **`stock_signal_scan`** 取当日扫描结果(`signal_status, indicators, triggered_count`),从 **`stock_realtime_price`** 取 `price, change_pct`。 + - 对每只股票调用 **`_compute_recommend(..., is_holding)`**,得到推荐类型、理由、推荐率等,并与价格一起返回。 + - 前端用返回结果覆盖 `stockAlerts`,保存缓存,并 **`updateHoldingPricesFromAlerts()`**(用提醒里的 price 回填持仓的 currentPrice)。 +3. **后台补齐实时价**: + 调用 **`refreshAlertPricesFromRealtime()`**(不传参 = 刷新全部 `stockAlerts`): + - 每批 5 只,**`GET /api/realtime_price/`**(超时 12s),用返回价格覆盖对应 alert 的 `price` 及 `latest_data['收盘价']`; + - 更新后 **`saveAlertsCache`**、**`updateHoldingPricesFromAlerts()`**。 + 即:提醒列表的**最终展示价**来自**实时接口**,不是表里旧值。 + +**小结**: +- 信号与推荐:**`stock_signal_scan`** + **`_compute_recommend`**,价格初值来自 **`stock_realtime_price`**。 +- 最终现价:**实时 API**(`/api/realtime_price` → 麦蕊智数等)通过 **`refreshAlertPricesFromRealtime()`** 覆盖。 + +--- + +## 五、交易 tab 下的刷新价格 + +**触发**:用户在交易 tab 点击「刷新价格」。 + +**涉及股票**:仅 **当前持仓**(`holdingPositions` 中 `quantity > 0` 的 code)。 + +**算法**(与提醒共用一套逻辑): +1. 取持仓 code 列表 **`holdingCodes`**。 +2. 设置 **`priceRefreshing = true`**,调用 **`refreshAlertPricesFromRealtime(holdingCodes, 12000)`**: + - 若持仓在 **`stockAlerts`** 中存在:按与提醒相同的逻辑,每批 5 只请求 **`GET /api/realtime_price/`**,更新 alert 的 `price`,并 **`updateHoldingPricesFromAlerts()`**,从而更新 **`holdingPositions[code].currentPrice`** 及统计。 + - 若某持仓不在 `stockAlerts` 中:仍对该 code 单独请求 **`GET /api/realtime_price/`**,直接写 **`holdingPositions[code].currentPrice`**,并触发 `calculateTradeStats()`。 +3. 结束后 **`checkStopLoss()`**,**`priceRefreshing = false`**。 + +**接口统一**: +- 提醒与交易刷新现价均使用 **同一接口** **`GET /api/realtime_price/`**,后端为 **`get_realtime_price(stock_code)`**(优先麦蕊智数,备用 AKShare),**不读** `stock_realtime_price` 表。 + +--- + +## 附录:关键数据流一览 + +| 场景 | 股票范围 | 信号/推荐来源 | 现价来源(最终展示) | +|------|----------|----------------|----------------------| +| 全景扫描 | 全市场(stock_realtime_price 表) | detect_all_signals,写入 stock_signal_scan | 扫描不写价格;列表用 stock_realtime_price | +| 策略建议 | 当日 stock_signal_scan 全量 | _compute_recommend | 不展示单股现价 | +| 扫描结果列表 | 按筛选/分页 | stock_signal_scan + _compute_recommend | stock_realtime_price 表 | +| 单只/批量检查信号 | 用户选定/关注列表 | 实时 K 线 + detect_all_signals | 不涉及现价 | +| 模拟交易查看 | 模拟持仓 | sim_positions.current_price | 刷新时:/api/realtime_price → update_prices | +| 提醒刷新 | 关注+持仓 | stock_signal_scan + _compute_recommend;初价 stock_realtime_price | refreshAlertPricesFromRealtime → /api/realtime_price | +| 交易刷新价格 | 持仓 | 无信号重算 | refreshAlertPricesFromRealtime(holdingCodes) → /api/realtime_price | + +**统一推荐逻辑**:**`_compute_recommend(signal_status, indicators, triggered_count, is_holding)`** 用于:策略建议、扫描结果列表的推荐列、提醒的推荐与理由。信号类型键与 `signal_detector` 一致(如真龙为 **`true_dragon`**)。 + +--- + +## 算法与逻辑是否一致? + +### 一致的部分 + +1. **信号检测** + - 全景扫描(full_signal_scan)、单只检查信号、批量扫描关注,均使用 **同一套** **`detect_all_signals`**(`services/signal_detector.py`),7 个信号定义与判定一致。 + - 唯一区别:全景扫描写库(`stock_signal_scan`),单只/批量不写库。 + +2. **推荐逻辑** + - 策略建议、扫描结果列表的推荐列、提醒的买卖/观望结论,均使用 **同一函数** **`_compute_recommend`**(`routes/analysis.py`),同一只股票在相同持仓状态下会得到相同推荐(买入/加仓/持有/关注/观察/观望/卖出)。 + - 策略建议的 4 档(立即买入、持仓加仓、关注、纳入关注)即按该推荐结果分组,无第二套规则。 + +3. **现价刷新(提醒与交易)** + - 提醒 tab 的「用实时价覆盖」与交易 tab 的「刷新价格」共用 **同一方法** **`refreshAlertPricesFromRealtime(codesOnly?, timeoutMs)`**,同一接口 **`GET /api/realtime_price/`**,同一后端 **`get_realtime_price`**(麦蕊智数优先,AKShare 备用)。 + - 逻辑一致:按 code 列表分批请求、写回 alert/持仓、更新缓存与统计。 + +4. **持仓状态** + - 提醒、策略建议、扫描结果列表都使用同一套 **`holding_codes`**(前端传 `holdingStocks`),**`_compute_recommend`** 的 `is_holding` 与真实持仓一致,故「持有/加仓/卖出」等与是否持仓一致。 + +### 需注意的差异(非矛盾) + +1. **数据来源与时效** + - **全景扫描 / 策略建议 / 扫描结果 / 提醒(初值)**:依赖 **当日** `stock_signal_scan`(及 `stock_realtime_price`)。若今日未跑全量扫描,则无当日信号,提醒会显示「今日尚未扫描此股」等。 + - **单只/批量检查信号**:不读库,用**当前 K 线**实时算,与库内扫描结果可能不同(日期或数据源不同)。 + - 设计如此:全量扫描是「当日快照」,单只/批量是「实时计算」,二者用途不同,不要求数值完全一致。 + +2. **现价来源** + - **列表/表内展示**(扫描结果、提醒初值):来自 **`stock_realtime_price`** 表(由定时或其它任务更新)。 + - **用户主动刷新后**(提醒、交易、模拟持仓):来自 **实时 API**(`/api/realtime_price`)。 + - 即:先表后实时,两段一致(同一实时接口),只是数据源阶段不同。 + +3. **模拟交易现价** + - 模拟持仓的 **`current_price`** 仅在使用「刷新」或定时更新时从实时接口写入;查看时只读库,不自动调实时接口。与「真实交易 tab」的持仓现价逻辑相同(都是刷新时才拉实时价)。 + +### 结论 + +- **信号检测**:全应用共用 **`detect_all_signals`**,一致。 +- **推荐与分档**:策略/扫描结果/提醒共用 **`_compute_recommend`**,一致。 +- **现价刷新**:提醒与交易共用 **`refreshAlertPricesFromRealtime`** 与 **`/api/realtime_price`**,一致。 +- **差异**仅在于:谁写库、谁读库、何时用表价/何时用实时价,属设计上的分工,不是算法或逻辑不一致。 diff --git a/stock-html/docs/推荐回测方案-约定与设计.md b/stock-html/docs/推荐回测方案-约定与设计.md new file mode 100644 index 0000000..00f75a7 --- /dev/null +++ b/stock-html/docs/推荐回测方案-约定与设计.md @@ -0,0 +1,131 @@ +# 推荐算法回测方案 — 约定与设计 + +## 一、回测规则约定(已确认) + +| 项目 | 约定 | 说明 | +|------|------|------| +| **10:00 买入依据** | 用**前一交易日收盘后**的全景扫描推荐 | 与当前应用一致,无未来数据 | +| **15:00 加仓/清仓依据** | 用**当天中午**的全景扫描数据 | 即当日 11:50 左右的扫描结果(与现有定时任务一致) | +| **成交价格** | 用**当时的实时价格** | 10:00 买入用 10:00 附近价,15:00 操作用 15:00 附近价;若无分钟数据则需约定近似方式 | +| **选股** | **每天最多买 2 只** | 全市场从「买入」中按推荐分取前 2 只,每只 1000 股(可配置 `MAX_BUYS_PER_DAY`) | + +## 二、时间线小结 + +- **T 日 10:00**:根据 **T-1 日收盘后** 的扫描结果,若出现「买入」则按约定选**最多 2 只**(推荐分从高到低),每只以 **10:00 实时价** 买入 1000 股。 +- **T 日 15:00**:根据 **T 日中午**(约 11:50)的扫描结果,对当前持仓做 `compute_recommend(..., is_holding=True)`,若为「加仓」则以 **15:00 实时价** 加仓 1000 股,若为「卖出」则以 **15:00 实时价** 清仓。 +- 价格:优先使用「当时实时价格」;回测若无分钟/实时数据,需在实现中明确近似规则(见下)。 + +## 三、实现要点与数据需求 + +### 3.1 10:00 推荐与价格 + +- **推荐**:对每个交易日 T,用 **T-1 收盘** 的日 K 跑 `detect_all_signals`,再 `compute_recommend(..., is_holding=False)`,筛出「买入」,按约定选单只(如推荐分最高或信号数最多)。 +- **价格**:理想为 T 日 10:00 实时价;若无分钟数据,可用 **T 日开盘价** 作为近似,并在文档/结果中注明。 + +### 3.2 15:00 推荐与价格(当天中午扫描) + +- **推荐**:使用 **T 日中午全景扫描** 结果。 + - 若回测期内有**历史中午扫描落库**(如 `stock_signal_scan` 带 scan_time 或 scan_date=当日且标记为午扫),可直接用。 + - 若没有,则需在回测中**模拟「当日中午」的扫描**:用 T 日 11:50 前可得的数据(例如 T-1 日 K + T 日 open,或若有分钟数据则用 T 日 11:30 前数据)跑一次 `detect_all_signals` + `compute_recommend`,作为当日 15:00 决策依据,且不引入 T 日 15:00 之后的数据。 +- **价格**:理想为 T 日 15:00 实时价;若无分钟数据,可用 **T 日收盘价** 作为近似,并注明。 + +### 3.3 实时价格的回测实现 + +- **有实时/分钟数据**:按 10:00 / 15:00 时刻取价。 +- **仅日 K**:在方案中明确写「回测采用:10:00 用当日 open,15:00 用当日 close」,并视为对「当时实时价格」的近似,在结果与文档中统一说明。 + +## 四、选股规则(每天最多买 2 只) + +- 在「买入」列表中按推荐分从高到低排序,**最多选 2 只**(`MAX_BUYS_PER_DAY=2`): + - 按 `recommend_rate` 从高到低,同分再按 `triggered_count` 或信号强度排序; + - 每只买入 1000 股(开盘价)。 +- 若当日无「买入」,则不新开仓;仅对已有持仓做 15:00 的加仓/清仓判断。 + +## 五、回测程序与运行 + +1. **脚本位置**:`stock-html/backtest_recommend.py` +2. **依赖**:需存在表 **`stock_kline_daily`** 且含 2026 年及以后的日 K 数据(可先运行 `sync_kline.py` 等同步脚本)。 +3. **运行**:在项目根目录下执行 + ```bash + cd stock-html && ./venv/bin/python backtest_recommend.py + ``` +4. **输出**:控制台打印交易记录 + 核心统计(胜率/回撤/持仓天数/盈亏比/个股明细);结果写入 `docs/backtest_result.json`。 +5. **约定**:10:00 用 T-1 扫描 + 开盘价,15:00 用当日中午扫描(T-1 + T 日 open 模拟)+ 收盘价,每天最多买 2 只(推荐分从高到低取前 2)。 + +### v2 优化(2026-02-25) + +| 优化项 | 说明 | +|--------|------| +| **股价区间过滤** | 仅扫描 2~100 元股票,避免仙股和高价股导致金额失衡 | +| **单只最大投入** | ¥30,000 上限(含加仓),防止单只占比过高 | +| **最大同时持仓** | 8 只,分散风险 | +| **卖出冷却期** | 卖出后 3 天内不再买入同只,防止反复买卖 | +| **批量 K 线加载** | 用窗口函数一次查全市场 K 线,减少 DB 往返约 10 倍 | +| **丰富统计指标** | 胜率、最大回撤、平均持仓天数、盈亏比、个股盈亏明细 | +| **对比表增强** | 15 场景对比增加胜率/回撤/盈亏比列,标注最优场景 | + +### v3 优化(2026-02-25) + +| 优化项 | 说明 | +|--------|------| +| **基于预扫描表** | 直接从 `stock_scan_history` 读取扫描结果,无需现场计算,秒级完成回测 | +| **修复最大回撤计算** | 改为基于每日组合权益(持仓市值 + 累计现金流)的回撤,修复之前用现金流计算导致数值虚高的问题 | +| **新增回撤百分比** | 输出 `max_drawdown_pct`(最大回撤 / 总投入资金 × 100%) | +| **运算符优先级修复** | 修复 `action_taken` 判断条件的括号缺失问题 | + +以上约定已写入本文档,作为实现推荐回测的统一依据。 + +--- + +## 六、清仓与止盈推荐(目的:挣钱而非单纯持有) + +当前回测里**清仓**只做一件事:**推荐为「卖出」时按收盘价清仓**(即 MACD 死叉且无主升浪)。 +目的是「按信号纪律出场」,但**没有**针对「高点落袋」的规则,可能拿很久才等到卖出信号,回吐利润。 + +### 6.1 建议增加的清仓/止盈方式 + +在保留「信号卖出」的前提下,可增加**以挣钱为导向**的止盈类规则,例如: + +| 类型 | 说明 | 目的 | +|------|------|------| +| **固定止盈** | 持仓浮盈 ≥ X%(如 10%、15%)时,当日 15:00 清仓 | 到点落袋,不贪最后一笔 | +| **高点回撤止盈** | 从持仓期间最高价回撤 ≥ Y%(如 8%)时清仓 | 近似「高点跑」,锁定大部分利润 | +| **信号卖出(现有)** | 推荐为「卖出」时清仓 | 趋势走弱时离场 | + +可只选一种,或**组合**:先看是否触发止盈,若未触发再看是否触发「卖出」;若都未触发则继续持有或按原规则加仓。 + +### 6.2 推荐用法(兼顾挣钱与纪律) + +- **优先**:在回测中增加 **固定止盈**(如 10%): + - 每个交易日 15:00 前,若 **持仓收益率 ≥ 10%**,则当日 15:00 按收盘价清仓,视为「高点跑」一次。 +- **可选**:再增加 **高点回撤止盈**(如 8%): + - 若持仓期间最高价到当前价的回撤 ≥ 8%,则当日 15:00 清仓。 +- **保留**:若未触发上述止盈,仍按当前逻辑:推荐「卖出」则清仓,「加仓」则加仓,「持有」则不动。 + +这样既保留「按推荐买卖」的纪律,又明确加入「高点跑 / 落袋为安」的推荐,更贴近「目的是挣钱」。 + +--- + +## 七、如何提供/提升收益率 + +### 7.1 当前收益率指标 + +回测脚本已输出并写入 `backtest_result.json`: + +- **收益率** = (总收回 - 总投入) / 总投入 × 100%,即整段回测的累计收益。 +- **年化收益率** = (1 + 收益率)^(365/回测天数) − 1,折算为「若按同样节奏跑满一年」的大致水平,便于和理财/指数对比。 + +v2 已内置更多指标:胜率、最大回撤、平均持仓天数、盈亏比、个股盈亏明细,均在控制台和 JSON 中输出。 + +### 7.2 可操作的提升方向 + +| 方向 | 做法 | 说明 | +|------|------|------| +| **止盈** | 使用 `--take-profit 10` 等 | 浮盈到点即走,减少回吐;可多试 8%、12% 等找合适区间。 | +| **止损** | 在回测中增加「浮亏达 X% 清仓」 | 控制单笔最大亏损,避免深套。 | +| **高点回撤止盈** | 从持仓最高价回撤 Y% 时清仓 | 贴近「高点跑」,锁住大部分利润。 | +| **选股过滤** | 在「买入」列表中提高门槛 | 如仅选 recommend_rate≥90 或 triggered_count≥2,减少弱信号。 | +| **仓位/频率** | 每日买一只 vs 仅无仓时买 | 当前为每日买一只;若改为仅无仓时买,收益曲线会不同,可对比。 | +| **参数扫描** | 对止盈比例、止损比例等做网格 | 批量回测不同参数,看哪组年化/回撤更优。 | + +建议先固定一套规则(如 止盈 10% + 信号卖出),跑出基准年化与回撤,再逐项加「止损」「高点回撤止盈」或选股过滤,对比同一区间下收益率与回撤的变化,再决定是否采用。 diff --git a/stock-html/full_signal_scan.py b/stock-html/full_signal_scan.py new file mode 100644 index 0000000..801a4eb --- /dev/null +++ b/stock-html/full_signal_scan.py @@ -0,0 +1,462 @@ +#!/usr/bin/env python3 +""" +全量股票技术信号扫描脚本 +对数据库中的全部股票进行7个技术信号检测,结果存入 stock_signal_scan 表 +支持断点续扫、并发处理、进度报告 +""" + +import sys +import os +import time +import json +import threading +import signal as sig_module +from datetime import datetime, date, timedelta +from concurrent.futures import ThreadPoolExecutor, as_completed + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import pandas as pd +import psycopg2 +from psycopg2.extras import Json + +from config import Config +from services.signal_detector import detect_all_signals +from services.stock_algorithms import ( + get_kline_from_local_db_threaded, + get_ali_session, get_tencent_session, + code_to_ali_symbol, code_to_tencent_symbol, is_bj_stock, + ALICLOUD_KLINE_URL, TENCENT_KLINE_URL, +) + +WORKERS = 8 +BATCH_SIZE = 80 +BATCH_SAVE_SIZE = 40 +K_DAYS = 120 +LOOKBACK = 5 + +_shutdown = False + + +def signal_handler(signum, frame): + global _shutdown + print("\n⚠️ 收到中断信号,正在优雅退出...") + _shutdown = True + + +sig_module.signal(sig_module.SIGINT, signal_handler) +sig_module.signal(sig_module.SIGTERM, signal_handler) + + +def get_db_conn(): + return psycopg2.connect( + host=Config.DB_HOST, port=Config.DB_PORT, + dbname=Config.DB_NAME, user=Config.DB_USER, password=Config.DB_PASSWORD, + ) + + +def get_all_stock_codes(conn): + with conn.cursor() as cur: + cur.execute("SELECT code, name FROM stock_realtime_price ORDER BY code") + return cur.fetchall() + + +def get_scanned_codes(conn, scan_date): + with conn.cursor() as cur: + cur.execute( + "SELECT code FROM stock_signal_scan WHERE scan_date = %s", + (scan_date,), + ) + return {row[0] for row in cur.fetchall()} + + +def get_kline_data(stock_code, days=K_DAYS): + """获取K线数据:优先本地DB → 阿里云API → 腾讯API → 麦蕊API → AKShare + (使用 services.stock_algorithms 统一的API会话和工具函数)""" + from services.mairui_api import get_kline as _mairui_get_kline + + # 1. 优先从本地数据库读取(多线程安全版本) + df = get_kline_from_local_db_threaded(stock_code, days) + if df is not None: + return df + + # 2. 阿里云K线API(北交所直接跳过,不支持) + if not is_bj_stock(stock_code): + try: + session = get_ali_session() + symbol = code_to_ali_symbol(stock_code) + resp = session.post(ALICLOUD_KLINE_URL, data={ + 'symbol': symbol, 'type': '240', + 'limit': str(min(days, 300)), 'ma': '5', + }, timeout=10) + if resp.status_code == 200: + data = resp.json() + if data.get('success') and data.get('data', {}).get('list'): + records = [] + for item in data['data']['list']: + day_str = item.get('day', '') + if not day_str or len(day_str) < 10: + continue + records.append({ + 'date': day_str[:10], + 'open': float(item.get('open', 0)), + 'high': float(item.get('high', 0)), + 'low': float(item.get('low', 0)), + 'close': float(item.get('close', 0)), + 'volume': float(item.get('volume', 0)), + }) + if len(records) >= 30: + return pd.DataFrame(records) + except Exception: + pass + + # 3. 腾讯K线API(全市场,含北交所) + try: + session = get_tencent_session() + tencent_symbol = code_to_tencent_symbol(stock_code) + start_date = (datetime.now() - timedelta(days=days + 30)).strftime('%Y-%m-%d') + resp = session.get(TENCENT_KLINE_URL, params={ + 'param': f'{tencent_symbol},day,{start_date},,{min(days, 300)},qfq', + }, timeout=15) + if resp.status_code == 200: + data = resp.json() + stock_data = data.get('data', {}).get(tencent_symbol, {}) + klines = stock_data.get('qfqday') or stock_data.get('day') or [] + if len(klines) >= 30: + records = [] + for item in klines: + if len(item) < 6: + continue + records.append({ + 'date': item[0][:10], + 'open': float(item[1]), + 'high': float(item[3]), + 'low': float(item[4]), + 'close': float(item[2]), + 'volume': float(item[5]), + }) + if len(records) >= 30: + return pd.DataFrame(records) + except Exception: + pass + + # 4. 回退到麦蕊API + try: + result = _mairui_get_kline(stock_code, period='d', days=days, adjust='f') + if result['success'] and result['data']: + df = pd.DataFrame(result['data']) + df.rename(columns={ + 'date': 'date', 'open': 'open', 'high': 'high', + 'low': 'low', 'close': 'close', 'volume': 'volume', + }, inplace=True) + if len(df) >= 30: + return df + except Exception: + pass + + # 5. 最后回退到AKShare (支持自动降级到腾讯数据源) + try: + from utils.data_fetcher import fetch_stock_hist + end_date = datetime.now().strftime('%Y%m%d') + start_date = (datetime.now() - timedelta(days=days)).strftime('%Y%m%d') + df = fetch_stock_hist( + stock_code=stock_code, period='daily', + start_date=start_date, end_date=end_date, adjust='qfq', + ) + if df is not None and not df.empty: + df = df.rename(columns={ + '日期': 'date', '开盘': 'open', '最高': 'high', + '最低': 'low', '收盘': 'close', '成交量': 'volume', + }) + df = df[['date', 'open', 'high', 'low', 'close', 'volume']] + return df + except Exception: + pass + + return None + + +def scan_single_stock(code, name): + try: + df = get_kline_data(code) + if df is None or df.empty or len(df) < 30: + return None + + result = detect_all_signals(df, lookback=LOOKBACK) + signal_status = result.get('signal_status', []) + triggered_count = sum(1 for s in signal_status if s.get('triggered')) + + return { + 'code': code, + 'name': name, + 'triggered_count': triggered_count, + 'signal_status': signal_status, + 'indicators': result.get('indicators', {}), + 'latest_signals': result.get('latest_signals', []), + } + except Exception: + return None + + +def _fix_sequence(conn): + """修复序列号,确保不会产生主键冲突""" + try: + with conn.cursor() as cur: + cur.execute(""" + SELECT setval('stock_signal_scan_id_seq', + COALESCE((SELECT max(id) FROM stock_signal_scan), 0) + 1, false + ) + """) + conn.commit() + except Exception as e: + conn.rollback() + print(f"⚠️ 修复序列失败: {e}", flush=True) + + +def save_batch(conn, results, scan_date): + if not results: + return + + # 每批次开始前确保序列正确 + _fix_sequence(conn) + + saved = 0 + for r in results: + try: + with conn.cursor() as cur: + cur.execute("SAVEPOINT sp_insert") + cur.execute(""" + INSERT INTO stock_signal_scan + (code, name, scan_date, triggered_count, signal_status, indicators, latest_signals) + VALUES (%s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (code, scan_date) DO UPDATE SET + name = EXCLUDED.name, + triggered_count = EXCLUDED.triggered_count, + signal_status = EXCLUDED.signal_status, + indicators = EXCLUDED.indicators, + latest_signals = EXCLUDED.latest_signals, + created_at = CURRENT_TIMESTAMP + """, ( + r['code'], r['name'], scan_date, r['triggered_count'], + Json(r['signal_status']), Json(r['indicators']), Json(r['latest_signals']), + )) + cur.execute("RELEASE SAVEPOINT sp_insert") + saved += 1 + except Exception as e: + # 主键冲突时回滚到 savepoint,修复序列后重试 + with conn.cursor() as cur: + cur.execute("ROLLBACK TO SAVEPOINT sp_insert") + if 'UniqueViolation' in type(e).__name__ or 'duplicate key' in str(e): + _fix_sequence(conn) + try: + with conn.cursor() as cur: + cur.execute("SAVEPOINT sp_insert") + cur.execute(""" + INSERT INTO stock_signal_scan + (code, name, scan_date, triggered_count, signal_status, indicators, latest_signals) + VALUES (%s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (code, scan_date) DO UPDATE SET + name = EXCLUDED.name, + triggered_count = EXCLUDED.triggered_count, + signal_status = EXCLUDED.signal_status, + indicators = EXCLUDED.indicators, + latest_signals = EXCLUDED.latest_signals, + created_at = CURRENT_TIMESTAMP + """, ( + r['code'], r['name'], scan_date, r['triggered_count'], + Json(r['signal_status']), Json(r['indicators']), Json(r['latest_signals']), + )) + cur.execute("RELEASE SAVEPOINT sp_insert") + saved += 1 + except Exception as e2: + with conn.cursor() as cur: + cur.execute("ROLLBACK TO SAVEPOINT sp_insert") + print(f"⚠️ 重试保存失败 {r.get('code','?')}: {e2}", flush=True) + else: + print(f"⚠️ 保存失败 {r.get('code','?')}: {e}", flush=True) + conn.commit() + + +def main(): + global _shutdown + + scan_date = date.today() + + force_rescan = os.environ.get('FORCE_RESCAN', '').strip() == '1' + print(f"{'='*60}", flush=True) + print(f"📊 全量股票技术信号扫描(高速版)", flush=True) + print(f"📅 扫描日期: {scan_date}", flush=True) + if force_rescan: + print(f"⚠️ 强制重新扫描模式", flush=True) + print(f"⚙️ 并发数: {WORKERS}, 批保存: {BATCH_SAVE_SIZE}, K线天数: {K_DAYS}", flush=True) + print(f"{'='*60}", flush=True) + + conn = get_db_conn() + + if force_rescan: + with conn.cursor() as cur: + cur.execute("DELETE FROM stock_signal_scan WHERE scan_date = %s", (scan_date,)) + deleted = cur.rowcount + conn.commit() + print(f"🗑️ 已清除今日 {deleted} 条扫描记录", flush=True) + + all_stocks = get_all_stock_codes(conn) + total = len(all_stocks) + print(f"📈 数据库股票总数: {total}", flush=True) + + scanned = get_scanned_codes(conn, scan_date) + if scanned: + print(f"✅ 今日已扫描: {len(scanned)} 只(续扫模式)", flush=True) + + # 过滤退市/ST股票 — 不参与扫描 + _SKIP_TAGS = ('退', 'ST', '*ST', '退市') + pending = [(code, name) for code, name in all_stocks + if code not in scanned and not any(tag in name for tag in _SKIP_TAGS)] + skipped_st = len([1 for _, name in all_stocks if any(tag in name for tag in _SKIP_TAGS)]) + pending_count = len(pending) + if skipped_st > 0: + print(f"🚫 已过滤退市/ST股票: {skipped_st} 只", flush=True) + print(f"⏳ 待扫描: {pending_count} 只", flush=True) + + if pending_count == 0: + print("🎉 今日扫描已全部完成!", flush=True) + show_summary(conn, scan_date) + conn.close() + return + + # 检测本地K线数据是否可用 + with conn.cursor() as cur: + cur.execute("SELECT count(DISTINCT code) FROM stock_kline_daily WHERE trade_date >= CURRENT_DATE - INTERVAL '7 days'") + local_kline_count = cur.fetchone()[0] + if local_kline_count > 0: + print(f"💾 本地K线数据: {local_kline_count} 只股票可用(优先使用本地数据)", flush=True) + else: + print(f"⚠️ 本地无K线数据,将通过API获取(较慢)", flush=True) + + start_time = time.time() + done_count = len(scanned) + error_count = 0 + triggered_total = 0 + total_to_scan = total + save_buffer = [] + last_report_time = time.time() + + print(f"\n🚀 开始扫描...", flush=True) + print(f"-" * 60, flush=True) + + # 使用全局线程池 — 避免反复创建/销毁线程池开销 + with ThreadPoolExecutor(max_workers=WORKERS) as executor: + futures = {} + # 提交所有任务 + for code, name in pending: + if _shutdown: + break + futures[executor.submit(scan_single_stock, code, name)] = (code, name) + + for future in as_completed(futures): + if _shutdown: + print("⏹️ 用户中断,正在保存当前进度...", flush=True) + break + + code, name = futures[future] + done_count += 1 + + try: + result = future.result() + except Exception: + result = None + + if result: + save_buffer.append(result) + if result['triggered_count'] > 0: + triggered_total += 1 + names = [s['name'] for s in result['signal_status'] if s.get('triggered')] + print(f" 🔔 {result['code']} {result['name']}: {', '.join(names)}", flush=True) + else: + error_count += 1 + + # 攒够一批就保存(减少DB写入频率) + if len(save_buffer) >= BATCH_SAVE_SIZE: + save_batch(conn, save_buffer, scan_date) + save_buffer = [] + + # 每3秒报告一次进度(避免刷屏) + now = time.time() + if now - last_report_time >= 3: + elapsed = now - start_time + scanned_this_run = done_count - len(scanned) + speed = scanned_this_run / elapsed if elapsed > 0 else 0 + remaining_stocks = total_to_scan - done_count + remaining_time = remaining_stocks / speed if speed > 0 else 0 + pct = done_count / total_to_scan * 100 + print(f" [{pct:5.1f}%] {done_count}/{total_to_scan} " + f"| 速度: {speed:.1f}只/秒 | 剩余: {remaining_time/60:.1f}分钟 " + f"| 触发: {triggered_total} | 失败: {error_count}", flush=True) + last_report_time = now + + # 保存剩余结果 + if save_buffer: + save_batch(conn, save_buffer, scan_date) + + elapsed = time.time() - start_time + print(f"\n{'='*60}", flush=True) + print(f"✅ 扫描{'中断' if _shutdown else '完成'}!", flush=True) + print(f" 扫描: {done_count} 只 | 耗时: {elapsed/60:.1f}分钟", flush=True) + print(f" 触发信号: {triggered_total} 只 | 失败: {error_count} 只", flush=True) + final_speed = (done_count - len(scanned)) / elapsed if elapsed > 0 else 0 + print(f" 平均速度: {final_speed:.1f} 只/秒", flush=True) + print(f"{'='*60}", flush=True) + + show_summary(conn, scan_date) + conn.close() + + +def show_summary(conn, scan_date): + print(f"\n📊 扫描结果摘要({scan_date})", flush=True) + print(f"-" * 60, flush=True) + + with conn.cursor() as cur: + cur.execute(""" + SELECT count(*), + coalesce(sum(case when triggered_count > 0 then 1 else 0 end), 0) + FROM stock_signal_scan WHERE scan_date = %s + """, (scan_date,)) + total, triggered = cur.fetchone() + print(f" 总扫描: {total} 只 | 有信号: {triggered} 只", flush=True) + + cur.execute(""" + SELECT code, name, triggered_count, signal_status + FROM stock_signal_scan + WHERE scan_date = %s AND triggered_count > 0 + ORDER BY triggered_count DESC + LIMIT 30 + """, (scan_date,)) + rows = cur.fetchall() + + if rows: + print(f"\n🔔 触发信号TOP30:", flush=True) + for code, name, tc, status in rows: + signals = status if isinstance(status, list) else json.loads(status) if status else [] + names = [s['name'] for s in signals if s.get('triggered')] + print(f" {code} {name:8s} | {tc}个信号: {', '.join(names)}", flush=True) + else: + print(f"\n 暂无触发信号的股票", flush=True) + + cur.execute(""" + SELECT + s.value->>'name' as signal_name, + count(*) as cnt + FROM stock_signal_scan, jsonb_array_elements(signal_status) s + WHERE scan_date = %s AND (s.value->>'triggered')::boolean = true + GROUP BY s.value->>'name' + ORDER BY cnt DESC + """, (scan_date,)) + signal_dist = cur.fetchall() + if signal_dist: + print(f"\n📈 信号分布:", flush=True) + for name, cnt in signal_dist: + print(f" {name}: {cnt} 只", flush=True) + + +if __name__ == '__main__': + main() diff --git a/stock-html/get-data.py b/stock-html/get-data.py new file mode 100644 index 0000000..b1aad81 --- /dev/null +++ b/stock-html/get-data.py @@ -0,0 +1,20 @@ +import akshare as ak + +# 获取个股资金流向排名(全市场) +# 返回股票主力净流入、超大单、大单、中单、小单排名 +df = ak.stock_individual_fund_flow_rank(indicator="今日") +print("=== 今日个股资金流向排名 ===") +print(df.head(10)) + +# 历史某股主力资金流向 +print("\n=== 平安银行历史资金流向 ===") +df2 = ak.stock_individual_fund_flow(stock="000001", market="sz") # 平安银行 +print(df2.head()) +df2.to_csv("000001_zjlx.csv", index=False) +print("已保存到 000001_zjlx.csv") + +# # B. Tushare Pro(需要注册获取token) +# import tushare as ts +# ts.set_token('你的 token') +# pro = ts.pro_api() +# df = pro.moneyflow(ts_code='000001.SZ', start_date='20240101', end_date='20240122') diff --git a/stock-html/init_db.sql b/stock-html/init_db.sql new file mode 100644 index 0000000..6a36729 --- /dev/null +++ b/stock-html/init_db.sql @@ -0,0 +1,69 @@ +-- 股票投资分析系统 - PostgreSQL 数据库初始化脚本 +-- 运行方式: psql -U postgres -f init_db.sql + +-- 创建数据库(如果不存在) +SELECT 'CREATE DATABASE stock_app' +WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'stock_app')\gexec + +-- 连接到数据库 +\c stock_app + +-- 用户表 +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + available_cash DECIMAL(15,2) DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 关注列表 +CREATE TABLE IF NOT EXISTS watchlist ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + stock_code VARCHAR(10) NOT NULL, + stock_name VARCHAR(50), + added_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, stock_code) +); + +-- 交易记录 +CREATE TABLE IF NOT EXISTS trades ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + stock_code VARCHAR(10) NOT NULL, + stock_name VARCHAR(50), + trade_type VARCHAR(10) NOT NULL, + price DECIMAL(10,4), + quantity INTEGER, + trade_date DATE, + reason TEXT, + result VARCHAR(20), + profit_amount DECIMAL(10,4), + stop_loss_price DECIMAL(10,4), + notes TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 分析缓存 +CREATE TABLE IF NOT EXISTS alerts_cache ( + id SERIAL PRIMARY KEY, + user_id INTEGER UNIQUE REFERENCES users(id) ON DELETE CASCADE, + data JSONB, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 股票名称缓存(公共) +CREATE TABLE IF NOT EXISTS stock_names ( + code VARCHAR(10) PRIMARY KEY, + name VARCHAR(50) +); + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_watchlist_user ON watchlist(user_id); +CREATE INDEX IF NOT EXISTS idx_trades_user ON trades(user_id); +CREATE INDEX IF NOT EXISTS idx_trades_stock ON trades(stock_code); +CREATE INDEX IF NOT EXISTS idx_alerts_user ON alerts_cache(user_id); + +-- 输出结果 +SELECT 'Database initialized successfully!' as status; diff --git a/stock-html/init_sim_trade.sql b/stock-html/init_sim_trade.sql new file mode 100644 index 0000000..cd4cf7a --- /dev/null +++ b/stock-html/init_sim_trade.sql @@ -0,0 +1,72 @@ +-- 模拟交易系统 - PostgreSQL 数据库表 +-- 运行方式: psql -U postgres -d stock_app -f init_sim_trade.sql + +-- 模拟交易记录表 +CREATE TABLE IF NOT EXISTS sim_trades ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + stock_code VARCHAR(10) NOT NULL, + stock_name VARCHAR(50), + trade_type VARCHAR(10) NOT NULL, -- 'buy' 或 'sell' + price DECIMAL(10,4) NOT NULL, + quantity INTEGER NOT NULL DEFAULT 1000, + trade_date DATE NOT NULL, + trade_time TIME, + recommend_rate DECIMAL(5,2), -- 推荐率 + signal_reason TEXT, -- 交易信号原因 + commission DECIMAL(10,4) DEFAULT 0, -- 佣金 (万2.5, 最低5元) + stamp_tax DECIMAL(10,4) DEFAULT 0, -- 印花税 (千1, 仅卖出) + total_fee DECIMAL(10,4) DEFAULT 0, -- 总手续费 + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 模拟持仓表 +CREATE TABLE IF NOT EXISTS sim_positions ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + stock_code VARCHAR(10) NOT NULL, + stock_name VARCHAR(50), + quantity INTEGER NOT NULL DEFAULT 0, + avg_cost DECIMAL(10,4) NOT NULL DEFAULT 0, + total_cost DECIMAL(15,4) NOT NULL DEFAULT 0, + current_price DECIMAL(10,4), + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, stock_code) +); + +-- 模拟交易统计表(每日汇总) +CREATE TABLE IF NOT EXISTS sim_daily_stats ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + stat_date DATE NOT NULL, + total_market_value DECIMAL(15,4) DEFAULT 0, -- 总市值 + total_cost DECIMAL(15,4) DEFAULT 0, -- 总成本 + unrealized_profit DECIMAL(15,4) DEFAULT 0, -- 浮动盈亏 + realized_profit DECIMAL(15,4) DEFAULT 0, -- 已实现盈亏 + total_profit DECIMAL(15,4) DEFAULT 0, -- 总盈亏 + trade_count INTEGER DEFAULT 0, -- 当日交易次数 + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, stat_date) +); + +-- 模拟交易配置表 +CREATE TABLE IF NOT EXISTS sim_config ( + id SERIAL PRIMARY KEY, + user_id INTEGER UNIQUE REFERENCES users(id) ON DELETE CASCADE, + initial_capital DECIMAL(15,4) DEFAULT 1000000, -- 初始资金(默认100万) + trade_quantity INTEGER DEFAULT 1000, -- 每次交易数量 + auto_trade_enabled BOOLEAN DEFAULT true, -- 是否启用自动交易 + auto_trade_time TIME DEFAULT '10:00:00', -- 自动交易时间 + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_sim_trades_user ON sim_trades(user_id); +CREATE INDEX IF NOT EXISTS idx_sim_trades_date ON sim_trades(trade_date); +CREATE INDEX IF NOT EXISTS idx_sim_trades_stock ON sim_trades(stock_code); +CREATE INDEX IF NOT EXISTS idx_sim_positions_user ON sim_positions(user_id); +CREATE INDEX IF NOT EXISTS idx_sim_daily_stats_user_date ON sim_daily_stats(user_id, stat_date); + +-- 输出结果 +SELECT 'Simulation trade tables created successfully!' as status; diff --git a/stock-html/init_smart_trade.sql b/stock-html/init_smart_trade.sql new file mode 100644 index 0000000..afdc755 --- /dev/null +++ b/stock-html/init_smart_trade.sql @@ -0,0 +1,169 @@ +-- 智能交易引擎 - 数据库表 +-- 运行方式: psql -U postgres -d stock_app -f init_smart_trade.sql + +-- ═══════════════════════════════════════════════════════ +-- 1. 算法配置表: 每个用户选择的算法及其参数 +-- ═══════════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS sim_algo_config ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + algo_name VARCHAR(100) NOT NULL DEFAULT 'PE50G3+BE8', -- 算法名称 + -- 止盈止损 + take_profit_pct DECIMAL(5,2) DEFAULT 12, -- 止盈百分比 + stop_loss_pct DECIMAL(5,2) DEFAULT 8, -- 止损百分比 + -- 卖出信号处理 + ignore_sell_signal BOOLEAN DEFAULT FALSE, -- 忽略扫描卖出信号 + sell_confirm_days INTEGER DEFAULT 3, -- 卖出信号确认天数 + -- 持仓管理 + max_hold_days INTEGER DEFAULT 60, -- 最大持仓天数 (0=无限) + no_timeout_if_rising BOOLEAN DEFAULT TRUE, -- 连涨中不强制平仓 + -- 资金管理 + total_capital DECIMAL(15,2) DEFAULT 200000, -- 总本金 + position_pct DECIMAL(5,2) DEFAULT 8, -- 单笔仓位占比% + signal_weight BOOLEAN DEFAULT TRUE, -- 信号加权仓位 + -- v6特性: 部分止盈 + partial_exit_pct INTEGER DEFAULT 50, -- 到TP时卖出比例% (0=全卖) + momentum_trail_gap DECIMAL(5,2) DEFAULT 3, -- 跟踪止盈回撤% + -- v6特性: 保本止损 + breakeven_at DECIMAL(5,2) DEFAULT 8, -- 盈利N%后止损移至成本 (0=关闭) + -- v6特性: 动量跟踪 + momentum_tp BOOLEAN DEFAULT FALSE, -- 连涨保护 + momentum_days INTEGER DEFAULT 3, -- 连涨判定天数 + -- v7特性: 交易时间 + buy_time VARCHAR(5) DEFAULT '09:35', -- 买入时间点 + sell_time VARCHAR(5) DEFAULT '13:40', -- 卖出时间点 + -- 启用状态 + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id) +); + +-- ═══════════════════════════════════════════════════════ +-- 2. 持仓元数据表: 跟踪每个持仓的算法状态 +-- ═══════════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS sim_position_meta ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + stock_code VARCHAR(10) NOT NULL, + -- 买入信息 + buy_date DATE NOT NULL, + buy_price DECIMAL(10,4) NOT NULL, + buy_reason TEXT, + buy_signal_rate INTEGER DEFAULT 0, -- 买入时信号强度 + buy_triggered_count INTEGER DEFAULT 0, -- 买入时触发信号数 + -- 算法状态跟踪 + days_held INTEGER DEFAULT 0, -- 已持仓天数 + max_price_since_buy DECIMAL(10,4) DEFAULT 0, -- 买入以来最高价 + consecutive_up_days INTEGER DEFAULT 0, -- 连续上涨天数 + consecutive_sell_signals INTEGER DEFAULT 0, -- 连续卖出信号天数 + -- v6特性状态 + partial_exit_done BOOLEAN DEFAULT FALSE, -- 是否已部分止盈 + breakeven_active BOOLEAN DEFAULT FALSE, -- 保本止损是否激活 + momentum_trailing_active BOOLEAN DEFAULT FALSE, -- 动量跟踪是否激活 + momentum_high_price DECIMAL(10,4) DEFAULT 0, -- 动量跟踪最高价 + -- 当前状态 + current_shares INTEGER DEFAULT 0, -- 当前持股数 + original_shares INTEGER DEFAULT 0, -- 原始买入股数 + last_update_date DATE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, stock_code) +); + +-- ═══════════════════════════════════════════════════════ +-- 3. 交易信号日志表: 记录每日的交易决策过程 +-- ═══════════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS sim_trade_signals ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + signal_date DATE NOT NULL, + signal_time TIME, + stock_code VARCHAR(10) NOT NULL, + stock_name VARCHAR(50), + -- 信号信息 + action VARCHAR(20) NOT NULL, -- buy/sell/partial_sell/hold/skip + reason TEXT, -- 详细原因 + algo_rule VARCHAR(50), -- 触发的算法规则 (TP/SL/PE/BE/MT/TIMEOUT等) + -- 价格信息 + signal_price DECIMAL(10,4), + buy_price DECIMAL(10,4), -- 成本价 + profit_pct DECIMAL(8,4), -- 当前盈亏% + -- 执行信息 + executed BOOLEAN DEFAULT FALSE, -- 是否已执行 + execute_price DECIMAL(10,4), + execute_shares INTEGER, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 索引 +CREATE INDEX IF NOT EXISTS idx_sim_algo_config_user ON sim_algo_config(user_id); +CREATE INDEX IF NOT EXISTS idx_sim_position_meta_user ON sim_position_meta(user_id); +CREATE INDEX IF NOT EXISTS idx_sim_position_meta_code ON sim_position_meta(user_id, stock_code); +CREATE INDEX IF NOT EXISTS idx_sim_trade_signals_date ON sim_trade_signals(user_id, signal_date); + +-- ═══════════════════════════════════════════════════════ +-- 4. 预置算法配置 (3个推荐算法) +-- ═══════════════════════════════════════════════════════ +-- 注意: 以下INSERT语句用于初始化默认算法模板, 实际用户配置通过API创建 + +-- 默认算法模板表 +CREATE TABLE IF NOT EXISTS algo_templates ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL UNIQUE, + display_name VARCHAR(100) NOT NULL, + description TEXT, + risk_level VARCHAR(20), -- aggressive / balanced / conservative + -- 参数 (与 sim_algo_config 相同) + take_profit_pct DECIMAL(5,2), + stop_loss_pct DECIMAL(5,2), + ignore_sell_signal BOOLEAN DEFAULT FALSE, + sell_confirm_days INTEGER DEFAULT 0, + max_hold_days INTEGER DEFAULT 0, + no_timeout_if_rising BOOLEAN DEFAULT FALSE, + position_pct DECIMAL(5,2) DEFAULT 10, + signal_weight BOOLEAN DEFAULT TRUE, + partial_exit_pct INTEGER DEFAULT 0, + momentum_trail_gap DECIMAL(5,2) DEFAULT 3, + breakeven_at DECIMAL(5,2) DEFAULT 0, + momentum_tp BOOLEAN DEFAULT FALSE, + momentum_days INTEGER DEFAULT 3, + buy_time VARCHAR(5) DEFAULT '09:35', + sell_time VARCHAR(5) DEFAULT '13:40', + -- 回测业绩 + backtest_annual_return DECIMAL(8,2), + backtest_max_drawdown DECIMAL(8,2), + backtest_win_rate DECIMAL(8,2), + backtest_calmar DECIMAL(8,2), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 插入3个推荐算法模板 +INSERT INTO algo_templates (name, display_name, description, risk_level, + take_profit_pct, stop_loss_pct, ignore_sell_signal, sell_confirm_days, + max_hold_days, no_timeout_if_rising, position_pct, signal_weight, + partial_exit_pct, momentum_trail_gap, breakeven_at, momentum_tp, + backtest_annual_return, backtest_max_drawdown, backtest_win_rate, backtest_calmar) +VALUES +-- 🏆 最高收益 +('PE50G3_TP10_SL6', '📈 最高收益策略', + '部分止盈50% + 跟踪止盈 | 止盈10%/止损6% | 忽略卖出信号 | 最长60天 | 10%仓位', + 'aggressive', + 10, 6, TRUE, 0, 60, TRUE, 10, TRUE, 50, 3, 0, FALSE, + 18.2, 7.1, 58.2, 2.56), + +-- 🛡️ 风险调整最优 +('PE50G3_BE8_TP12_SL8', '🛡️ 风控优先策略', + '部分止盈50% + 保本止损8% + 延迟卖出3天 | 止盈12%/止损8% | 最长60天 | 8%仓位', + 'balanced', + 12, 8, FALSE, 3, 60, TRUE, 8, TRUE, 50, 3, 8, FALSE, + 17.0, 5.3, 53.8, 3.22), + +-- 📊 最稳健 +('PE30G3_BE8_TP10_SL6', '📊 稳健均衡策略', + '部分止盈30% + 保本止损8% + 忽略卖出 | 止盈10%/止损6% | 最长60天 | 10%仓位', + 'conservative', + 10, 6, TRUE, 0, 60, TRUE, 10, TRUE, 30, 3, 8, FALSE, + 17.4, 7.5, 55.9, 2.32) + +ON CONFLICT (name) DO NOTHING; diff --git a/stock-html/init_stock_data_db.sql b/stock-html/init_stock_data_db.sql new file mode 100644 index 0000000..cc86ace --- /dev/null +++ b/stock-html/init_stock_data_db.sql @@ -0,0 +1,139 @@ +-- 股票数据表结构 + +-- 1. 股票基本信息表 +CREATE TABLE IF NOT EXISTS stock_info ( + code VARCHAR(10) PRIMARY KEY, + name VARCHAR(50), + industry VARCHAR(50), + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 2. 实时价格表 +CREATE TABLE IF NOT EXISTS stock_realtime_price ( + code VARCHAR(10) PRIMARY KEY, + name VARCHAR(50), + price DECIMAL(12, 4), + change_pct DECIMAL(8, 4), + change_amount DECIMAL(12, 4), + volume BIGINT, + amount DECIMAL(20, 2), + high DECIMAL(12, 4), + low DECIMAL(12, 4), + open DECIMAL(12, 4), + prev_close DECIMAL(12, 4), + pe DECIMAL(12, 4), + pb DECIMAL(12, 4), + total_market_cap DECIMAL(20, 2), + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 3. 今日资金流向表 +CREATE TABLE IF NOT EXISTS stock_fund_flow_today ( + code VARCHAR(10) PRIMARY KEY, + name VARCHAR(50), + main_net_inflow DECIMAL(20, 2), -- 主力净流入 + main_net_inflow_pct DECIMAL(8, 4), -- 主力净流入占比 + super_net_inflow DECIMAL(20, 2), -- 超大单净流入 + super_net_inflow_pct DECIMAL(8, 4), -- 超大单净流入占比 + big_net_inflow DECIMAL(20, 2), -- 大单净流入 + big_net_inflow_pct DECIMAL(8, 4), -- 大单净流入占比 + mid_net_inflow DECIMAL(20, 2), -- 中单净流入 + mid_net_inflow_pct DECIMAL(8, 4), -- 中单净流入占比 + small_net_inflow DECIMAL(20, 2), -- 小单净流入 + small_net_inflow_pct DECIMAL(8, 4), -- 小单净流入占比 + price DECIMAL(12, 4), + change_pct DECIMAL(8, 4), + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 4. 历史资金流向表(保留最近6个月) +CREATE TABLE IF NOT EXISTS stock_fund_flow_history ( + id SERIAL PRIMARY KEY, + code VARCHAR(10) NOT NULL, + trade_date DATE NOT NULL, + close_price DECIMAL(12, 4), + change_pct DECIMAL(8, 4), + main_net_inflow DECIMAL(20, 2), + main_net_inflow_pct DECIMAL(8, 4), + super_net_inflow DECIMAL(20, 2), + super_net_inflow_pct DECIMAL(8, 4), + big_net_inflow DECIMAL(20, 2), + big_net_inflow_pct DECIMAL(8, 4), + mid_net_inflow DECIMAL(20, 2), + mid_net_inflow_pct DECIMAL(8, 4), + small_net_inflow DECIMAL(20, 2), + small_net_inflow_pct DECIMAL(8, 4), + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(code, trade_date) +); + +-- 5. 日K线表(供全景扫描、回测等使用) +CREATE TABLE IF NOT EXISTS stock_kline_daily ( + code VARCHAR(10) NOT NULL, + trade_date DATE NOT NULL, + open DECIMAL(12, 4), + high DECIMAL(12, 4), + low DECIMAL(12, 4), + close DECIMAL(12, 4), + volume BIGINT, + amount DECIMAL(20, 2), + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (code, trade_date) +); +CREATE INDEX IF NOT EXISTS idx_kline_daily_date ON stock_kline_daily(trade_date); +CREATE INDEX IF NOT EXISTS idx_kline_daily_code_date ON stock_kline_daily(code, trade_date); + +-- 6. 数据更新日志表 +CREATE TABLE IF NOT EXISTS data_update_log ( + id SERIAL PRIMARY KEY, + data_type VARCHAR(50) NOT NULL, -- realtime_price, fund_flow_today, fund_flow_history + status VARCHAR(20) NOT NULL, -- success, failed + records_count INT, + error_message TEXT, + started_at TIMESTAMP, + finished_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 7. 历史全景扫描结果表(回测专用,每日 11:30 + 16:30 两次扫描) +CREATE TABLE IF NOT EXISTS stock_scan_history ( + id SERIAL PRIMARY KEY, + scan_date DATE NOT NULL, + scan_time VARCHAR(5) NOT NULL, -- '11:30' 或 '16:30' + code VARCHAR(10) NOT NULL, + recommend_display VARCHAR(10), -- '买入','卖出','加仓','持有','关注','观察','观望' + recommend_type VARCHAR(10), -- 'buy','sell','watch' + recommend_reason TEXT, + recommend_rate INT DEFAULT 0, -- 0~100 + triggered_count INT DEFAULT 0, + signal_status JSONB, + indicators JSONB, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(scan_date, scan_time, code) +); +CREATE INDEX IF NOT EXISTS idx_scan_hist_date_time ON stock_scan_history(scan_date, scan_time); +CREATE INDEX IF NOT EXISTS idx_scan_hist_code ON stock_scan_history(code); +CREATE INDEX IF NOT EXISTS idx_scan_hist_display ON stock_scan_history(scan_date, scan_time, recommend_display); + +-- 8. 5分钟K线表(盘中价格历史,供精确回测使用) +CREATE TABLE IF NOT EXISTS stock_kline_5min ( + code VARCHAR(10) NOT NULL, + dt TIMESTAMP NOT NULL, -- K线时间戳 (如 2026-02-25 09:35:00) + open DECIMAL(12, 4), + high DECIMAL(12, 4), + low DECIMAL(12, 4), + close DECIMAL(12, 4), + volume BIGINT, + amount DECIMAL(20, 2), + change_pct DECIMAL(8, 4), -- 涨跌幅 + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (code, dt) +); +CREATE INDEX IF NOT EXISTS idx_kline_5min_dt ON stock_kline_5min(dt); +CREATE INDEX IF NOT EXISTS idx_kline_5min_code_date ON stock_kline_5min(code, (dt::date)); + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_fund_flow_history_code ON stock_fund_flow_history(code); +CREATE INDEX IF NOT EXISTS idx_fund_flow_history_date ON stock_fund_flow_history(trade_date); +CREATE INDEX IF NOT EXISTS idx_fund_flow_history_code_date ON stock_fund_flow_history(code, trade_date); +CREATE INDEX IF NOT EXISTS idx_realtime_price_updated ON stock_realtime_price(updated_at); +CREATE INDEX IF NOT EXISTS idx_fund_flow_today_updated ON stock_fund_flow_today(updated_at); diff --git a/stock-html/migrate_data.py b/stock-html/migrate_data.py new file mode 100644 index 0000000..8c3710a --- /dev/null +++ b/stock-html/migrate_data.py @@ -0,0 +1,242 @@ +""" +数据迁移脚本 - 将JSON数据迁移到PostgreSQL数据库 +""" +import json +import os +import sys +import psycopg2 +from psycopg2.extras import RealDictCursor +from werkzeug.security import generate_password_hash +from config import Config + +def get_db(): + """获取数据库连接""" + return psycopg2.connect( + host=Config.DB_HOST, + port=Config.DB_PORT, + database=Config.DB_NAME, + user=Config.DB_USER, + password=Config.DB_PASSWORD + ) + + +def create_default_user(conn, email=None, password=None): + """创建用户""" + cur = conn.cursor(cursor_factory=RealDictCursor) + + # 使用指定的或默认的邮箱密码 + user_email = email or 'admin@admin.com' + user_password = password or 'admin123' + + # 检查是否已存在用户 + cur.execute("SELECT id FROM users WHERE email = %s OR username = %s", (user_email, user_email)) + user = cur.fetchone() + + if user: + print(f"用户 {user_email} 已存在,使用现有用户") + return user['id'] + + # 创建用户 + password_hash = generate_password_hash(user_password) + cur.execute( + "INSERT INTO users (username, email, password_hash) VALUES (%s, %s, %s) RETURNING id", + (user_email, user_email, password_hash) + ) + user_id = cur.fetchone()['id'] + conn.commit() + print(f"创建用户: {user_email}") + return user_id + + +def migrate_trades(conn, user_id): + """迁移交易记录""" + if not os.path.exists(Config.TRADES_FILE): + print("trades.json 不存在,跳过") + return 0 + + with open(Config.TRADES_FILE, 'r', encoding='utf-8') as f: + trades = json.load(f) + + if not trades: + print("trades.json 为空,跳过") + return 0 + + cur = conn.cursor() + count = 0 + + for trade in trades: + try: + # 处理日期 + trade_date = trade.get('trade_date') + if trade_date and len(trade_date) > 10: + trade_date = trade_date[:10] + + # 处理数值 + price = trade.get('price') + if price and price != '': + price = float(price) + else: + price = None + + quantity = trade.get('quantity') + if quantity and quantity != '': + quantity = int(quantity) + else: + quantity = None + + profit_amount = trade.get('profit_amount') + if profit_amount and profit_amount != '': + profit_amount = float(profit_amount) + else: + profit_amount = None + + stop_loss_price = trade.get('stop_loss_price') + if stop_loss_price and stop_loss_price != '': + stop_loss_price = float(stop_loss_price) + else: + stop_loss_price = None + + cur.execute(""" + INSERT INTO trades (user_id, stock_code, stock_name, trade_type, price, + quantity, trade_date, reason, result, profit_amount, + stop_loss_price, notes) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, ( + user_id, + trade.get('stock_code'), + trade.get('stock_name'), + trade.get('trade_type'), + price, + quantity, + trade_date, + trade.get('reason'), + trade.get('result'), + profit_amount, + stop_loss_price, + trade.get('notes') + )) + count += 1 + except Exception as e: + print(f"迁移交易记录失败: {e}, 数据: {trade}") + + conn.commit() + print(f"迁移交易记录: {count} 条") + return count + + +def migrate_watchlist(conn, user_id): + """迁移关注列表""" + if not os.path.exists(Config.WATCHLIST_FILE): + print("watchlist.json 不存在,跳过") + return 0 + + with open(Config.WATCHLIST_FILE, 'r', encoding='utf-8') as f: + watchlist = json.load(f) + + if not watchlist: + print("watchlist.json 为空,跳过") + return 0 + + cur = conn.cursor() + count = 0 + + for item in watchlist: + try: + cur.execute(""" + INSERT INTO watchlist (user_id, stock_code, stock_name) + VALUES (%s, %s, %s) + ON CONFLICT (user_id, stock_code) DO NOTHING + """, ( + user_id, + item.get('code'), + item.get('name') + )) + count += 1 + except Exception as e: + print(f"迁移关注列表失败: {e}, 数据: {item}") + + conn.commit() + print(f"迁移关注列表: {count} 条") + return count + + +def migrate_alerts_cache(conn, user_id): + """迁移分析缓存""" + if not os.path.exists(Config.ALERTS_CACHE_FILE): + print("alerts_cache.json 不存在,跳过") + return 0 + + with open(Config.ALERTS_CACHE_FILE, 'r', encoding='utf-8') as f: + cache = json.load(f) + + alerts = cache.get('alerts', []) + if not alerts: + print("alerts_cache.json 为空,跳过") + return 0 + + cur = conn.cursor() + cur.execute(""" + INSERT INTO alerts_cache (user_id, data, updated_at) + VALUES (%s, %s, NOW()) + ON CONFLICT (user_id) DO UPDATE SET + data = EXCLUDED.data, + updated_at = NOW() + """, (user_id, json.dumps(alerts))) + + conn.commit() + print(f"迁移分析缓存: {len(alerts)} 条") + return len(alerts) + + +def main(): + import argparse + parser = argparse.ArgumentParser(description='数据迁移工具') + parser.add_argument('--email', default=None, help='用户邮箱') + parser.add_argument('--password', default=None, help='用户密码') + args = parser.parse_args() + + print("=" * 60) + print("数据迁移 - JSON -> PostgreSQL") + print("=" * 60) + + try: + conn = get_db() + print("数据库连接成功") + except Exception as e: + print(f"数据库连接失败: {e}") + print("\n请先执行: psql -U postgres -f init_db.sql") + sys.exit(1) + + try: + # 创建用户 + user_id = create_default_user(conn, args.email, args.password) + + # 先清空现有数据 + cur = conn.cursor() + cur.execute("DELETE FROM trades WHERE user_id = %s", (user_id,)) + cur.execute("DELETE FROM watchlist WHERE user_id = %s", (user_id,)) + cur.execute("DELETE FROM alerts_cache WHERE user_id = %s", (user_id,)) + conn.commit() + print("清空现有数据") + + # 迁移数据 + migrate_trades(conn, user_id) + migrate_watchlist(conn, user_id) + migrate_alerts_cache(conn, user_id) + + print("=" * 60) + print("迁移完成!") + print(f"登录邮箱: {args.email or 'admin@admin.com'}") + print(f"登录密码: {args.password or 'admin123'}") + print("=" * 60) + + except Exception as e: + print(f"迁移失败: {e}") + conn.rollback() + raise + finally: + conn.close() + + +if __name__ == '__main__': + main() diff --git a/stock-html/requirements.txt b/stock-html/requirements.txt new file mode 100644 index 0000000..13c5305 --- /dev/null +++ b/stock-html/requirements.txt @@ -0,0 +1,7 @@ +flask>=2.0.0 +flask-cors>=3.0.0 +akshare>=1.10.0 +pandas>=1.5.0 +numpy>=1.20.0 +schedule>=1.2.0 +psycopg2-binary>=2.9.0 diff --git a/stock-html/routes/__init__.py b/stock-html/routes/__init__.py new file mode 100644 index 0000000..ae6c4cd --- /dev/null +++ b/stock-html/routes/__init__.py @@ -0,0 +1 @@ +# Routes 模块 diff --git a/stock-html/routes/admin.py b/stock-html/routes/admin.py new file mode 100644 index 0000000..773997b --- /dev/null +++ b/stock-html/routes/admin.py @@ -0,0 +1,1359 @@ +""" +管理后台 API 路由 +""" +from flask import Blueprint, request, jsonify, session, render_template +from db import get_db +from psycopg2.extras import RealDictCursor +from werkzeug.security import generate_password_hash +import functools +import subprocess +import os +import socket +import platform + +bp = Blueprint('admin', __name__) + + +def _calc_win_rate(trades_rows, sim_pos_rows, sim_trades_rows, realtime_prices=None): + if realtime_prices is None: + realtime_prices = {} + from decimal import Decimal + + def _pair_trades(rows): + holdings = {} # code -> list of (price, qty) + completed = [] + for r in rows: + code = r['stock_code'] + name = r['stock_name'] + ttype = r['trade_type'] + price = float(r['price'] or 0) + qty = int(r['quantity'] or 0) + if ttype == 'buy': + if code not in holdings: + holdings[code] = {'name': name, 'buys': []} + holdings[code]['buys'].append({'price': price, 'qty': qty, 'date': r['trade_date']}) + elif ttype == 'sell': + if code in holdings and holdings[code]['buys']: + remaining = qty + total_cost = 0 + total_qty = 0 + while remaining > 0 and holdings[code]['buys']: + buy = holdings[code]['buys'][0] + take = min(remaining, buy['qty']) + total_cost += buy['price'] * take + total_qty += take + remaining -= take + buy['qty'] -= take + if buy['qty'] <= 0: + holdings[code]['buys'].pop(0) + if total_qty > 0: + avg_buy = total_cost / total_qty + profit = (price - avg_buy) * total_qty + pct = (price - avg_buy) / avg_buy * 100 if avg_buy else 0 + completed.append({ + 'code': code, 'name': name, + 'buy_price': round(avg_buy, 4), 'sell_price': price, + 'quantity': total_qty, 'profit': round(profit, 2), + 'pct': round(pct, 2), 'date': r['trade_date'], + }) + + open_positions = [] + for code, data in holdings.items(): + if data['buys']: + total_cost = sum(b['price'] * b['qty'] for b in data['buys']) + total_qty = sum(b['qty'] for b in data['buys']) + if total_qty > 0: + open_positions.append({ + 'code': code, 'name': data['name'], + 'avg_cost': round(total_cost / total_qty, 4), + 'quantity': total_qty, + }) + return completed, open_positions + + # 实盘交易分析 + real_completed, real_open = _pair_trades(trades_rows) + real_wins = [t for t in real_completed if t['profit'] > 0] + real_losses = [t for t in real_completed if t['profit'] <= 0] + real_total_profit = sum(t['profit'] for t in real_completed) + + # 实盘持仓浮盈(用实时价格计算) + real_open_detail = [] + for pos in real_open: + code = pos['code'] + avg_cost = pos['avg_cost'] + qty = pos['quantity'] + current = float(realtime_prices.get(code, 0)) + if current > 0 and avg_cost > 0: + profit = (current - avg_cost) * qty + pct = (current - avg_cost) / avg_cost * 100 + real_open_detail.append({ + 'code': code, 'name': pos['name'], + 'avg_cost': avg_cost, 'current_price': round(current, 4), + 'quantity': qty, 'profit': round(profit, 2), 'pct': round(pct, 2), + }) + else: + real_open_detail.append({ + 'code': code, 'name': pos['name'], + 'avg_cost': avg_cost, 'current_price': current, + 'quantity': qty, 'profit': 0, 'pct': 0, + }) + + real_open_wins = [p for p in real_open_detail if p['profit'] > 0] + real_open_losses = [p for p in real_open_detail if p['profit'] <= 0 and p['current_price'] > 0] + real_open_profit = sum(p['profit'] for p in real_open_detail) + + # 模拟交易分析 + sim_completed, _ = _pair_trades(sim_trades_rows) + sim_wins = [t for t in sim_completed if t['profit'] > 0] + sim_losses = [t for t in sim_completed if t['profit'] <= 0] + sim_completed_profit = sum(t['profit'] for t in sim_completed) + + # 模拟持仓浮盈 + sim_unrealized = [] + for p in sim_pos_rows: + avg_cost = float(p['avg_cost'] or 0) + current = float(p.get('realtime_price') or p.get('current_price') or 0) + qty = int(p['quantity'] or 0) + if qty > 0 and avg_cost > 0: + profit = (current - avg_cost) * qty + pct = (current - avg_cost) / avg_cost * 100 + sim_unrealized.append({ + 'code': p['stock_code'], 'name': p['stock_name'], + 'avg_cost': round(avg_cost, 4), 'current_price': round(current, 4), + 'quantity': qty, 'profit': round(profit, 2), 'pct': round(pct, 2), + }) + + sim_unrealized_wins = [p for p in sim_unrealized if p['profit'] > 0] + sim_unrealized_losses = [p for p in sim_unrealized if p['profit'] <= 0 and p['current_price'] > 0] + sim_unrealized_profit = sum(p['profit'] for p in sim_unrealized) + + sim_unrealized_with_price = [p for p in sim_unrealized if p['current_price'] > 0] + sim_total_count = len(sim_completed) + len(sim_unrealized_with_price) + sim_total_wins = len(sim_wins) + len(sim_unrealized_wins) + sim_total_losses = len(sim_losses) + len(sim_unrealized_losses) + sim_total_profit = sim_completed_profit + sim_unrealized_profit + + # 总胜率:实盘已完成 + 实盘持仓 + 模拟已完成 + 模拟持仓 + real_open_with_price = [p for p in real_open_detail if p['current_price'] > 0] + total_count = len(real_completed) + len(real_open_with_price) + sim_total_count + total_wins = len(real_wins) + len(real_open_wins) + sim_total_wins + total_profit = real_total_profit + real_open_profit + sim_total_profit + + return { + 'real': { + 'completed': real_completed, + 'open': real_open_detail, + 'win_count': len(real_wins) + len(real_open_wins), + 'loss_count': len(real_losses) + len(real_open_losses), + 'total_profit': round(real_total_profit + real_open_profit, 2), + 'completed_profit': round(real_total_profit, 2), + 'open_profit': round(real_open_profit, 2), + 'win_rate': round((len(real_wins) + len(real_open_wins)) / (len(real_completed) + len(real_open_with_price)) * 100, 1) if (real_completed or real_open_with_price) else 0, + }, + 'sim': { + 'completed': sim_completed, + 'unrealized': sim_unrealized, + 'win_count': sim_total_wins, + 'loss_count': sim_total_losses, + 'total': sim_total_count, + 'total_profit': round(sim_total_profit, 2), + 'completed_profit': round(sim_completed_profit, 2), + 'unrealized_profit': round(sim_unrealized_profit, 2), + 'win_rate': round(sim_total_wins / sim_total_count * 100, 1) if sim_total_count else 0, + }, + 'overall_win_rate': round(total_wins / total_count * 100, 1) if total_count else 0, + 'total_completed': total_count, + 'total_profit': round(total_profit, 2), + } + + +def admin_required(f): + @functools.wraps(f) + def decorated(*args, **kwargs): + if 'user_id' not in session: + return jsonify({'success': False, 'error': '请先登录'}), 401 + conn = get_db() + if not conn: + return jsonify({'success': False, 'error': '数据库连接失败'}), 500 + try: + cur = conn.cursor() + cur.execute("SELECT is_admin FROM users WHERE id = %s", (session['user_id'],)) + row = cur.fetchone() + if not row or not row[0]: + return jsonify({'success': False, 'error': '无管理员权限'}), 403 + finally: + conn.close() + return f(*args, **kwargs) + return decorated + + +@bp.route('/admin') +def admin_page(): + if 'user_id' not in session: + return render_template('admin.html') + conn = get_db() + if not conn: + return render_template('admin.html') + try: + cur = conn.cursor() + cur.execute("SELECT is_admin FROM users WHERE id = %s", (session['user_id'],)) + row = cur.fetchone() + if not row or not row[0]: + return '无权访问', 403 + finally: + conn.close() + return render_template('admin.html') + + +# ========== 系统概览 ========== + +@bp.route('/api/admin/dashboard', methods=['GET']) +@admin_required +def dashboard(): + conn = get_db() + if not conn: + return jsonify({'success': False}), 500 + try: + cur = conn.cursor() + stats = {} + + cur.execute("SELECT count(*) FROM users") + stats['user_count'] = cur.fetchone()[0] + + cur.execute("SELECT count(*) FROM stock_realtime_price") + stats['stock_count'] = cur.fetchone()[0] + + cur.execute("SELECT count(*), max(scan_date)::text FROM stock_signal_scan") + row = cur.fetchone() + stats['scan_count'] = row[0] + stats['last_scan_date'] = row[1] + + cur.execute("SELECT count(*) FROM stock_fundamental") + stats['fundamental_count'] = cur.fetchone()[0] + + cur.execute("SELECT count(*) FROM trades") + stats['trade_count'] = cur.fetchone()[0] + + cur.execute("SELECT count(*) FROM watchlist") + stats['watchlist_count'] = cur.fetchone()[0] + + cur.execute("SELECT count(*) FROM sim_trades") + stats['sim_trade_count'] = cur.fetchone()[0] + + cur.execute("SELECT count(DISTINCT code) FROM stock_signal_scan WHERE triggered_count > 0 AND scan_date = (SELECT max(scan_date) FROM stock_signal_scan)") + stats['signal_stock_count'] = cur.fetchone()[0] + + # 服务器信息 + try: + from config import Config + hostname = socket.gethostname() + # 获取服务器外网IP + try: + import urllib.request + external_ip = urllib.request.urlopen( + 'http://metadata.tencentyun.com/latest/meta-data/public-ipv4', timeout=2 + ).read().decode().strip() + except Exception: + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + external_ip = s.getsockname()[0] + s.close() + except Exception: + external_ip = '未知' + # 内网IP + try: + internal_ip = socket.gethostbyname(hostname) + except Exception: + internal_ip = '未知' + stats['server_info'] = { + 'hostname': hostname, + 'external_ip': external_ip, + 'internal_ip': internal_ip, + 'app_port': getattr(Config, 'PORT', 3333), + 'os_info': f"{platform.system()} {platform.release()}", + 'python_version': platform.python_version(), + 'domain': request.host, + 'scheme': request.scheme, + } + except Exception as e: + stats['server_info'] = {'error': str(e)} + + return jsonify({'success': True, 'data': stats}) + finally: + conn.close() + + +# ========== 用户管理 ========== + +@bp.route('/api/admin/users', methods=['GET']) +@admin_required +def list_users(): + conn = get_db() + if not conn: + return jsonify({'success': False}), 500 + try: + cur = conn.cursor(cursor_factory=RealDictCursor) + cur.execute(""" + SELECT u.id, u.username, u.email, u.is_admin, u.created_at::text, + (SELECT count(*) FROM watchlist w WHERE w.user_id = u.id) as watchlist_count, + (SELECT count(*) FROM trades t WHERE t.user_id = u.id) as trade_count, + (SELECT count(*) FROM sim_trades st WHERE st.user_id = u.id) as sim_trade_count, + (SELECT count(*) FROM ai_call_log al WHERE al.user_id = u.id) as ai_call_count, + (SELECT updated_at::text FROM alerts_cache ac WHERE ac.user_id = u.id) as last_active + FROM users u + ORDER BY u.id + """) + users = [dict(u) for u in cur.fetchall()] + + for u in users: + uid = u['id'] + cur.execute("SELECT stock_code, stock_name, trade_type, price, quantity, trade_date::text FROM trades WHERE user_id = %s ORDER BY trade_date, created_at", (uid,)) + all_trades = cur.fetchall() + cur.execute(""" + SELECT p.stock_code, p.stock_name, p.quantity, p.avg_cost, p.current_price, + r.price as realtime_price + FROM sim_positions p LEFT JOIN stock_realtime_price r ON p.stock_code = r.code + WHERE p.user_id = %s AND p.quantity > 0 + """, (uid,)) + sim_pos = cur.fetchall() + cur.execute("SELECT stock_code, stock_name, trade_type, price, quantity, trade_date::text FROM sim_trades WHERE user_id = %s ORDER BY trade_date, created_at", (uid,)) + all_sim = cur.fetchall() + + real_codes_qty = {} + for t in all_trades: + c = t['stock_code'] + q = int(t['quantity'] or 0) + real_codes_qty[c] = real_codes_qty.get(c, 0) + (q if t['trade_type'] == 'buy' else -q) + open_codes = {c for c, q in real_codes_qty.items() if q > 0} + rt_prices = {} + if open_codes: + cl = list(open_codes) + cur.execute(f"SELECT code, price FROM stock_realtime_price WHERE code IN ({','.join(['%s']*len(cl))})", cl) + for row in cur.fetchall(): + rt_prices[row['code']] = float(row['price'] or 0) + + wa = _calc_win_rate(all_trades, sim_pos, all_sim, rt_prices) + u['win_rate'] = wa['overall_win_rate'] + u['total_profit'] = wa.get('total_profit', 0) + + return jsonify({'success': True, 'data': users}) + finally: + conn.close() + + +@bp.route('/api/admin/users/', methods=['GET']) +@admin_required +def get_user_detail(user_id): + conn = get_db() + if not conn: + return jsonify({'success': False}), 500 + try: + cur = conn.cursor(cursor_factory=RealDictCursor) + + cur.execute("SELECT id, username, email, is_admin, created_at::text FROM users WHERE id = %s", (user_id,)) + user = cur.fetchone() + if not user: + return jsonify({'success': False, 'error': '用户不存在'}), 404 + + cur.execute("SELECT stock_code, stock_name, added_time::text FROM watchlist WHERE user_id = %s ORDER BY added_time DESC", (user_id,)) + watchlist = cur.fetchall() + + cur.execute(""" + SELECT id, stock_code, stock_name, trade_type, price, quantity, + trade_date::text, reason, result, profit_amount, notes, created_at::text + FROM trades WHERE user_id = %s ORDER BY trade_date DESC LIMIT 50 + """, (user_id,)) + trades = cur.fetchall() + + cur.execute(""" + SELECT id, stock_code, stock_name, trade_type, price, quantity, + trade_date::text, signal_reason, created_at::text + FROM sim_trades WHERE user_id = %s ORDER BY trade_date DESC LIMIT 50 + """, (user_id,)) + sim_trades = cur.fetchall() + + cur.execute(""" + SELECT id, stock_code, stock_name, call_type, created_at::text + FROM ai_call_log WHERE user_id = %s ORDER BY created_at DESC LIMIT 50 + """, (user_id,)) + ai_calls = cur.fetchall() + + # 胜率分析 - 配对买卖交易 + cur.execute(""" + SELECT stock_code, stock_name, trade_type, price, quantity, trade_date::text + FROM trades WHERE user_id = %s ORDER BY trade_date, created_at + """, (user_id,)) + all_trades = cur.fetchall() + + # 模拟持仓和收益 + cur.execute(""" + SELECT p.stock_code, p.stock_name, p.quantity, p.avg_cost, p.current_price, + r.price as realtime_price + FROM sim_positions p + LEFT JOIN stock_realtime_price r ON p.stock_code = r.code + WHERE p.user_id = %s AND p.quantity > 0 + """, (user_id,)) + sim_positions = cur.fetchall() + + # 模拟交易历史中的已卖出记录 + cur.execute(""" + SELECT stock_code, stock_name, trade_type, price, quantity, trade_date::text + FROM sim_trades WHERE user_id = %s ORDER BY trade_date, created_at + """, (user_id,)) + all_sim = cur.fetchall() + + # 获取实盘持仓股票的实时价格 + real_codes = set() + buys = {} + for t in all_trades: + if t['trade_type'] == 'buy': + buys.setdefault(t['stock_code'], 0) + buys[t['stock_code']] += int(t['quantity'] or 0) + elif t['trade_type'] == 'sell': + buys.setdefault(t['stock_code'], 0) + buys[t['stock_code']] -= int(t['quantity'] or 0) + real_codes = {c for c, q in buys.items() if q > 0} + + realtime_prices = {} + if real_codes: + codes_list = list(real_codes) + placeholders = ','.join(['%s'] * len(codes_list)) + cur.execute(f"SELECT code, price FROM stock_realtime_price WHERE code IN ({placeholders})", codes_list) + for row in cur.fetchall(): + realtime_prices[row['code']] = float(row['price'] or 0) + + win_analysis = _calc_win_rate(all_trades, sim_positions, all_sim, realtime_prices) + + return jsonify({ + 'success': True, + 'data': { + 'user': dict(user), + 'watchlist': [dict(w) for w in watchlist], + 'trades': [dict(t) for t in trades], + 'sim_trades': [dict(s) for s in sim_trades], + 'ai_calls': [dict(a) for a in ai_calls], + 'win_analysis': win_analysis, + } + }) + finally: + conn.close() + + +@bp.route('/api/admin/users//reset_password', methods=['POST']) +@admin_required +def reset_user_password(user_id): + data = request.get_json() or {} + new_password = data.get('new_password', '') + if len(new_password) < 6: + return jsonify({'success': False, 'error': '密码至少6位'}), 400 + + conn = get_db() + if not conn: + return jsonify({'success': False}), 500 + try: + cur = conn.cursor() + pw_hash = generate_password_hash(new_password) + cur.execute("UPDATE users SET password_hash = %s WHERE id = %s", (pw_hash, user_id)) + conn.commit() + return jsonify({'success': True}) + except Exception as e: + conn.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + finally: + conn.close() + + +@bp.route('/api/admin/users//toggle_admin', methods=['POST']) +@admin_required +def toggle_admin(user_id): + if user_id == session.get('user_id'): + return jsonify({'success': False, 'error': '不能修改自己的管理员状态'}), 400 + + conn = get_db() + if not conn: + return jsonify({'success': False}), 500 + try: + cur = conn.cursor() + cur.execute("UPDATE users SET is_admin = NOT is_admin WHERE id = %s RETURNING is_admin", (user_id,)) + row = cur.fetchone() + conn.commit() + return jsonify({'success': True, 'is_admin': row[0] if row else False}) + except Exception as e: + conn.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + finally: + conn.close() + + +@bp.route('/api/admin/users/', methods=['DELETE']) +@admin_required +def delete_user(user_id): + if user_id == session.get('user_id'): + return jsonify({'success': False, 'error': '不能删除自己'}), 400 + + conn = get_db() + if not conn: + return jsonify({'success': False}), 500 + try: + cur = conn.cursor() + for tbl in ['alerts_cache', 'watchlist', 'trades', 'sim_trades']: + cur.execute(f"DELETE FROM {tbl} WHERE user_id = %s", (user_id,)) + cur.execute("DELETE FROM sim_positions WHERE user_id = %s", (user_id,)) + cur.execute("DELETE FROM sim_config WHERE user_id = %s", (user_id,)) + cur.execute("DELETE FROM sim_daily_stats WHERE user_id = %s", (user_id,)) + cur.execute("DELETE FROM users WHERE id = %s", (user_id,)) + conn.commit() + return jsonify({'success': True}) + except Exception as e: + conn.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + finally: + conn.close() + + +# ========== 用户数据管理 ========== + +@bp.route('/api/admin/users//watchlist/', methods=['DELETE']) +@admin_required +def delete_user_watchlist(user_id, code): + conn = get_db() + if not conn: + return jsonify({'success': False}), 500 + try: + cur = conn.cursor() + cur.execute("DELETE FROM watchlist WHERE user_id = %s AND stock_code = %s", (user_id, code)) + conn.commit() + return jsonify({'success': True}) + finally: + conn.close() + + +@bp.route('/api/admin/users//trades/', methods=['DELETE']) +@admin_required +def delete_user_trade(user_id, trade_id): + conn = get_db() + if not conn: + return jsonify({'success': False}), 500 + try: + cur = conn.cursor() + cur.execute("DELETE FROM trades WHERE id = %s AND user_id = %s", (trade_id, user_id)) + conn.commit() + return jsonify({'success': True}) + finally: + conn.close() + + +# ========== 系统数据 ========== + +@bp.route('/api/admin/scan_history', methods=['GET']) +@admin_required +def scan_history(): + conn = get_db() + if not conn: + return jsonify({'success': False}), 500 + try: + cur = conn.cursor(cursor_factory=RealDictCursor) + cur.execute(""" + SELECT scan_date::text, count(*) as total, + sum(CASE WHEN triggered_count > 0 THEN 1 ELSE 0 END) as with_signal, + max(triggered_count) as max_triggered, + to_char(min(created_at), 'HH24:MI:SS') as scan_start, + to_char(max(created_at), 'HH24:MI:SS') as scan_end, + EXTRACT(EPOCH FROM (max(created_at) - min(created_at)))::int as duration_sec + FROM stock_signal_scan + GROUP BY scan_date + ORDER BY scan_date DESC + LIMIT 30 + """) + return jsonify({'success': True, 'data': [dict(r) for r in cur.fetchall()]}) + finally: + conn.close() + + +@bp.route('/api/admin/data_stats', methods=['GET']) +@admin_required +def data_stats(): + conn = get_db() + if not conn: + return jsonify({'success': False}), 500 + try: + cur = conn.cursor(cursor_factory=RealDictCursor) + tables = [ + ('stock_realtime_price', '实时价格'), + ('stock_signal_scan', '信号扫描'), + ('stock_fundamental', '基本面'), + ('stock_fund_flow_history', '资金流向'), + ('stock_fund_flow_today', '今日资金'), + ] + result = [] + for tbl, label in tables: + try: + cur.execute(f"SELECT count(*) as cnt FROM {tbl}") + cnt = cur.fetchone()['cnt'] + result.append({'table': tbl, 'label': label, 'count': cnt}) + except Exception: + result.append({'table': tbl, 'label': label, 'count': 0}) + + cur.execute(""" + SELECT data_type, status, started_at::text, finished_at::text as completed_at, records_count, error_message + FROM data_update_log ORDER BY started_at DESC LIMIT 20 + """) + logs = [dict(r) for r in cur.fetchall()] + + return jsonify({'success': True, 'data': {'tables': result, 'logs': logs}}) + finally: + conn.close() + + +# ========== 全景扫描管理 ========== + +_scan_proc = None # 全景扫描 Popen 对象 + + +def _is_scan_running(): + """检查扫描脚本是否正在运行""" + global _scan_proc + + # 方法1:检查保存的 Popen 对象 + if _scan_proc is not None: + rc = _scan_proc.poll() + if rc is None: + return True + else: + _scan_proc = None + + # 方法2:pgrep 兜底 + try: + result = subprocess.run( + ['/usr/bin/pgrep', '-f', 'full_signal_scan\\.py'], + capture_output=True, timeout=5, + ) + return result.returncode == 0 + except Exception: + return False + + +@bp.route('/api/admin/trigger_scan', methods=['POST']) +@admin_required +def trigger_scan(): + """管理员触发全景扫描""" + try: + if _is_scan_running(): + return jsonify({'success': False, 'error': '扫描正在进行中,请稍后再试'}), 409 + + script_path = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + 'full_signal_scan.py', + ) + if not os.path.exists(script_path): + return jsonify({'success': False, 'error': '扫描脚本不存在'}), 404 + + force = True # 管理员触发始终强制重新扫描 + env = os.environ.copy() + env['PATH'] = '/opt/stock-app/venv/bin:/usr/local/bin:/usr/bin:/bin' + env['FORCE_RESCAN'] = '1' + + log_path = os.path.join(os.path.dirname(script_path), 'scan.log') + proc = subprocess.Popen( + ['python', script_path], + cwd=os.path.dirname(script_path), + stdout=open(log_path, 'w'), + stderr=subprocess.STDOUT, + env=env, + start_new_session=True, + ) + global _scan_proc + _scan_proc = proc + + return jsonify({ + 'success': True, + 'message': '全景扫描已在后台启动(强制重新扫描)', + }) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +# ========== K线数据同步 ========== + +_kline_sync_proc = None # subprocess.Popen 对象 + + +def _is_kline_sync_running(): + """检查K线同步脚本是否正在运行""" + global _kline_sync_proc + + # 方法1:检查保存的 Popen 对象(最可靠,能正确处理僵尸进程) + if _kline_sync_proc is not None: + rc = _kline_sync_proc.poll() # None=运行中, 非None=已结束 + if rc is None: + return True + else: + _kline_sync_proc = None # 进程已结束,清理引用 + + # 方法2:pgrep 兜底(处理Flask重启后进程仍在的情况) + try: + result = subprocess.run( + ['/usr/bin/pgrep', '-f', 'sync_kline\\.py'], + capture_output=True, timeout=5, + ) + return result.returncode == 0 + except Exception: + return False + + +@bp.route('/api/admin/trigger_kline_sync', methods=['POST']) +@admin_required +def trigger_kline_sync(): + """管理员触发K线数据同步""" + try: + if _is_kline_sync_running(): + return jsonify({'success': False, 'error': 'K线同步正在进行中,请稍后再试'}), 409 + + data = request.get_json() or {} + sync_mode = data.get('mode', 'incremental') # 'full' 或 'incremental' + + script_path = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + 'sync_kline.py', + ) + if not os.path.exists(script_path): + return jsonify({'success': False, 'error': 'K线同步脚本不存在'}), 404 + + env = os.environ.copy() + env['PATH'] = '/opt/stock-app/venv/bin:/usr/local/bin:/usr/bin:/bin' + + cmd = ['python', script_path] + if sync_mode == 'full': + cmd.append('--full') + + log_path = os.path.join(os.path.dirname(script_path), 'kline_sync.log') + proc = subprocess.Popen( + cmd, + cwd=os.path.dirname(script_path), + stdout=open(log_path, 'w'), + stderr=subprocess.STDOUT, + env=env, + start_new_session=True, + ) + global _kline_sync_proc + _kline_sync_proc = proc + + mode_text = '全量同步(180天历史)' if sync_mode == 'full' else '增量同步(最近5天)' + return jsonify({ + 'success': True, + 'message': f'K线同步已启动:{mode_text}', + }) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@bp.route('/api/admin/kline_sync_status', methods=['GET']) +@admin_required +def kline_sync_status(): + """管理员查询K线同步状态""" + try: + import psycopg2 + from config import Config + + conn = psycopg2.connect( + host=Config.DB_HOST, port=Config.DB_PORT, + dbname=Config.DB_NAME, user=Config.DB_USER, password=Config.DB_PASSWORD, + ) + cur = conn.cursor() + + # K线数据统计 + cur.execute(""" + SELECT count(*) as total_rows, + count(DISTINCT code) as stock_count, + min(trade_date)::text as min_date, + max(trade_date)::text as max_date + FROM stock_kline_daily + """) + row = cur.fetchone() + total_rows, stock_count, min_date, max_date = row + + # 最近更新统计 + cur.execute(""" + SELECT count(DISTINCT code) + FROM stock_kline_daily + WHERE updated_at >= CURRENT_TIMESTAMP - INTERVAL '1 hour' + """) + recent_updated = cur.fetchone()[0] + + # 总股票数 + cur.execute("SELECT count(*) FROM stock_realtime_price") + total_stocks = cur.fetchone()[0] + + cur.close() + conn.close() + + is_running = _is_kline_sync_running() + + # 从日志中解析同步进度(优先读取较新的日志文件) + sync_progress = {} + log_tail = '' + try: + import re + base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + log_candidates = [ + os.path.join(base_dir, 'kline_scan.log'), # trigger_task 触发时写入 + os.path.join(base_dir, 'kline_sync.log'), # cron/direct触发时写入 + ] + # 选择最新的日志文件 + log_path = None + latest_mtime = 0 + for lp in log_candidates: + if os.path.exists(lp): + mt = os.path.getmtime(lp) + if mt > latest_mtime: + latest_mtime = mt + log_path = lp + if log_path and os.path.exists(log_path): + with open(log_path, 'r', errors='replace') as f: + lines = f.readlines() + log_tail = ''.join(lines[-5:]).strip() + + # 解析进度行: [ 83.9%] 4872/5810 | 速度: 28.5只/秒 | 剩余: 0.5分钟 | 成功: 4034 | 失败: 838 | 已保存: 4000条 + for line in reversed(lines): + m = re.search( + r'\[\s*([\d.]+)%\]\s+(\d+)/(\d+)\s+\|.*?速度:\s*([\d.]+).*?\|.*?剩余:\s*([\d.]+).*?\|.*?成功:\s*(\d+).*?\|.*?失败:\s*(\d+)', + line, + ) + if m: + sync_progress = { + 'percent': float(m.group(1)), + 'done': int(m.group(2)), + 'total': int(m.group(3)), + 'speed': float(m.group(4)), + 'remaining_min': float(m.group(5)), + 'success': int(m.group(6)), + 'failed': int(m.group(7)), + } + break + + # 检查是否已完成 + if not is_running and any('✅ 同步完成' in l or '✅ 同步中断' in l for l in lines[-10:]): + for line in reversed(lines[-15:]): + m2 = re.search(r'成功:\s*(\d+)\s*\|\s*失败:\s*(\d+)', line) + if m2: + sync_progress['completed'] = True + sync_progress['success'] = int(m2.group(1)) + sync_progress['failed'] = int(m2.group(2)) + break + m3 = re.search(r'耗时:\s*([\d.]+)分钟', line) + if m3: + sync_progress['elapsed_min'] = float(m3.group(1)) + except Exception: + pass + + return jsonify({ + 'success': True, + 'is_running': is_running, + 'total_rows': total_rows, + 'stock_count': stock_count, + 'total_stocks': total_stocks, + 'coverage': round(stock_count / total_stocks * 100, 1) if total_stocks > 0 else 0, + 'min_date': min_date, + 'max_date': max_date, + 'recent_updated': recent_updated, + 'sync_progress': sync_progress, + 'log_tail': log_tail, + }) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +# ========== 定时任务监控 ========== + +@bp.route('/api/admin/scheduled_tasks', methods=['GET']) +@admin_required +def scheduled_tasks(): + """获取全部定时任务执行状态""" + import re + from datetime import datetime + + # 定义所有定时任务 + tasks = [ + { + 'id': 'kline_scan', + 'name': 'K线同步+全景扫描', + 'schedule': '工作日 11:45, 16:25', + 'script': 'auto_kline_then_scan.sh', + 'log_file': '/opt/stock-app/kline_scan.log', + 'extra_log_files': ['/opt/stock-app/scan.log', '/opt/stock-app/kline_sync.log'], + 'type': 'cron', + 'pgrep_patterns': ['auto_kline_then_scan', 'sync_kline\\.py', 'full_signal_scan'], + }, + { + 'id': 'kline_5min', + 'name': '5分钟K线采集', + 'schedule': '工作日 17:30', + 'script': 'auto_sync_kline_5min.sh', + 'log_file': '/opt/stock-app/sync_kline_5min.log', + 'type': 'cron', + 'pgrep_patterns': ['auto_sync_kline_5min', 'sync_kline_5min'], + }, + { + 'id': 'fund_flow', + 'name': '资金流向计算', + 'schedule': '工作日 18:00', + 'script': 'auto_sync_fund_flow.sh', + 'log_file': '/opt/stock-app/sync_fund_flow.log', + 'type': 'cron', + 'pgrep_patterns': ['auto_sync_fund_flow', 'sync_fund_flow'], + }, + { + 'id': 'vacuum', + 'name': '数据库维护(VACUUM)', + 'schedule': '周日 03:00', + 'script': 'cleanup_data.py', + 'log_file': '/opt/stock-app/cleanup.log', + 'type': 'cron', + 'pgrep_patterns': ['cleanup_data'], + }, + { + 'id': 'stock_data_service', + 'name': '实时数据采集服务', + 'schedule': '常驻服务', + 'script': 'stock_data_service.py', + 'log_file': '/opt/stock-app/stock_data_service.log', + 'type': 'systemd', + 'service_name': 'stock-data-service', + }, + { + 'id': 'stock_app', + 'name': 'Web应用服务', + 'schedule': '常驻服务', + 'script': 'app.py', + 'log_file': None, + 'type': 'systemd', + 'service_name': 'stock-app', + }, + ] + + result = [] + for task in tasks: + info = { + 'id': task['id'], + 'name': task['name'], + 'schedule': task['schedule'], + 'script': task['script'], + 'type': task['type'], + 'status': 'unknown', + 'last_run': None, + 'last_log': '', + 'is_running': False, + } + + # 检查 systemd 服务状态 + if task['type'] == 'systemd' and task.get('service_name'): + try: + r = subprocess.run( + ['systemctl', 'is-active', task['service_name']], + capture_output=True, text=True, timeout=5, + ) + active = r.stdout.strip() + info['is_running'] = (active == 'active') + info['status'] = 'running' if active == 'active' else 'stopped' + + # 获取服务运行时间 + r2 = subprocess.run( + ['systemctl', 'show', task['service_name'], '--property=ActiveEnterTimestamp'], + capture_output=True, text=True, timeout=5, + ) + ts = r2.stdout.strip().replace('ActiveEnterTimestamp=', '') + if ts and ts != 'n/a': + info['last_run'] = ts + except Exception: + pass + + # 检查进程是否在运行 (cron 任务) + if task['type'] == 'cron': + patterns = task.get('pgrep_patterns', [task['script'].replace('.sh', '').replace('.py', '')]) + for pat in patterns: + try: + r = subprocess.run( + ['/usr/bin/pgrep', '-f', pat], + capture_output=True, timeout=5, + ) + if r.returncode == 0: + info['is_running'] = True + info['status'] = 'running' + break + except Exception: + pass + + # 读取日志文件(支持多日志源,选择最新的) + log_candidates = [task.get('log_file')] + log_candidates.extend(task.get('extra_log_files', [])) + log_candidates = [lf for lf in log_candidates if lf and os.path.exists(lf)] + + # 选择最新修改的日志文件 + log_file = None + if log_candidates: + log_file = max(log_candidates, key=lambda f: os.path.getmtime(f)) + + if log_file: + try: + stat = os.stat(log_file) + file_size = stat.st_size + info['log_size'] = file_size + + # 读取最后8000字节(确保能覆盖完成标记和汇总信息) + with open(log_file, 'r', errors='replace') as f: + if file_size > 8000: + f.seek(file_size - 8000) + f.readline() # 跳过可能的不完整行 + lines = f.readlines() + + # 获取最后执行的日志 + info['last_log'] = ''.join(lines[-15:]).strip() + + # 解析最后执行时间和状态 + for line in reversed(lines): + # 匹配 "2026-02-26 19:17:33" 格式的时间戳 + m = re.search(r'(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})', line) + if m and not info['last_run']: + info['last_run'] = m.group(1) + + # 解析状态 (优先检查退出码和成功标记) + if info['status'] == 'unknown' or info['status'] == 'running': + low = line.lower() + stripped = line.strip() + + # 1. 最高优先: 明确的退出码 + exit_m = re.search(r'退出码:\s*(\d+)', line) + if exit_m: + exit_code = int(exit_m.group(1)) + if exit_code == 0 and not info['is_running']: + info['status'] = 'success' + elif exit_code != 0 and not info['is_running']: + info['status'] = 'error' + # 2. 成功标记(扫描完成、同步完成等) + elif ('✅' in line and ('完成' in line or '成功' in line or 'success' in low)): + if not info['is_running']: + info['status'] = 'success' + elif re.search(r'(扫描|同步|采集)(完成|成功)', line) and 'Traceback' not in line: + if not info['is_running']: + info['status'] = 'success' + # 3. 致命错误(Traceback、连接失败等,排除统计行中的"失败") + elif 'Traceback' in line or 'psycopg2.' in line: + if not info['is_running']: + info['status'] = 'error' + elif 'fe_sendauth' in line or ('password' in low and 'connection' in low): + info['status'] = 'error' + info['error_msg'] = '数据库连接失败' + # 4. 只有行首是错误标记才认为是真正的错误(排除"失败: 2只"这类统计行) + elif stripped.startswith('❌') or (stripped.startswith('失败') and '只' not in line): + if not info['is_running']: + info['status'] = 'error' + + if info['last_run'] and info['status'] != 'unknown': + break + + if info['status'] == 'unknown' and not info['is_running']: + info['status'] = 'idle' + + except Exception as e: + info['last_log'] = f'读取日志失败: {str(e)}' + elif not log_file: + pass + else: + info['status'] = 'no_log' + info['last_log'] = '日志文件不存在' + + # 读取 crontab + result.append(info) + + crontab_content = '' + try: + r = subprocess.run(['crontab', '-l'], capture_output=True, text=True, timeout=5) + if r.returncode == 0: + crontab_content = r.stdout.strip() + # 也检查 root crontab + r2 = subprocess.run(['sudo', 'crontab', '-l'], capture_output=True, text=True, timeout=5) + if r2.returncode == 0 and r2.stdout.strip(): + crontab_content = r2.stdout.strip() + except Exception: + pass + + return jsonify({ + 'success': True, + 'tasks': result, + 'crontab': crontab_content, + }) + + +@bp.route('/api/admin/scheduled_tasks//log', methods=['GET']) +@admin_required +def scheduled_task_log(task_id): + """获取指定任务的完整日志""" + log_map = { + 'kline_scan': '/opt/stock-app/kline_scan.log', + 'kline_5min': '/opt/stock-app/sync_kline_5min.log', + 'fund_flow': '/opt/stock-app/sync_fund_flow.log', + 'vacuum': '/opt/stock-app/cleanup.log', + 'stock_data_service': '/opt/stock-app/stock_data_service.log', + } + + log_file = log_map.get(task_id) + if not log_file: + return jsonify({'success': False, 'error': '无效的任务ID'}), 404 + + if not os.path.exists(log_file): + return jsonify({'success': True, 'log': '日志文件不存在', 'lines': 0}) + + try: + lines_param = request.args.get('lines', 100, type=int) + lines_param = min(lines_param, 500) + + with open(log_file, 'r', errors='replace') as f: + all_lines = f.readlines() + + total_lines = len(all_lines) + content = ''.join(all_lines[-lines_param:]) + + return jsonify({ + 'success': True, + 'log': content, + 'total_lines': total_lines, + 'showing': min(lines_param, total_lines), + }) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@bp.route('/api/admin/trigger_task/', methods=['POST']) +@admin_required +def trigger_task(task_id): + """手动触发指定定时任务""" + import threading + + TASK_COMMANDS = { + 'kline_scan': { + 'cmd': ['/bin/bash', '/opt/stock-app/auto_kline_then_scan.sh'], + 'log': '/opt/stock-app/kline_scan.log', + 'name': 'K线同步+全景扫描', + }, + 'kline_5min': { + 'cmd': ['/bin/bash', '/opt/stock-app/auto_sync_kline_5min.sh'], + 'log': '/opt/stock-app/sync_kline_5min.log', + 'name': '5分钟K线采集', + }, + 'fund_flow': { + 'cmd': ['/bin/bash', '/opt/stock-app/auto_sync_fund_flow.sh'], + 'log': '/opt/stock-app/sync_fund_flow.log', + 'name': '资金流向计算', + }, + 'vacuum': { + 'cmd': ['/opt/stock-app/venv/bin/python', '/opt/stock-app/cleanup_data.py'], + 'log': '/opt/stock-app/cleanup.log', + 'name': '数据库维护(VACUUM)', + 'env_extra': {'DB_PASSWORD': 'stock_password_2025'}, + }, + } + + task_cfg = TASK_COMMANDS.get(task_id) + if not task_cfg: + return jsonify({'success': False, 'error': f'不支持手动触发的任务: {task_id}'}), 400 + + # 检查是否已在运行 + script_base = task_cfg['cmd'][-1].split('/')[-1].replace('.sh', '').replace('.py', '') + try: + r = subprocess.run(['/usr/bin/pgrep', '-f', script_base], capture_output=True, timeout=5) + if r.returncode == 0: + return jsonify({'success': False, 'error': f'{task_cfg["name"]} 正在运行中,请等待完成'}), 409 + except Exception: + pass + + def _run_task(): + env = dict(os.environ) + env['DB_PASSWORD'] = env.get('DB_PASSWORD', 'stock_password_2025') + if 'env_extra' in task_cfg: + env.update(task_cfg['env_extra']) + log_file = task_cfg['log'] + with open(log_file, 'a') as lf: + lf.write(f"\n{'='*50}\n") + lf.write(f"手动触发 by admin ({__import__('datetime').datetime.now().strftime('%Y-%m-%d %H:%M:%S')})\n") + lf.write(f"{'='*50}\n") + lf.flush() + subprocess.run( + task_cfg['cmd'], + stdout=lf, stderr=lf, + env=env, + cwd='/opt/stock-app', + ) + + t = threading.Thread(target=_run_task, daemon=True) + t.start() + + return jsonify({'success': True, 'message': f'{task_cfg["name"]} 已启动'}) + + +@bp.route('/api/admin/kline_5min_progress', methods=['GET']) +@admin_required +def kline_5min_progress(): + """获取5分钟K线采集实时进度(从日志文件解析)""" + import re + + log_path = '/opt/stock-app/sync_kline_5min.log' + progress = {} + + # 检查进程是否在运行 + is_running = False + try: + r = subprocess.run(['/usr/bin/pgrep', '-f', 'sync_kline_5min'], capture_output=True, timeout=5) + is_running = (r.returncode == 0) + except Exception: + pass + + if not os.path.exists(log_path): + return jsonify({'success': True, 'is_running': is_running, 'progress': progress}) + + try: + stat = os.stat(log_path) + # 读取最后8KB + with open(log_path, 'r', errors='replace') as f: + if stat.st_size > 8192: + f.seek(stat.st_size - 8192) + f.readline() + lines = f.readlines() + + # 解析进度行: [ 5.1%] 300/5810 | 3.3只/秒 | 剩余 27.8分钟 | ✅200 ❌5 ⏭️95 | 已保存 15000条 + for line in reversed(lines): + m = re.search( + r'\[\s*([\d.]+)%\]\s+(\d+)/(\d+)\s+\|.*?([\d.]+)只/秒.*?剩余\s*([\d.]+)分钟', + line, + ) + if m: + progress = { + 'percent': float(m.group(1)), + 'done': int(m.group(2)), + 'total': int(m.group(3)), + 'speed': float(m.group(4)), + 'remaining_min': float(m.group(5)), + } + # 解析成功/失败/跳过 + m2 = re.search(r'✅(\d+)\s*❌(\d+)\s*⏭️(\d+)', line) + if m2: + progress['success'] = int(m2.group(1)) + progress['failed'] = int(m2.group(2)) + progress['skipped'] = int(m2.group(3)) + # 解析已保存条数 + m3 = re.search(r'已保存\s*(\d+)条', line) + if m3: + progress['saved_rows'] = int(m3.group(1)) + break + + # 检查是否已完成(日志尾部的总结行) + if not is_running and progress: + for line in reversed(lines[-20:]): + if '✅ 完成' in line or '⏹️ 中断' in line: + progress['completed'] = True + break + m_done = re.search(r'成功:\s*(\d+)\s*\|\s*失败:\s*(\d+)\s*\|\s*跳过:\s*(\d+)', line) + if m_done: + progress['completed'] = True + progress['success'] = int(m_done.group(1)) + progress['failed'] = int(m_done.group(2)) + progress['skipped'] = int(m_done.group(3)) + m_elapsed = re.search(r'耗时:\s*([\d.]+)\s*分钟', line) + if m_elapsed: + progress['elapsed_min'] = float(m_elapsed.group(1)) + + # 取最后3行日志供展示 + log_tail = ''.join(lines[-3:]).strip() + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + return jsonify({ + 'success': True, + 'is_running': is_running, + 'progress': progress, + 'log_tail': log_tail, + }) + + +@bp.route('/api/admin/scan_status', methods=['GET']) +@admin_required +def admin_scan_status(): + """管理员查询扫描进度""" + try: + from datetime import datetime + import psycopg2 + from config import Config + + conn = psycopg2.connect( + host=Config.DB_HOST, port=Config.DB_PORT, + dbname=Config.DB_NAME, user=Config.DB_USER, password=Config.DB_PASSWORD, + ) + cur = conn.cursor() + + scan_date = datetime.now().strftime('%Y-%m-%d') + cur.execute( + "SELECT count(*) FROM stock_signal_scan WHERE scan_date = %s", + (scan_date,), + ) + scanned = cur.fetchone()[0] + + cur.execute("SELECT count(*) FROM stock_realtime_price") + total = cur.fetchone()[0] + + cur.execute( + "SELECT count(*) FROM stock_signal_scan WHERE scan_date = %s AND triggered_count > 0", + (scan_date,), + ) + triggered = cur.fetchone()[0] + + cur.close() + conn.close() + + is_running = _is_scan_running() + + # 从日志中解析实时进度(速度、剩余时间等) + scan_progress = {} + try: + import re + log_path = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + 'scan.log', + ) + if os.path.exists(log_path): + with open(log_path, 'r') as f: + lines = f.readlines() + + # 解析进度行: [ 83.5%] 4872/5810 | 速度: 28.5只/秒 | 剩余: 0.5分钟 | 触发: 1312 | 失败: 838 + for line in reversed(lines): + m = re.search( + r'\[\s*([\d.]+)%\]\s+(\d+)/(\d+)\s+\|.*?速度:\s*([\d.]+).*?\|.*?剩余:\s*([\d.]+).*?\|.*?触发:\s*(\d+).*?\|.*?失败:\s*(\d+)', + line, + ) + if m: + scan_progress = { + 'percent': float(m.group(1)), + 'done': int(m.group(2)), + 'total': int(m.group(3)), + 'speed': float(m.group(4)), + 'remaining_min': float(m.group(5)), + 'triggered': int(m.group(6)), + 'failed': int(m.group(7)), + } + break + except Exception: + pass + + return jsonify({ + 'success': True, + 'scan_date': scan_date, + 'total': total, + 'scanned': scanned, + 'triggered': triggered, + 'progress': round(scanned / total * 100, 1) if total > 0 else 0, + 'is_complete': scanned >= total and not is_running, + 'is_running': is_running, + 'scan_progress': scan_progress, + }) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 diff --git a/stock-html/routes/analysis.py b/stock-html/routes/analysis.py new file mode 100644 index 0000000..38c4613 --- /dev/null +++ b/stock-html/routes/analysis.py @@ -0,0 +1,1180 @@ +""" +股票分析 API 路由(纯数据库版) +""" +import json +from flask import Blueprint, request, jsonify +from datetime import datetime, date +from services.stock_service import ( + get_stock_fund_flow, analyze_fund_flow_impact, + get_realtime_price, get_stock_name +) +from services.stock_algorithms import ( + compute_recommend, get_kline_data as algo_get_kline_data, + compute_bull_stage, find_bull_stocks, BULL_STAGES, +) +from db import ( + login_required, get_current_user_id, + db_get_alerts_cache, db_save_alerts_cache +) + +bp = Blueprint('analysis', __name__, url_prefix='/api') + + +@bp.route('/analyze', methods=['POST']) +def analyze(): + """分析单只股票(数据库优先 + 增量更新)""" + try: + from db import db_get_fund_flow_history, db_save_fund_flow_history + + data = request.get_json() + stock_code = data.get('stock_code', '').strip() + + if not stock_code: + return jsonify({'error': '股票代码不能为空'}), 400 + + # 从数据库获取历史数据(东方财富资金流向API已不可用,仅使用数据库缓存) + history_records, latest_date = db_get_fund_flow_history(stock_code) + + if not history_records: + return jsonify({'error': '无法获取数据'}), 400 + + # 2. 基于数据库数据进行分析 + import pandas as pd + df = pd.DataFrame(history_records) + df.rename(columns={ + 'trade_date': '日期', + 'close_price': '收盘价', + 'change_pct': '涨跌幅', + 'main_net_inflow': '主力净流入-净额', + 'main_net_inflow_pct': '主力净流入-净占比', + 'super_net_inflow': '超大单净流入-净额', + 'super_net_inflow_pct': '超大单净流入-净占比', + 'big_net_inflow': '大单净流入-净额', + 'big_net_inflow_pct': '大单净流入-净占比', + }, inplace=True) + + result = analyze_fund_flow_impact(df) + if result is None: + return jsonify({'error': '分析失败'}), 500 + + # 3. 获取实时价格补充到结果(优先腾讯API,兼容腾讯云) + try: + import requests as _req + _tcode = ('sh' if stock_code.startswith('6') else 'sz') + stock_code + _r = _req.get(f'http://qt.gtimg.cn/q={_tcode}', timeout=5, + headers={'Referer': 'https://finance.qq.com'}) + if _r.status_code == 200 and '\"' in _r.text: + _fields = _r.text.split('\"')[1].split('~') + if len(_fields) > 35 and _fields[3]: + result['实时价格'] = float(_fields[3]) + result['实时涨跌幅'] = float(_fields[32]) if _fields[32] else 0 + except Exception as e: + print(f"获取实时价格失败(腾讯): {e}") + + # 获取股票名称 + stock_name = get_stock_name(stock_code) or f'股票{stock_code}' + + return jsonify({ + 'success': True, + 'stock_code': stock_code, + 'stock_name': stock_name, + 'data': result, + 'source': 'database', + 'latest_date': latest_date + }) + except Exception as e: + import traceback + traceback.print_exc() + return jsonify({'error': str(e)}), 500 + + +@bp.route('/realtime_price/', methods=['GET']) +def realtime_price(stock_code): + """获取实时价格(直接调用实时API,不使用数据库缓存)""" + # 直接调用实时API获取最新价格 + result = get_realtime_price(stock_code) + if result['success']: + result['source'] = 'api' + return jsonify(result) + return jsonify(result), 500 + + +@bp.route('/alerts_cache', methods=['GET']) +@login_required +def get_alerts_cache(): + """获取分析缓存""" + user_id = get_current_user_id() + cache = db_get_alerts_cache(user_id) + raw = cache.get('alerts', []) + if isinstance(raw, dict): + alerts = raw.get('alerts', []) + version = raw.get('version', 0) + elif isinstance(raw, list): + alerts = raw + version = 0 + else: + alerts = [] + version = 0 + return jsonify({ + 'success': True, + 'lastUpdate': cache.get('lastUpdate'), + 'alerts': alerts, + 'version': version + }) + + +@bp.route('/alerts_cache', methods=['POST']) +@login_required +def save_alerts_cache(): + """保存分析缓存""" + try: + user_id = get_current_user_id() + data = request.get_json() + alerts = data.get('alerts', []) + version = data.get('version', 0) + + cache_obj = {'alerts': alerts, 'version': version} + success = db_save_alerts_cache(user_id, cache_obj) + return jsonify({'success': success}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@bp.route('/ai_analyze_stream/', methods=['GET']) +def ai_analyze_stream(stock_code): + """使用豆包AI分析股票(SSE流式输出)""" + from flask import Response + from services.doubao_api import analyze_stock_stream, format_fund_flow, format_market_cap + from services.mairui_api import get_realtime_price as mairui_price, get_financial_indicators + from db import get_db + + def generate(): + # 获取股票数据 + stock_data = {} + + # 获取实时价格 + price_result = mairui_price(stock_code) + if price_result['success']: + data = price_result['data'] + stock_data['price'] = data.get('price') + stock_data['change'] = data.get('change') + stock_data['pe'] = data.get('pe') + stock_data['pb'] = data.get('pb') + stock_data['total_market_cap'] = data.get('total_market_cap') + + # 获取财务指标 + fin_result = get_financial_indicators(stock_code) + if fin_result['success']: + data = fin_result['data'] + stock_data['roe'] = data.get('roe') + + # 获取股票名称 + stock_name = get_stock_name(stock_code) or stock_code + + # 获取资金流向和技术信号 + try: + conn = get_db() + if conn: + cur = conn.cursor() + cur.execute(""" + SELECT trade_date, change_pct, main_net_inflow_pct, super_net_inflow_pct + FROM stock_fund_flow_history + WHERE code = %s + ORDER BY trade_date DESC + LIMIT 3 + """, (stock_code,)) + rows = cur.fetchall() + fund_flow = [] + for row in rows: + fund_flow.append({ + 'date': row[0].strftime('%m-%d') if row[0] else '', + 'change_pct': float(row[1]) if row[1] else 0, + 'main_pct': float(row[2]) if row[2] else 0, + 'super_pct': float(row[3]) if row[3] else 0, + }) + stock_data['fund_flow_3days'] = fund_flow + + from psycopg2.extras import RealDictCursor + cur2 = conn.cursor(cursor_factory=RealDictCursor) + # 优先今天的扫描数据,无则回退到最近可用日期 + scan_date = date.today().strftime('%Y-%m-%d') + cur2.execute(""" + SELECT signal_status, indicators, triggered_count + FROM stock_signal_scan + WHERE code = %s AND scan_date = %s + """, (stock_code, scan_date)) + scan_row = cur2.fetchone() + if not scan_row: + cur2.execute(""" + SELECT signal_status, indicators, triggered_count + FROM stock_signal_scan + WHERE code = %s AND scan_date = (SELECT MAX(scan_date) FROM stock_signal_scan) + """, (stock_code,)) + scan_row = cur2.fetchone() + if scan_row: + stock_data['signal_status'] = scan_row['signal_status'] or [] + stock_data['indicators'] = scan_row['indicators'] or {} + stock_data['triggered_count'] = scan_row['triggered_count'] or 0 + cur2.close() + + conn.close() + except Exception as e: + print(f"获取数据失败: {e}") + + # 记录AI调用日志 + try: + from flask import session as _sess + _uid = _sess.get('user_id') + if _uid: + _conn = get_db() + if _conn: + _cur = _conn.cursor() + _cur.execute("INSERT INTO ai_call_log (user_id, stock_code, stock_name) VALUES (%s, %s, %s)", + (_uid, stock_code, stock_name)) + _conn.commit() + _conn.close() + except Exception: + pass + + # 流式调用AI + for chunk in analyze_stock_stream(stock_code, stock_name, stock_data): + yield f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n" + + yield "data: [DONE]\n\n" + + return Response(generate(), mimetype='text/event-stream', headers={ + 'Cache-Control': 'no-cache', + 'X-Accel-Buffering': 'no' + }) + + +@bp.route('/ai_analyze/', methods=['GET']) +def ai_analyze(stock_code): + """使用豆包AI分析股票""" + try: + from services.doubao_api import analyze_stock + from services.mairui_api import get_realtime_price as mairui_price, get_financial_indicators + from db import get_db + + # 获取股票数据 + stock_data = {} + + # 获取实时价格 + price_result = mairui_price(stock_code) + if price_result['success']: + data = price_result['data'] + stock_data['price'] = data.get('price') + stock_data['change'] = data.get('change') + stock_data['pe'] = data.get('pe') + stock_data['pb'] = data.get('pb') + stock_data['total_market_cap'] = data.get('total_market_cap') + + # 获取财务指标 + fin_result = get_financial_indicators(stock_code) + if fin_result['success']: + data = fin_result['data'] + stock_data['roe'] = data.get('roe') + + # 获取股票名称和行业 + stock_name = get_stock_name(stock_code) or stock_code + + # 获取近三日资金流向 + try: + conn = get_db() + if conn: + cur = conn.cursor() + cur.execute(""" + SELECT trade_date, change_pct, main_net_inflow_pct, super_net_inflow_pct + FROM stock_fund_flow_history + WHERE code = %s + ORDER BY trade_date DESC + LIMIT 3 + """, (stock_code,)) + rows = cur.fetchall() + fund_flow = [] + for row in rows: + fund_flow.append({ + 'date': row[0].strftime('%m-%d') if row[0] else '', + 'change_pct': float(row[1]) if row[1] else 0, + 'main_pct': float(row[2]) if row[2] else 0, + 'super_pct': float(row[3]) if row[3] else 0, + }) + stock_data['fund_flow_3days'] = fund_flow + conn.close() + except Exception as e: + print(f"获取资金流向失败: {e}") + + # 调用AI分析 + result = analyze_stock(stock_code, stock_name, stock_data) + return jsonify(result) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@bp.route('/technical_signals/', methods=['GET']) +def technical_signals(stock_code): + """检测7个技术交易信号(主升浪、底背离、龙抬头、真龙、短底背离、老鼠仓、反弹),并给出与提醒一致的综合推荐""" + try: + import pandas as pd + from services.signal_detector import detect_all_signals + + lookback = request.args.get('lookback', 5, type=int) + days = request.args.get('days', 120, type=int) + holding_codes_str = request.args.get('holding_codes', '') + holding_set = set(c.strip() for c in holding_codes_str.split(',') if c.strip()) + is_holding = stock_code in holding_set + + kline_df = _get_kline_data(stock_code, days) + if kline_df is None or kline_df.empty: + return jsonify({'success': False, 'error': '无法获取K线数据'}), 400 + + result = detect_all_signals(kline_df, lookback=lookback) + if 'error' in result: + return jsonify({'success': False, 'error': result['error']}), 400 + + stock_name = get_stock_name(stock_code) or stock_code + signal_status = result.get('signal_status', []) + indicators = result.get('indicators', {}) + triggered_count = sum(1 for s in signal_status if s.get('triggered')) + + st, recommend_text, recommend_reason, recommend_rate = _compute_recommend( + signal_status, indicators, triggered_count, is_holding, + ) + + # 计算持仓说明:当非持仓且建议买入时,模拟持仓情况下的建议 + holding_note = None + if not is_holding and recommend_text == '买入': + _, disp_h, reason_h, _ = _compute_recommend(signal_status, indicators, triggered_count, True) + if disp_h in ('卖出', '观望'): + holding_note = f"若已持仓:{disp_h}({reason_h})" + + resp = { + 'success': True, + 'stock_code': stock_code, + 'stock_name': stock_name, + 'signals': result['signals'], + 'latest_signals': result['latest_signals'], + 'signal_summary': result['signal_summary'], + 'indicators': indicators, + 'signal_status': signal_status, + 'recommend_type': st, + 'recommend_text': recommend_text, + 'recommend_reason': recommend_reason, + 'recommend_rate': recommend_rate, + } + if holding_note: + resp['holding_note'] = holding_note + return jsonify(resp) + except Exception as e: + import traceback + traceback.print_exc() + return jsonify({'success': False, 'error': str(e)}), 500 + + +@bp.route('/batch_technical_signals', methods=['POST']) +def batch_technical_signals(): + """批量检测技术交易信号 — 优先从 stock_signal_scan 读取(与提醒一致),无记录时实时计算""" + try: + import pandas as pd + import psycopg2 + from psycopg2.extras import RealDictCursor + from services.signal_detector import detect_all_signals + from config import Config + + data = request.get_json() + codes = data.get('codes', []) + lookback = data.get('lookback', 5) + days = data.get('days', 120) + holding_codes = data.get('holding_codes', []) + holding_set = set(holding_codes) + + if not codes: + return jsonify({'success': False, 'error': '股票代码列表为空'}), 400 + + codes = codes[:20] # 限制数量 + + # 查询实时价格 & 当日扫描缓存 + price_map = {} + change_map = {} + scan_map = {} + try: + conn = psycopg2.connect( + host=Config.DB_HOST, port=Config.DB_PORT, + dbname=Config.DB_NAME, user=Config.DB_USER, password=Config.DB_PASSWORD, + ) + cur = conn.cursor(cursor_factory=RealDictCursor) + placeholders = ','.join(['%s'] * len(codes)) + + # 实时价格 + cur.execute(f"SELECT code, price, change_pct FROM stock_realtime_price WHERE code IN ({placeholders})", codes) + for pr in cur.fetchall(): + price_map[pr['code']] = float(pr['price'] or 0) + change_map[pr['code']] = float(pr['change_pct'] or 0) + + # 全景扫描缓存:优先今天,无则回退到最近可用日期 + scan_date = date.today().strftime('%Y-%m-%d') + cur.execute(f""" + SELECT code, name, signal_status, indicators, triggered_count + FROM stock_signal_scan + WHERE scan_date = %s AND code IN ({placeholders}) + """, [scan_date] + codes) + scan_rows = cur.fetchall() + if not scan_rows: + # 今天无扫描数据,回退到最近一次扫描 + cur.execute("SELECT MAX(scan_date)::text FROM stock_signal_scan") + latest_row = cur.fetchone() + if latest_row and latest_row[0]: + scan_date = latest_row[0] + cur.execute(f""" + SELECT code, name, signal_status, indicators, triggered_count + FROM stock_signal_scan + WHERE scan_date = %s AND code IN ({placeholders}) + """, [scan_date] + codes) + scan_rows = cur.fetchall() + for row in scan_rows: + scan_map[row['code']] = row + + cur.close() + conn.close() + except Exception as e: + print(f"批量扫描获取数据失败: {e}") + + results = [] + errors = [] + + for code in codes: + try: + scan = scan_map.get(code) + if scan: + # 优先使用全景扫描缓存(与提醒推荐一致) + signal_status = scan['signal_status'] or [] + indicators = scan['indicators'] or {} + triggered_count = scan['triggered_count'] or 0 + stock_name = scan['name'] or get_stock_name(code) or code + else: + # 无当日扫描记录,实时计算 + kline_df = _get_kline_data(code, days) + if kline_df is None or kline_df.empty: + errors.append({'code': code, 'error': '无法获取K线数据'}) + continue + result = detect_all_signals(kline_df, lookback=lookback) + signal_status = result.get('signal_status', []) + indicators = result.get('indicators', {}) + triggered_count = sum(1 for ss in signal_status if ss.get('triggered')) + stock_name = get_stock_name(code) or code + + is_holding = code in holding_set + st, disp, reason, rate = _compute_recommend(signal_status, indicators, triggered_count, is_holding) + + # 计算持仓说明 + holding_note = None + if not is_holding and disp == '买入': + _, disp_h, reason_h, _ = _compute_recommend(signal_status, indicators, triggered_count, True) + if disp_h in ('卖出', '观望'): + holding_note = f"若已持仓:{disp_h}({reason_h})" + + item = { + 'code': code, + 'name': stock_name, + 'latest_signals': [], + 'indicators': indicators, + 'signal_status': signal_status, + 'triggered_count': triggered_count, + 'price': price_map.get(code), + 'change_pct': change_map.get(code), + 'recommend_type': st, + 'recommend_text': disp, + 'recommend_reason': reason, + 'recommend_rate': rate, + } + if holding_note: + item['holding_note'] = holding_note + results.append(item) + except Exception as e: + errors.append({'code': code, 'error': str(e)}) + + return jsonify({ + 'success': True, + 'results': results, + 'errors': errors, + 'total': len(codes), + }) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@bp.route('/scan_results', methods=['GET']) +def get_scan_results(): + """查询全量扫描结果""" + try: + import psycopg2 + from config import Config + + scan_date = request.args.get('date', datetime.now().strftime('%Y-%m-%d')) + min_triggered = int(request.args.get('min_triggered', 0)) + signal_type = request.args.get('signal_type', '') + page = int(request.args.get('page', 1)) + per_page = int(request.args.get('per_page', 50)) + sort_by = request.args.get('sort', 'triggered_count') + holding_codes_str = request.args.get('holding_codes', '') + holding_set = set(c.strip() for c in holding_codes_str.split(',') if c.strip()) + recommend_text = (request.args.get('recommend_text') or '').strip() + + conn = psycopg2.connect( + host=Config.DB_HOST, port=Config.DB_PORT, + dbname=Config.DB_NAME, user=Config.DB_USER, password=Config.DB_PASSWORD, + ) + cur = conn.cursor() + + # 检查请求日期是否有数据,如果没有则自动回退到最近可用的扫描日期 + cur.execute( + "SELECT count(*) FROM stock_signal_scan WHERE scan_date = %s", + (scan_date,), + ) + total_scanned = cur.fetchone()[0] + + if total_scanned == 0 and not request.args.get('date'): + # 前端未指定日期且今天无数据,自动回退到最近一次扫描日期 + cur.execute("SELECT MAX(scan_date)::text FROM stock_signal_scan") + latest_date_row = cur.fetchone() + if latest_date_row and latest_date_row[0]: + scan_date = latest_date_row[0] + cur.execute( + "SELECT count(*) FROM stock_signal_scan WHERE scan_date = %s", + (scan_date,), + ) + total_scanned = cur.fetchone()[0] + + cur.execute("SELECT count(*) FROM stock_realtime_price") + total_stocks = cur.fetchone()[0] + + where_clauses = ["scan_date = %s"] + params = [scan_date] + + # 过滤退市/ST股票(psycopg2中 %% 才是字面 %) + where_clauses.append("name NOT LIKE '%%退%%'") + where_clauses.append("name NOT LIKE '%%ST%%'") + + if min_triggered > 0: + where_clauses.append("triggered_count >= %s") + params.append(min_triggered) + + if signal_type: + signal_types = [s.strip() for s in signal_type.split(',') if s.strip()] + for st in signal_types: + where_clauses.append("""EXISTS ( + SELECT 1 FROM jsonb_array_elements(signal_status) elem + WHERE elem.value->>'type' = %s AND (elem.value->>'triggered')::boolean = true + )""") + params.append(st) + + where = " AND ".join(where_clauses) + cur.execute(f"SELECT count(*) FROM stock_signal_scan WHERE {where}", params) + filtered_count = cur.fetchone()[0] + + order = "s.triggered_count DESC, s.code ASC" + if sort_by == 'code': + order = "s.code ASC" + + where_s = where.replace("scan_date", "s.scan_date") \ + .replace("triggered_count", "s.triggered_count") \ + .replace("signal_status", "s.signal_status") \ + .replace("name NOT", "s.name NOT") + + offset = (page - 1) * per_page + results = [] + codes_for_page = None + + if recommend_text: + cur.execute(""" + SELECT code, signal_status, indicators, triggered_count + FROM stock_signal_scan WHERE scan_date = %s + AND name NOT LIKE '%%退%%' AND name NOT LIKE '%%ST%%' + """, (scan_date,)) + recommend_counts = {} + filtered_ordered = [] + for r in cur.fetchall(): + code, signal_status, indicators, triggered_count = r[0], r[1] or [], r[2] or {}, r[3] or 0 + is_holding = code in holding_set + _st, disp, _reason, _rate = _compute_recommend(signal_status, indicators, triggered_count, is_holding) + recommend_counts[disp] = recommend_counts.get(disp, 0) + 1 + if disp == recommend_text: + filtered_ordered.append((code, triggered_count or 0)) + filtered_ordered.sort(key=lambda x: (-x[1], x[0])) + filtered_count = len(filtered_ordered) + codes_for_page = [c for c, _ in filtered_ordered[offset:offset + per_page]] + else: + cur.execute(f""" + SELECT s.code, s.name, s.triggered_count, s.signal_status, s.indicators, s.latest_signals, + p.price, p.change_pct + FROM stock_signal_scan s + LEFT JOIN stock_realtime_price p ON s.code = p.code + WHERE {where_s} + ORDER BY {order} + LIMIT %s OFFSET %s + """, params + [per_page, offset]) + for row in cur.fetchall(): + code, name, triggered_count, signal_status, indicators = row[0], row[1], row[2], row[3] or [], row[4] or {} + is_holding = code in holding_set + st, disp, reason, rate = _compute_recommend(signal_status, indicators, triggered_count or 0, is_holding) + rec_cls = 'hold' if st == 'hold' else st + item = { + 'code': code, + 'name': name, + 'triggered_count': triggered_count, + 'signal_status': signal_status, + 'indicators': indicators, + 'latest_signals': row[5] or [], + 'price': float(row[6]) if row[6] else None, + 'change_pct': float(row[7]) if row[7] else None, + 'recommend_type': rec_cls, + 'recommend_text': disp, + 'recommend_reason': reason, + 'recommend_rate': rate, + } + if not is_holding and disp == '买入': + _, dh, rh, _ = _compute_recommend(signal_status, indicators, triggered_count or 0, True) + if dh in ('卖出', '观望'): + item['holding_note'] = f"若已持仓:{dh}({rh})" + results.append(item) + + if codes_for_page is not None: + if codes_for_page: + placeholders = ','.join(['%s'] * len(codes_for_page)) + cur.execute(f""" + SELECT s.code, s.name, s.triggered_count, s.signal_status, s.indicators, s.latest_signals, + p.price, p.change_pct + FROM stock_signal_scan s + LEFT JOIN stock_realtime_price p ON s.code = p.code + WHERE s.scan_date = %s AND s.code IN ({placeholders}) + """, [scan_date] + codes_for_page) + by_code = {} + for row in cur.fetchall(): + code, name, triggered_count, signal_status, indicators = row[0], row[1], row[2], row[3] or [], row[4] or {} + is_holding = code in holding_set + st, disp, reason, rate = _compute_recommend(signal_status, indicators, triggered_count or 0, is_holding) + rec_cls = 'hold' if st == 'hold' else st + item = { + 'code': code, 'name': name, 'triggered_count': triggered_count, + 'signal_status': signal_status, 'indicators': indicators, + 'latest_signals': row[5] or [], 'price': float(row[6]) if row[6] else None, + 'change_pct': float(row[7]) if row[7] else None, + 'recommend_type': rec_cls, 'recommend_text': disp, + 'recommend_reason': reason, 'recommend_rate': rate, + } + if not is_holding and disp == '买入': + _, dh, rh, _ = _compute_recommend(signal_status, indicators, triggered_count or 0, True) + if dh in ('卖出', '观望'): + item['holding_note'] = f"若已持仓:{dh}({rh})" + by_code[code] = item + results = [by_code[c] for c in codes_for_page if c in by_code] + + cur.execute(""" + SELECT + s.value->>'name' as signal_name, + s.value->>'type' as signal_type, + count(*) as cnt + FROM stock_signal_scan, jsonb_array_elements(signal_status) s + WHERE scan_date = %s AND (s.value->>'triggered')::boolean = true + GROUP BY s.value->>'name', s.value->>'type' + ORDER BY cnt DESC + """, (scan_date,)) + signal_distribution = [ + {'name': r[0], 'type': r[1], 'count': r[2]} + for r in cur.fetchall() + ] + + cur.execute(""" + SELECT count(*) FROM stock_signal_scan + WHERE scan_date = %s AND triggered_count > 0 + """, (scan_date,)) + triggered_stocks = cur.fetchone()[0] + + cur.execute(""" + SELECT MIN(created_at)::text, MAX(created_at)::text + FROM stock_signal_scan WHERE scan_date = %s + """, (scan_date,)) + time_row = cur.fetchone() + scan_start = time_row[0] if time_row else None + scan_end = time_row[1] if time_row else None + + if not recommend_text: + cur.execute(""" + SELECT code, signal_status, indicators, triggered_count + FROM stock_signal_scan WHERE scan_date = %s + """, (scan_date,)) + recommend_counts = {} + for r in cur.fetchall(): + code, signal_status, indicators, triggered_count = r[0], r[1] or [], r[2] or {}, r[3] or 0 + is_holding = code in holding_set + _st, disp, _reason, _rate = _compute_recommend(signal_status, indicators, triggered_count, is_holding) + recommend_counts[disp] = recommend_counts.get(disp, 0) + 1 + + cur.close() + conn.close() + + return jsonify({ + 'success': True, + 'scan_date': scan_date, + 'total_scanned': total_scanned, + 'total_stocks': total_stocks, + 'triggered_stocks': triggered_stocks, + 'filtered_count': filtered_count, + 'recommend_counts': recommend_counts, + 'page': page, + 'per_page': per_page, + 'total_pages': (filtered_count + per_page - 1) // per_page, + 'results': results, + 'signal_distribution': signal_distribution, + 'scan_start': scan_start, + 'scan_end': scan_end, + }) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +def _compute_recommend(signal_status, indicators, triggered_count, is_holding): + """统一推荐逻辑 — 委托给 services.stock_algorithms.compute_recommend""" + return compute_recommend(signal_status, indicators, triggered_count, is_holding) + + +@bp.route('/signal_alerts', methods=['POST']) +def signal_alerts(): + """基于信号扫描结果生成买入/卖出/观望提醒(与扫描结果共用 _compute_recommend)""" + import psycopg2 + from psycopg2.extras import RealDictCursor + from config import Config + + try: + data = request.get_json() or {} + stock_codes = [s.get('code', '') for s in data.get('stocks', [])] + stock_names = {s.get('code', ''): s.get('name', '') for s in data.get('stocks', [])} + holding_codes = data.get('holding_codes', []) + + if not stock_codes: + return jsonify({'success': True, 'results': []}) + + conn = psycopg2.connect( + host=Config.DB_HOST, port=Config.DB_PORT, + dbname=Config.DB_NAME, user=Config.DB_USER, password=Config.DB_PASSWORD, + ) + cur = conn.cursor(cursor_factory=RealDictCursor) + + # 优先今天的扫描数据,无则回退到最近可用日期 + scan_date = date.today().strftime('%Y-%m-%d') + + placeholders = ','.join(['%s'] * len(stock_codes)) + cur.execute(f""" + SELECT code, name, signal_status, indicators, triggered_count + FROM stock_signal_scan + WHERE scan_date = %s AND code IN ({placeholders}) + """, [scan_date] + stock_codes) + rows = cur.fetchall() + + if not rows: + # 今天无扫描数据,回退到最近一次扫描日期 + cur.execute("SELECT MAX(scan_date)::text AS d FROM stock_signal_scan") + latest = cur.fetchone() + if latest and latest['d']: + scan_date = latest['d'] + cur.execute(f""" + SELECT code, name, signal_status, indicators, triggered_count + FROM stock_signal_scan + WHERE scan_date = %s AND code IN ({placeholders}) + """, [scan_date] + stock_codes) + rows = cur.fetchall() + + cur.execute(f""" + SELECT code, price, change_pct FROM stock_realtime_price + WHERE code IN ({placeholders}) + """, stock_codes) + price_rows = cur.fetchall() + conn.close() + + price_map = {} + change_map = {} + for pr in price_rows: + price_map[pr['code']] = float(pr['price'] or 0) + change_map[pr['code']] = float(pr['change_pct'] or 0) + + scan_map = {} + for row in rows: + scan_map[row['code']] = row + + results = [] + for code in stock_codes: + name = stock_names.get(code, '') + scan = scan_map.get(code) + is_holding = code in holding_codes + + signal_type = 'watch' + recommend_text = '观望' + reason = '今日尚未扫描此股' + recommend_rate = 0 + price = price_map.get(code, 0) + triggered_signals = [] + + holding_note = None + if scan: + st, disp, reason, recommend_rate = _compute_recommend( + scan['signal_status'] or [], + scan['indicators'] or {}, + scan['triggered_count'] or 0, + is_holding, + ) + signal_type = st + recommend_text = disp + name = name or scan['name'] or '' + for s in (scan['signal_status'] or []): + if s.get('triggered'): + triggered_signals.append(s.get('name', s.get('type', ''))) + # 推荐买入时,再按持仓算一遍,给出综合结论,避免买入后立刻变成卖出令用户困惑 + if not is_holding and disp == '买入': + _, disp_h, reason_h, _ = _compute_recommend( + scan['signal_status'] or [], + scan['indicators'] or {}, + scan['triggered_count'] or 0, + True, + ) + if disp_h in ('卖出', '观望'): + holding_note = f"若已持仓:{disp_h}({reason_h})" + else: + signal_type = 'watch' + reason = '尚无扫描数据' + + scan_change = change_map.get(code, 0) + item = { + 'code': code, + 'name': name, + 'signalType': signal_type, + 'recommendText': recommend_text, + 'recommendRate': recommend_rate, + 'reason': reason, + 'price': price, + 'changePct': scan_change, + 'scanPrice': price, + 'scanChangePct': scan_change, + 'triggeredSignals': triggered_signals, + } + if holding_note: + item['holdingNote'] = holding_note + results.append(item) + + return jsonify({ + 'success': True, + 'results': results, + 'total': len(stock_codes), + 'success_count': len(results), + 'error_count': 0, + }) + except Exception as e: + import traceback + traceback.print_exc() + return jsonify({'success': False, 'error': str(e)}), 500 + + +def _is_scan_running(): + import subprocess + try: + result = subprocess.run(['/usr/bin/pgrep', '-f', 'full_signal_scan.py'], capture_output=True, text=True) + return result.returncode == 0 + except Exception: + return False + + +@bp.route('/scan_status', methods=['GET']) +def get_scan_status(): + """查询扫描进度""" + try: + import psycopg2 + from config import Config + + conn = psycopg2.connect( + host=Config.DB_HOST, port=Config.DB_PORT, + dbname=Config.DB_NAME, user=Config.DB_USER, password=Config.DB_PASSWORD, + ) + cur = conn.cursor() + + scan_date = datetime.now().strftime('%Y-%m-%d') + cur.execute( + "SELECT count(*) FROM stock_signal_scan WHERE scan_date = %s", + (scan_date,), + ) + scanned = cur.fetchone()[0] + + cur.execute("SELECT count(*) FROM stock_realtime_price") + total = cur.fetchone()[0] + + cur.execute( + "SELECT count(*) FROM stock_signal_scan WHERE scan_date = %s AND triggered_count > 0", + (scan_date,), + ) + triggered = cur.fetchone()[0] + + cur.close() + conn.close() + + return jsonify({ + 'success': True, + 'scan_date': scan_date, + 'total': total, + 'scanned': scanned, + 'triggered': triggered, + 'progress': round(scanned / total * 100, 1) if total > 0 else 0, + 'is_complete': scanned >= total, + 'scan_running': _is_scan_running(), + }) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@bp.route('/start_full_scan', methods=['POST']) +def start_full_scan(): + """在后台启动全量信号扫描""" + try: + import subprocess + import os + + if _is_scan_running(): + return jsonify({'success': False, 'error': '扫描正在进行中,请稍后再试'}), 409 + + script_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'full_signal_scan.py') + if not os.path.exists(script_path): + return jsonify({'success': False, 'error': '扫描脚本不存在'}), 404 + + force = request.json.get('force', False) if request.is_json else False + env = os.environ.copy() + env['PATH'] = '/opt/stock-app/venv/bin:/usr/local/bin:/usr/bin:/bin' + if force: + env['FORCE_RESCAN'] = '1' + + subprocess.Popen( + ['python', script_path], + cwd=os.path.dirname(script_path), + stdout=open(os.path.join(os.path.dirname(script_path), 'scan.log'), 'w'), + stderr=subprocess.STDOUT, + env=env, + start_new_session=True, + ) + + return jsonify({ + 'success': True, + 'message': '全量扫描已在后台启动' + ('(强制重新扫描)' if force else ''), + }) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@bp.route('/scan_strategy', methods=['GET']) +def get_scan_strategy(): + """基于体系最强战法,给出分梯队买卖建议。与全景扫描推荐共用 _compute_recommend,算法一致。""" + try: + import psycopg2 + from psycopg2.extras import RealDictCursor + from config import Config + + scan_date = request.args.get('date', datetime.now().strftime('%Y-%m-%d')) + holding_codes_str = request.args.get('holding_codes', '') + holding_set = set(c.strip() for c in holding_codes_str.split(',') if c.strip()) + + conn = psycopg2.connect( + host=Config.DB_HOST, port=Config.DB_PORT, + dbname=Config.DB_NAME, user=Config.DB_USER, password=Config.DB_PASSWORD, + ) + cur = conn.cursor(cursor_factory=RealDictCursor) + + # 如果前端未指定日期,且当天无数据,自动回退到最近扫描日期 + if not request.args.get('date'): + cur.execute("SELECT count(*) FROM stock_signal_scan WHERE scan_date = %s", (scan_date,)) + if cur.fetchone()['count'] == 0: + cur.execute("SELECT MAX(scan_date)::text AS d FROM stock_signal_scan") + row = cur.fetchone() + if row and row['d']: + scan_date = row['d'] + + cur.execute(""" + SELECT code, name, triggered_count, signal_status, indicators + FROM stock_signal_scan WHERE scan_date = %s + AND name NOT LIKE '%%退%%' AND name NOT LIKE '%%ST%%' + ORDER BY triggered_count DESC, code + """, (scan_date,)) + rows = cur.fetchall() + cur.close() + conn.close() + + tier1, tier2, tier3, tier4 = [], [], [], [] + for r in rows: + code = r['code'] + name = r['name'] + signal_status = r.get('signal_status') or [] + indicators = r.get('indicators') or {} + triggered_count = r.get('triggered_count') or 0 + is_holding = code in holding_set + st, disp, reason, _ = _compute_recommend(signal_status, indicators, triggered_count, is_holding) + triggered = [s.get('name', s.get('type', '')) for s in signal_status if s.get('triggered')] + item = { + 'code': code, 'name': name, 'triggered_count': triggered_count, + 'triggered_signals': triggered, + 'signal_status': signal_status, + 'indicators': indicators, + } + # 计算持仓说明 + if not is_holding and disp == '买入': + _, dh, rh, _ = _compute_recommend(signal_status, indicators, triggered_count, True) + if dh in ('卖出', '观望'): + item['holding_note'] = f"若已持仓:{dh}({rh})" + if disp == '买入': + tier1.append(item) + elif disp in ('加仓', '持有'): + tier2.append(item) + elif disp == '关注': + sig_map = {s.get('type', ''): s for s in signal_status} + has_dragon = sig_map.get('dragon_head', {}).get('triggered', False) + if has_dragon: + # 龙抬头+MACD死叉 → 信号冲突,关注等待金叉 + tier3.append(item) + else: + # 底背离/其他信号 → 纳入关注,等待龙抬头 + tier4.append(item) + + return jsonify({ + 'success': True, + 'scan_date': scan_date, + 'tiers': [ + { + 'level': 1, + 'action': '立即买入', + 'emoji': '🔴', + 'condition': '龙抬头 + MACD金叉(核心买入信号)', + 'desc': '龙抬头=资金进场起爆点,MACD金叉确认趋势向上', + 'count': len(tier1), + 'stocks': tier1, + }, + { + 'level': 2, + 'action': '持仓加仓', + 'emoji': '🟢', + 'condition': '主升浪/真龙(持仓持有)', + 'desc': '趋势最强阶段,不见主升浪消失不出场', + 'count': len(tier2), + 'stocks': tier2, + }, + { + 'level': 3, + 'action': '关注', + 'emoji': '🟡', + 'condition': '龙抬头+MACD死叉(信号冲突)', + 'desc': '龙抬头出现但MACD趋势未确认,等待金叉再入场', + 'count': len(tier3), + 'stocks': tier3, + }, + { + 'level': 4, + 'action': '纳入关注', + 'emoji': '👀', + 'condition': '日线底背离/其他信号', + 'desc': '底部信号出现,等待龙抬头+MACD金叉确认', + 'count': len(tier4), + 'stocks': tier4, + }, + ], + }) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +def _get_kline_data(stock_code, days=120): + """获取K线数据 — 委托给 services.stock_algorithms.get_kline_data(实时分析不用本地DB缓存)""" + return algo_get_kline_data(stock_code, days=days, use_local_db=False) + + +@bp.route('/bull_stocks', methods=['GET']) +def get_bull_stocks(): + """ + 找牛股 — 基于标准牛股启动信号先后顺序(suanfa.md) + + 流程: + 阶段1: 底部探测(日线底背离/短底背离)→ 跌到底部 + 阶段2: 资金进场(龙抬头)→ 短线起爆,最佳买入 + 阶段3: 趋势确立(真龙)→ 中期趋势确认 + 阶段4: 加速拉升(主升浪)→ 利润兑现最快 + 阶段5: 回调补涨(反弹)→ 中途回调补涨 + + 参数: + stage: 可选,筛选特定阶段(1-5) + holdingStocks: 可选,持仓代码逗号分隔 + """ + import psycopg2 + from psycopg2.extras import RealDictCursor + from config import Config + + try: + stage_filter = request.args.get('stage', type=int, default=0) + holding_str = request.args.get('holdingStocks', '') + holding_codes = set(holding_str.split(',')) if holding_str else set() + + conn = psycopg2.connect( + host=Config.DB_HOST, port=Config.DB_PORT, + dbname=Config.DB_NAME, user=Config.DB_USER, password=Config.DB_PASSWORD, + ) + cur = conn.cursor(cursor_factory=RealDictCursor) + + # 获取最近一次扫描数据(过滤退市/ST) + cur.execute(""" + SELECT code, name, triggered_count, signal_status, indicators + FROM stock_signal_scan + WHERE scan_date = (SELECT MAX(scan_date) FROM stock_signal_scan) + AND triggered_count > 0 + AND name NOT LIKE '%%退%%' AND name NOT LIKE '%%ST%%' + """) + scan_rows = cur.fetchall() + + # 获取价格数据 + cur.execute("SELECT code, price, change_pct FROM stock_realtime_price WHERE price > 0") + price_map = {} + for p in cur.fetchall(): + price_map[p['code']] = {'price': float(p['price']), 'change_pct': float(p.get('change_pct') or 0)} + + conn.close() + + # 使用统一算法找牛股 + result = find_bull_stocks(scan_rows, holding_codes) + + # 为每只股票附加价格信息 + for stage_num, stocks in result['stages'].items(): + for stock in stocks: + pm = price_map.get(stock['code'], {}) + stock['price'] = pm.get('price', 0) + stock['change_pct'] = pm.get('change_pct', 0) + + # 如果指定了阶段筛选 + if stage_filter and stage_filter in result['stages']: + filtered_stages = {stage_filter: result['stages'][stage_filter]} + else: + filtered_stages = result['stages'] + + # 构建阶段信息(给前端用) + stage_info_list = [] + for sn in [1, 2, 3, 4, 5]: + info = BULL_STAGES[sn] + stage_info_list.append({ + 'stage': sn, + 'name': info['name'], + 'icon': info['icon'], + 'color': info['color'], + 'desc': info['desc'], + 'count': result['summary'].get(sn, 0), + }) + + return jsonify({ + 'success': True, + 'stages': {str(k): v for k, v in filtered_stages.items()}, + 'summary': result['summary'], + 'total': result['total'], + 'stage_info': stage_info_list, + }) + except Exception as e: + import traceback + traceback.print_exc() + return jsonify({'success': False, 'error': str(e)}), 500 diff --git a/stock-html/routes/auth.py b/stock-html/routes/auth.py new file mode 100644 index 0000000..234cea6 --- /dev/null +++ b/stock-html/routes/auth.py @@ -0,0 +1,136 @@ +""" +用户认证 API 路由 +使用邮箱和密码进行登录/注册 +""" +from flask import Blueprint, request, jsonify, session +from db import create_user, verify_user + +bp = Blueprint('auth', __name__, url_prefix='/api') + + +@bp.route('/register', methods=['POST']) +def register(): + """用户注册""" + try: + data = request.get_json() + email = data.get('email', '').strip().lower() + password = data.get('password', '') + + if not email or not password: + return jsonify({'success': False, 'error': '邮箱和密码不能为空'}), 400 + + # 验证邮箱格式 + import re + if not re.match(r'^[^\s@]+@[^\s@]+\.[^\s@]+$', email): + return jsonify({'success': False, 'error': '请输入有效的邮箱地址'}), 400 + + if len(password) < 6: + return jsonify({'success': False, 'error': '密码至少6位'}), 400 + + user, error = create_user(email, password) + if error: + return jsonify({'success': False, 'error': error}), 400 + + # 自动登录 + session['user_id'] = user['id'] + session['username'] = user['username'] # username存的是email + session['email'] = user['username'] + + return jsonify({ + 'success': True, + 'user': {'id': user['id'], 'email': user['username'], 'username': user['username'].split('@')[0]} + }) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@bp.route('/login', methods=['POST']) +def login(): + """用户登录""" + try: + data = request.get_json() + email = data.get('email', '').strip().lower() + password = data.get('password', '') + + if not email or not password: + return jsonify({'success': False, 'error': '邮箱和密码不能为空'}), 400 + + user, error = verify_user(email, password) + if error: + return jsonify({'success': False, 'error': error}), 400 + + session['user_id'] = user['id'] + session['username'] = user['username'] + session['email'] = user['username'] + + return jsonify({ + 'success': True, + 'user': {'id': user['id'], 'email': user['username'], 'username': user['username'].split('@')[0]} + }) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@bp.route('/logout', methods=['POST']) +def logout(): + """用户登出""" + session.clear() + return jsonify({'success': True}) + + +@bp.route('/change_password', methods=['POST']) +def change_password(): + """修改密码""" + if 'user_id' not in session: + return jsonify({'success': False, 'error': '请先登录'}), 401 + + try: + data = request.get_json() + old_password = data.get('old_password', '') + new_password = data.get('new_password', '') + + if not old_password or not new_password: + return jsonify({'success': False, 'error': '请填写所有字段'}), 400 + + if len(new_password) < 6: + return jsonify({'success': False, 'error': '新密码至少6位'}), 400 + + from db import change_user_password + success, error = change_user_password(session['user_id'], old_password, new_password) + + if success: + return jsonify({'success': True}) + else: + return jsonify({'success': False, 'error': error}), 400 + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@bp.route('/me', methods=['GET']) +def get_current_user(): + """获取当前用户""" + if 'user_id' in session: + email = session.get('email', session.get('username', '')) + is_admin = False + try: + from db import get_db + conn = get_db() + if conn: + cur = conn.cursor() + cur.execute("SELECT is_admin FROM users WHERE id = %s", (session['user_id'],)) + row = cur.fetchone() + if row: + is_admin = bool(row[0]) + conn.close() + except Exception: + pass + return jsonify({ + 'success': True, + 'user': { + 'id': session['user_id'], + 'email': email, + 'username': email.split('@')[0] if '@' in email else email, + 'is_admin': is_admin + } + }) + return jsonify({'success': False, 'user': None}) diff --git a/stock-html/routes/market.py b/stock-html/routes/market.py new file mode 100644 index 0000000..d769ce7 --- /dev/null +++ b/stock-html/routes/market.py @@ -0,0 +1,525 @@ +""" +市场数据 API 路由 +""" +from flask import Blueprint, request, jsonify +import pandas as pd +from datetime import datetime, timedelta +from services.stock_service import get_stock_fund_flow, load_cached_data +from services.stock_algorithms import get_kline_data as algo_get_kline_data +from db import get_db + +bp = Blueprint('market', __name__, url_prefix='/api') + + +# ============ 数据库查询API(高速版) ============ + +@bp.route('/db/realtime_price/', methods=['GET']) +def db_realtime_price(stock_code): + """从数据库获取实时价格(毫秒级响应)""" + conn = get_db() + if not conn: + return jsonify({'success': False, 'error': '数据库连接失败'}), 500 + + try: + from psycopg2.extras import RealDictCursor + cur = conn.cursor(cursor_factory=RealDictCursor) + cur.execute(""" + SELECT code, name, price, change_pct, change_amount, + volume, amount, high, low, open, prev_close, + pe, pb, total_market_cap, updated_at::text + FROM stock_realtime_price + WHERE code = %s + """, (stock_code,)) + row = cur.fetchone() + + if not row: + return jsonify({'success': False, 'error': '未找到数据'}), 404 + + return jsonify({ + 'success': True, + 'data': dict(row) + }) + finally: + conn.close() + + +@bp.route('/db/realtime_prices', methods=['POST']) +def db_realtime_prices(): + """批量获取实时价格""" + data = request.get_json() + codes = data.get('codes', []) + + if not codes: + return jsonify({'success': True, 'data': []}) + + conn = get_db() + if not conn: + return jsonify({'success': False, 'error': '数据库连接失败'}), 500 + + try: + from psycopg2.extras import RealDictCursor + cur = conn.cursor(cursor_factory=RealDictCursor) + cur.execute(""" + SELECT code, name, price, change_pct, pe, pb, total_market_cap, updated_at::text + FROM stock_realtime_price + WHERE code = ANY(%s) + """, (codes,)) + rows = cur.fetchall() + + return jsonify({ + 'success': True, + 'data': [dict(row) for row in rows] + }) + finally: + conn.close() + + +@bp.route('/db/fund_flow_today/', methods=['GET']) +def db_fund_flow_today(stock_code): + """从数据库获取今日资金流向""" + conn = get_db() + if not conn: + return jsonify({'success': False, 'error': '数据库连接失败'}), 500 + + try: + from psycopg2.extras import RealDictCursor + cur = conn.cursor(cursor_factory=RealDictCursor) + cur.execute(""" + SELECT code, name, main_net_inflow, main_net_inflow_pct, + super_net_inflow, super_net_inflow_pct, + big_net_inflow, big_net_inflow_pct, + price, change_pct, updated_at::text + FROM stock_fund_flow_today + WHERE code = %s + """, (stock_code,)) + row = cur.fetchone() + + if not row: + return jsonify({'success': False, 'error': '未找到数据'}), 404 + + return jsonify({ + 'success': True, + 'data': dict(row) + }) + finally: + conn.close() + + +@bp.route('/db/fund_flow_today_batch', methods=['POST']) +def db_fund_flow_today_batch(): + """批量获取今日资金流向""" + data = request.get_json() + codes = data.get('codes', []) + + if not codes: + return jsonify({'success': True, 'data': []}) + + conn = get_db() + if not conn: + return jsonify({'success': False, 'error': '数据库连接失败'}), 500 + + try: + from psycopg2.extras import RealDictCursor + cur = conn.cursor(cursor_factory=RealDictCursor) + cur.execute(""" + SELECT code, name, main_net_inflow, main_net_inflow_pct, + super_net_inflow, super_net_inflow_pct, + price, change_pct, updated_at::text + FROM stock_fund_flow_today + WHERE code = ANY(%s) + """, (codes,)) + rows = cur.fetchall() + + return jsonify({ + 'success': True, + 'data': [dict(row) for row in rows] + }) + finally: + conn.close() + + +@bp.route('/db/data_status', methods=['GET']) +def db_data_status(): + """获取数据更新状态""" + conn = get_db() + if not conn: + return jsonify({'success': False, 'error': '数据库连接失败'}), 500 + + try: + from psycopg2.extras import RealDictCursor + cur = conn.cursor(cursor_factory=RealDictCursor) + + # 获取各表数据统计 + cur.execute("SELECT COUNT(*) as count, MAX(updated_at)::text as last_update FROM stock_realtime_price") + price_stats = cur.fetchone() + + cur.execute("SELECT COUNT(*) as count, MAX(updated_at)::text as last_update FROM stock_fund_flow_today") + flow_stats = cur.fetchone() + + cur.execute(""" + SELECT data_type, status, records_count, finished_at::text + FROM data_update_log + ORDER BY finished_at DESC + LIMIT 5 + """) + logs = cur.fetchall() + + return jsonify({ + 'success': True, + 'realtime_price': dict(price_stats) if price_stats else {}, + 'fund_flow_today': dict(flow_stats) if flow_stats else {}, + 'recent_logs': [dict(log) for log in logs] + }) + finally: + conn.close() + + +# ============ 原有API(兼容) ============ + + +@bp.route('/hot_stocks', methods=['GET']) +def hot_stocks(): + """人气榜 — 已删除(东方财富API不可用,无替代源)""" + return jsonify({'success': False, 'error': '人气榜功能已停用', 'data': [], 'total': 0}), 410 + + +@bp.route('/kline/', methods=['GET']) +def get_kline(stock_code): + """获取K线数据 — 使用统一算法模块 services.stock_algorithms""" + try: + period = request.args.get('period', 'daily') + days_map = { + 'weekly': 7, + 'monthly': 30, + 'quarterly': 90, + 'yearly': 365 + } + days = days_map.get(period, 30) + + # 使用统一K线获取(含5种数据源自动回退) + df = algo_get_kline_data(stock_code, days=days, use_local_db=True) + if df is None or df.empty: + return jsonify({'success': True, 'data': [], 'stock_code': stock_code, 'period': period}) + + kline_data = [] + for _, row in df.iterrows(): + d = row.get('date', '') + kline_data.append({ + 'date': d.strftime('%Y-%m-%d') if hasattr(d, 'strftime') else str(d), + 'open': float(row.get('open', 0)), + 'close': float(row.get('close', 0)), + 'high': float(row.get('high', 0)), + 'low': float(row.get('low', 0)), + 'volume': float(row.get('volume', 0)), + }) + + return jsonify({ + 'success': True, + 'data': kline_data, + 'stock_code': stock_code, + 'period': period + }) + except Exception as e: + print(f"K线接口异常({stock_code}): {e}") + return jsonify({'success': True, 'data': [], 'stock_code': stock_code, 'period': period}) + + +@bp.route('/fundflow/', methods=['GET']) +def get_fundflow(stock_code): + """获取近N天资金流向(失败时返回空数据)""" + try: + days = request.args.get('days', 3, type=int) + + try: + cached_df, stock_name, _ = load_cached_data(stock_code) + except Exception as e: + print(f"加载缓存数据失败({stock_code}): {e}") + cached_df, stock_name = None, None + + if cached_df is None or cached_df.empty: + try: + end_date = datetime.now().strftime('%Y-%m-%d') + start_date = (datetime.now() - timedelta(days=30)).strftime('%Y-%m-%d') + cached_df, stock_name, error = get_stock_fund_flow(stock_code, start_date, end_date) + except Exception as e: + print(f"获取资金流向失败({stock_code}): {e}") + return jsonify({'success': True, 'stock_code': stock_code, 'stock_name': '', 'data': []}) + + if cached_df is None or cached_df.empty: + return jsonify({'success': True, 'stock_code': stock_code, 'stock_name': stock_name or '', 'data': []}) + + cached_df = cached_df.sort_values('日期', ascending=False) + recent = cached_df.head(days) + + flow_data = [] + for _, row in recent.iterrows(): + flow_data.append({ + 'date': row['日期'].strftime('%Y-%m-%d') if hasattr(row['日期'], 'strftime') else str(row['日期']), + 'price': float(row['收盘价']) if pd.notna(row['收盘价']) else 0, + 'change': float(row['涨跌幅']) if pd.notna(row['涨跌幅']) else 0, + 'super_ratio': float(row['超大单净流入-净占比']) if pd.notna(row['超大单净流入-净占比']) else 0, + 'main_ratio': float(row['主力净流入-净占比']) if pd.notna(row['主力净流入-净占比']) else 0, + }) + + return jsonify({ + 'success': True, + 'stock_code': stock_code, + 'stock_name': stock_name or '', + 'data': flow_data + }) + except Exception as e: + print(f"资金流向接口异常({stock_code}): {e}") + return jsonify({'success': True, 'stock_code': stock_code, 'stock_name': '', 'data': []}) + + +@bp.route('/lhb', methods=['GET']) +def get_lhb(): + """龙虎榜 — 已删除(东方财富API不可用,无替代源)""" + return jsonify({'success': False, 'error': '龙虎榜功能已停用', 'data': [], 'total': 0}), 410 + + +@bp.route('/fund_flow_rank', methods=['GET']) +def get_fund_flow_rank(): + """获取资金流向排行 — 从数据库缓存获取""" + try: + limit = request.args.get('limit', 50, type=int) + + # 东方财富API已不可用,从数据库获取缓存数据 + from db import get_db as _get_db + _conn = _get_db() + if _conn: + try: + _cur = _conn.cursor() + _cur.execute(""" + SELECT code, name, main_net_inflow, main_net_inflow_pct, + price, change_pct + FROM stock_fund_flow_today + ORDER BY main_net_inflow DESC LIMIT %s + """, (limit,)) + rows = _cur.fetchall() + flow_data = [{'code': r[0], 'name': r[1], + 'main_net_inflow': float(r[2] or 0), + 'main_pct': float(r[3] or 0), + 'price': float(r[4] or 0), + 'change_pct': float(r[5] or 0)} for r in rows] + return jsonify({'success': True, 'data': flow_data, 'total': len(flow_data), + 'source': 'cache'}) + finally: + _conn.close() + + return jsonify({'success': True, 'data': [], 'total': 0}) + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@bp.route('/fundamental/', methods=['GET']) +def get_fundamental(stock_code): + """获取基本面数据(当日缓存版)+ 近三日资金流向 + 财务指标""" + try: + from db import db_get_fundamental, db_save_fundamental, get_db + from services.stock_service import get_stock_name + + # 获取近三日资金流向数据 + 技术信号 + fund_flow_3days = [] + realtime_data = None + signal_data = None + + try: + conn = get_db() + if conn: + cur = conn.cursor() + cur.execute(""" + SELECT trade_date, close_price, change_pct, + main_net_inflow_pct, super_net_inflow_pct, big_net_inflow_pct + FROM stock_fund_flow_history + WHERE code = %s + ORDER BY trade_date DESC + LIMIT 3 + """, (stock_code,)) + rows = cur.fetchall() + for row in rows: + fund_flow_3days.append({ + 'date': row[0].strftime('%m-%d') if row[0] else '', + 'close_price': float(row[1]) if row[1] else 0, + 'change_pct': float(row[2]) if row[2] else 0, + 'main_pct': float(row[3]) if row[3] else 0, + 'super_pct': float(row[4]) if row[4] else 0, + 'big_pct': float(row[5]) if row[5] else 0, + }) + + cur.execute(""" + SELECT name, price, pe, pb, change_pct, total_market_cap + FROM stock_realtime_price + WHERE code = %s + """, (stock_code,)) + rt_row = cur.fetchone() + if rt_row: + realtime_data = { + 'name': rt_row[0], + 'price': float(rt_row[1]) if rt_row[1] else None, + 'pe': float(rt_row[2]) if rt_row[2] else None, + 'pb': float(rt_row[3]) if rt_row[3] else None, + 'change_pct': float(rt_row[4]) if rt_row[4] else None, + 'total_market_cap': float(rt_row[5]) if rt_row[5] else None, + } + + from datetime import date as date_cls + # 优先今天的扫描数据,无则回退到最近可用日期 + scan_date = date_cls.today().strftime('%Y-%m-%d') + cur.execute(""" + SELECT signal_status, indicators, triggered_count + FROM stock_signal_scan + WHERE code = %s AND scan_date = %s + """, (stock_code, scan_date)) + sig_row = cur.fetchone() + if not sig_row: + cur.execute(""" + SELECT signal_status, indicators, triggered_count + FROM stock_signal_scan + WHERE code = %s AND scan_date = (SELECT MAX(scan_date) FROM stock_signal_scan) + """, (stock_code,)) + sig_row = cur.fetchone() + if sig_row: + import json as json_mod + ss = sig_row[0] if isinstance(sig_row[0], list) else (json_mod.loads(sig_row[0]) if sig_row[0] else []) + ind = sig_row[1] if isinstance(sig_row[1], dict) else (json_mod.loads(sig_row[1]) if sig_row[1] else {}) + signal_data = { + 'signal_status': ss, + 'indicators': ind, + 'triggered_count': sig_row[2] or 0, + } + + conn.close() + except Exception as e: + print(f"获取数据失败: {e}") + + # 优先从数据库获取当日缓存 + cached = db_get_fundamental(stock_code) + if cached: + # 优先使用实时价格表中的PE/PB数据 + pe_val = realtime_data['pe'] if realtime_data and realtime_data['pe'] else (float(cached['pe']) if cached['pe'] else '') + pb_val = realtime_data['pb'] if realtime_data and realtime_data['pb'] else (float(cached['pb']) if cached['pb'] else '') + price_val = realtime_data['price'] if realtime_data and realtime_data['price'] else (float(cached['latest_price']) if cached['latest_price'] else '') + change_val = realtime_data['change_pct'] if realtime_data and realtime_data['change_pct'] else (float(cached['change_pct']) if cached['change_pct'] else '') + market_cap_val = realtime_data['total_market_cap'] if realtime_data and realtime_data['total_market_cap'] else (float(cached['total_market_cap']) if cached['total_market_cap'] else '') + + return jsonify({ + 'success': True, + 'data': { + 'stock_code': cached['code'], + 'stock_name': cached['name'], + 'pe_ttm': pe_val, + 'pb': pb_val, + 'total_market_cap': market_cap_val, + 'industry': cached['industry'] or '', + 'latest_price': price_val, + 'change_pct': change_val, + 'roe': float(cached['roe']) if cached.get('roe') else '', + 'eps': float(cached['eps']) if cached.get('eps') else '', + 'bps': float(cached['bps']) if cached.get('bps') else '', + 'revenue_yoy': float(cached['revenue_yoy']) if cached.get('revenue_yoy') else '', + 'profit_yoy': float(cached['profit_yoy']) if cached.get('profit_yoy') else '', + 'gross_margin': float(cached['gross_margin']) if cached.get('gross_margin') else '', + 'net_margin': float(cached['net_margin']) if cached.get('net_margin') else '', + 'fund_flow_3days': fund_flow_3days, + 'signal_data': signal_data, + }, + 'source': 'database' + }) + + # 数据库没有基本面缓存,从实时价格表和API获取 + result = { + 'stock_code': stock_code, + 'stock_name': realtime_data['name'] if realtime_data else (get_stock_name(stock_code) or ''), + 'pe_ttm': realtime_data['pe'] if realtime_data and realtime_data['pe'] else '', + 'pb': realtime_data['pb'] if realtime_data and realtime_data['pb'] else '', + 'total_market_cap': realtime_data['total_market_cap'] if realtime_data and realtime_data['total_market_cap'] else '', + 'industry': '', + 'latest_price': realtime_data['price'] if realtime_data and realtime_data['price'] else '', + 'change_pct': realtime_data['change_pct'] if realtime_data and realtime_data['change_pct'] else '', + 'roe': '', + 'eps': '', + 'bps': '', + 'revenue_yoy': '', + 'profit_yoy': '', + 'gross_margin': '', + 'net_margin': '', + } + + # 优先使用mairuiapi获取数据 + try: + from services.mairui_api import get_realtime_price as mairui_realtime, get_financial_indicators, get_company_info + + # 获取实时价格 + rt_result = mairui_realtime(stock_code) + if rt_result['success']: + rt_data = rt_result['data'] + result['latest_price'] = rt_data.get('price', '') + result['change_pct'] = rt_data.get('change', '') + result['pe_ttm'] = rt_data.get('pe') or result['pe_ttm'] + result['pb'] = rt_data.get('pb') or result['pb'] + result['total_market_cap'] = rt_data.get('total_market_cap') or result['total_market_cap'] + + # 获取公司信息 + company_result = get_company_info(stock_code) + if company_result['success']: + company_data = company_result['data'] + result['stock_name'] = company_data.get('name') or result['stock_name'] + result['industry'] = company_data.get('industry') or result['industry'] + + # 获取财务指标 + fin_result = get_financial_indicators(stock_code) + if fin_result['success']: + fin_data = fin_result['data'] + result['eps'] = fin_data.get('eps') or '' + result['bps'] = fin_data.get('bps') or '' + result['roe'] = fin_data.get('roe') or '' + result['gross_margin'] = fin_data.get('gross_margin') or '' + result['net_margin'] = fin_data.get('net_margin') or '' + result['revenue_yoy'] = fin_data.get('revenue_yoy') or '' + result['profit_yoy'] = fin_data.get('profit_yoy') or '' + except Exception as e: + print(f"mairuiapi获取基本面失败: {e}") + + # 备用方案:先试腾讯API,再试akshare + try: + import requests as _rq + _tc = ('sh' if stock_code.startswith('6') else 'sz') + stock_code + _rr = _rq.get(f'http://qt.gtimg.cn/q={_tc}', timeout=5, + headers={'Referer': 'https://finance.qq.com'}) + if _rr.status_code == 200 and '\"' in _rr.text: + _ff = _rr.text.split('\"')[1].split('~') + if len(_ff) > 46: + result['stock_name'] = _ff[1] or result['stock_name'] + result['latest_price'] = _ff[3] + result['total_market_cap'] = f'{float(_ff[45])*100000000:.0f}' if _ff[45].strip() else '' + except Exception as e2: + print(f"腾讯财经备用方案也失败: {e2}") + + # 保存到数据库缓存 + try: + db_save_fundamental(stock_code, { + 'name': result['stock_name'], + 'pe': float(result['pe_ttm']) if result['pe_ttm'] else None, + 'pb': float(result['pb']) if result['pb'] else None, + 'total_market_cap': float(result['total_market_cap']) if result['total_market_cap'] else None, + 'industry': result['industry'], + 'latest_price': float(result['latest_price']) if result['latest_price'] else None, + 'change_pct': float(str(result['change_pct']).replace('%', '')) if result['change_pct'] else None, + 'roe': float(result['roe']) if result['roe'] else None, + 'eps': float(result['eps']) if result['eps'] else None, + 'bps': float(result['bps']) if result['bps'] else None, + 'revenue_yoy': float(result['revenue_yoy']) if result['revenue_yoy'] else None, + 'profit_yoy': float(result['profit_yoy']) if result['profit_yoy'] else None, + 'gross_margin': float(result['gross_margin']) if result['gross_margin'] else None, + 'net_margin': float(result['net_margin']) if result['net_margin'] else None, + }) + except Exception as e: + print(f"保存基本面缓存失败: {e}") + + result['fund_flow_3days'] = fund_flow_3days + result['signal_data'] = signal_data + + return jsonify({'success': True, 'data': result, 'source': 'api'}) + except Exception as e: + return jsonify({'error': str(e)}), 500 diff --git a/stock-html/routes/sim_trade.py b/stock-html/routes/sim_trade.py new file mode 100644 index 0000000..b1dbff7 --- /dev/null +++ b/stock-html/routes/sim_trade.py @@ -0,0 +1,733 @@ +""" +模拟交易 API 路由 +每个交易日10点根据推荐率最高的买入卖出方案进行自动操作 +""" +from flask import Blueprint, request, jsonify +from datetime import datetime, date, time +from db import get_db, login_required, get_current_user_id +from psycopg2.extras import RealDictCursor + +bp = Blueprint('sim_trade', __name__, url_prefix='/api/sim') + + +def init_user_config(user_id): + """初始化用户模拟交易配置""" + conn = get_db() + if not conn: + return None + try: + cur = conn.cursor(cursor_factory=RealDictCursor) + cur.execute(""" + INSERT INTO sim_config (user_id) + VALUES (%s) + ON CONFLICT (user_id) DO NOTHING + RETURNING * + """, (user_id,)) + conn.commit() + + # 获取配置 + cur.execute("SELECT * FROM sim_config WHERE user_id = %s", (user_id,)) + return cur.fetchone() + finally: + conn.close() + + +@bp.route('/config', methods=['GET']) +@login_required +def get_config(): + """获取模拟交易配置""" + user_id = get_current_user_id() + config = init_user_config(user_id) + + if config: + return jsonify({ + 'success': True, + 'config': { + 'initial_capital': float(config['initial_capital']), + 'trade_quantity': config['trade_quantity'], + 'auto_trade_enabled': config['auto_trade_enabled'], + 'auto_trade_time': str(config['auto_trade_time']) if config['auto_trade_time'] else '10:00:00' + } + }) + return jsonify({'success': False, 'error': '获取配置失败'}), 500 + + +@bp.route('/config', methods=['POST']) +@login_required +def update_config(): + """更新模拟交易配置""" + user_id = get_current_user_id() + data = request.get_json() + + conn = get_db() + if not conn: + return jsonify({'success': False, 'error': '数据库连接失败'}), 500 + + try: + cur = conn.cursor() + cur.execute(""" + UPDATE sim_config SET + trade_quantity = COALESCE(%s, trade_quantity), + auto_trade_enabled = COALESCE(%s, auto_trade_enabled), + updated_at = NOW() + WHERE user_id = %s + """, ( + data.get('trade_quantity'), + data.get('auto_trade_enabled'), + user_id + )) + conn.commit() + return jsonify({'success': True}) + except Exception as e: + conn.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + finally: + conn.close() + + +@bp.route('/trades', methods=['GET']) +@login_required +def get_trades(): + """获取模拟交易记录""" + user_id = get_current_user_id() + limit = request.args.get('limit', 100, type=int) + + conn = get_db() + if not conn: + return jsonify({'success': False, 'error': '数据库连接失败'}), 500 + + try: + cur = conn.cursor(cursor_factory=RealDictCursor) + cur.execute(""" + SELECT id, stock_code, stock_name, trade_type, + price::float, quantity, trade_date::text, + trade_time::text, recommend_rate::float, signal_reason, + COALESCE(commission, 0)::float as commission, + COALESCE(stamp_tax, 0)::float as stamp_tax, + COALESCE(total_fee, 0)::float as total_fee, + created_at::text + FROM sim_trades + WHERE user_id = %s + ORDER BY trade_date DESC, trade_time DESC + LIMIT %s + """, (user_id, limit)) + trades = cur.fetchall() + return jsonify({'success': True, 'trades': trades}) + finally: + conn.close() + + +@bp.route('/positions', methods=['GET']) +@login_required +def get_positions(): + """获取模拟持仓""" + user_id = get_current_user_id() + + conn = get_db() + if not conn: + return jsonify({'success': False, 'error': '数据库连接失败'}), 500 + + try: + cur = conn.cursor(cursor_factory=RealDictCursor) + cur.execute(""" + SELECT stock_code, stock_name, quantity, + avg_cost::float, total_cost::float, + current_price::float, updated_at::text + FROM sim_positions + WHERE user_id = %s AND quantity > 0 + ORDER BY total_cost DESC + """, (user_id,)) + positions = cur.fetchall() + return jsonify({'success': True, 'positions': positions}) + finally: + conn.close() + + +@bp.route('/stats', methods=['GET']) +@login_required +def get_stats(): + """获取模拟交易统计""" + user_id = get_current_user_id() + + conn = get_db() + if not conn: + return jsonify({'success': False, 'error': '数据库连接失败'}), 500 + + try: + cur = conn.cursor(cursor_factory=RealDictCursor) + + # 获取配置 — 优先使用 sim_algo_config.total_capital(智能交易配置) + initial_capital = 200000 # 默认值 + cur.execute("SELECT total_capital::float FROM sim_algo_config WHERE user_id = %s AND is_active = TRUE", (user_id,)) + algo_cfg = cur.fetchone() + if algo_cfg and algo_cfg['total_capital']: + initial_capital = algo_cfg['total_capital'] + else: + cur.execute("SELECT initial_capital::float FROM sim_config WHERE user_id = %s", (user_id,)) + config = cur.fetchone() + if config and config['initial_capital']: + initial_capital = config['initial_capital'] + + # 获取持仓统计 + cur.execute(""" + SELECT + COALESCE(SUM(quantity * current_price), 0)::float as total_market_value, + COALESCE(SUM(total_cost), 0)::float as total_cost, + COALESCE(SUM(quantity * current_price - total_cost), 0)::float as unrealized_profit + FROM sim_positions + WHERE user_id = %s AND quantity > 0 + """, (user_id,)) + position_stats = cur.fetchone() + + # 获取已实现盈亏(卖出交易) + cur.execute(""" + SELECT COALESCE(SUM( + CASE WHEN trade_type = 'sell' THEN price * quantity ELSE 0 END + ), 0)::float as total_sell, + COUNT(DISTINCT trade_date) as trade_days, + COUNT(*) as total_trades, + COALESCE(SUM(COALESCE(commission, 0)), 0)::float as total_commission, + COALESCE(SUM(COALESCE(stamp_tax, 0)), 0)::float as total_stamp_tax, + COALESCE(SUM(COALESCE(total_fee, 0)), 0)::float as total_fees + FROM sim_trades + WHERE user_id = %s +""", (user_id,)) + trade_stats = cur.fetchone() + + # 计算已实现盈亏(需要更复杂的计算,这里简化处理) + # 从每日统计表获取最新的已实现盈亏 + cur.execute(""" + SELECT realized_profit::float + FROM sim_daily_stats + WHERE user_id = %s + ORDER BY stat_date DESC + LIMIT 1 + """, (user_id,)) + daily_stat = cur.fetchone() + realized_profit = daily_stat['realized_profit'] if daily_stat else 0 + + # 获取历史统计(用于图表) + cur.execute(""" + SELECT stat_date::text, total_profit::float, + total_market_value::float, realized_profit::float + FROM sim_daily_stats + WHERE user_id = %s + ORDER BY stat_date DESC + LIMIT 30 + """, (user_id,)) + history = cur.fetchall() + + total_market_value = position_stats['total_market_value'] or 0 + total_cost = position_stats['total_cost'] or 0 + unrealized_profit = position_stats['unrealized_profit'] or 0 + total_fees = trade_stats['total_fees'] or 0 + total_commission = trade_stats['total_commission'] or 0 + total_stamp_tax = trade_stats['total_stamp_tax'] or 0 + + # 毛利润(不含手续费的计算) + gross_profit = unrealized_profit + realized_profit + total_fees # 加回手续费 = 毛收益 + # 净利润(含手续费) + net_profit = unrealized_profit + realized_profit # realized_profit 已扣除卖出手续费 + total_profit = net_profit + + # 计算收益率 + gross_rate = (gross_profit / initial_capital * 100) if initial_capital > 0 else 0 + net_rate = (net_profit / initial_capital * 100) if initial_capital > 0 else 0 + profit_rate = net_rate # 默认显示净收益率 + + return jsonify({ + 'success': True, + 'stats': { + 'initial_capital': initial_capital, + 'total_market_value': total_market_value, + 'total_cost': total_cost, + 'cash': initial_capital - total_cost + realized_profit, + 'unrealized_profit': unrealized_profit, + 'realized_profit': realized_profit, + 'total_profit': total_profit, + 'profit_rate': profit_rate, + 'trade_days': trade_stats['trade_days'] or 0, + 'total_trades': trade_stats['total_trades'] or 0, + # 手续费明细 + 'total_fees': total_fees, + 'total_commission': total_commission, + 'total_stamp_tax': total_stamp_tax, + # 对比数据: 毛收益 vs 净收益 + 'gross_profit': gross_profit, + 'gross_rate': gross_rate, + 'net_profit': net_profit, + 'net_rate': net_rate, + }, + 'history': list(reversed(history)) if history else [] + }) + finally: + conn.close() + + +@bp.route('/execute', methods=['POST']) +@login_required +def execute_trade(): + """执行模拟交易(手动或自动)""" + user_id = get_current_user_id() + data = request.get_json() + + stock_code = data.get('stock_code') + stock_name = data.get('stock_name', '') + trade_type = data.get('trade_type') # 'buy' or 'sell' + price = data.get('price') + quantity = data.get('quantity', 1000) + recommend_rate = data.get('recommend_rate') + signal_reason = data.get('signal_reason', '') + + if not stock_code or not trade_type or not price: + return jsonify({'success': False, 'error': '参数不完整'}), 400 + + conn = get_db() + if not conn: + return jsonify({'success': False, 'error': '数据库连接失败'}), 500 + + try: + cur = conn.cursor(cursor_factory=RealDictCursor) + today = date.today() + now = datetime.now().time() + + # 1. 记录交易 + cur.execute(""" + INSERT INTO sim_trades + (user_id, stock_code, stock_name, trade_type, price, quantity, + trade_date, trade_time, recommend_rate, signal_reason) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id + """, (user_id, stock_code, stock_name, trade_type, price, quantity, + today, now, recommend_rate, signal_reason)) + trade_id = cur.fetchone()['id'] + + # 2. 更新持仓 + if trade_type == 'buy': + # 买入:增加持仓 + cur.execute(""" + INSERT INTO sim_positions + (user_id, stock_code, stock_name, quantity, avg_cost, total_cost, current_price) + VALUES (%s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (user_id, stock_code) DO UPDATE SET + quantity = sim_positions.quantity + EXCLUDED.quantity, + total_cost = sim_positions.total_cost + EXCLUDED.total_cost, + avg_cost = (sim_positions.total_cost + EXCLUDED.total_cost) / + (sim_positions.quantity + EXCLUDED.quantity), + current_price = EXCLUDED.current_price, + stock_name = COALESCE(EXCLUDED.stock_name, sim_positions.stock_name), + updated_at = NOW() + """, (user_id, stock_code, stock_name, quantity, price, + price * quantity, price)) + else: + # 卖出:减少持仓,计算已实现盈亏 + cur.execute(""" + SELECT quantity, avg_cost::float, total_cost::float + FROM sim_positions + WHERE user_id = %s AND stock_code = %s + """, (user_id, stock_code)) + position = cur.fetchone() + + if not position or position['quantity'] < quantity: + conn.rollback() + return jsonify({'success': False, 'error': '持仓不足'}), 400 + + # 计算已实现盈亏 + avg_cost = position['avg_cost'] + realized_pnl = (price - avg_cost) * quantity + + # 更新持仓 + new_quantity = position['quantity'] - quantity + new_total_cost = position['total_cost'] - (avg_cost * quantity) + + if new_quantity > 0: + cur.execute(""" + UPDATE sim_positions SET + quantity = %s, + total_cost = %s, + current_price = %s, + updated_at = NOW() + WHERE user_id = %s AND stock_code = %s + """, (new_quantity, new_total_cost, price, user_id, stock_code)) + else: + # 清仓 + cur.execute(""" + UPDATE sim_positions SET + quantity = 0, + total_cost = 0, + current_price = %s, + updated_at = NOW() + WHERE user_id = %s AND stock_code = %s + """, (price, user_id, stock_code)) + + # 更新每日统计中的已实现盈亏 + cur.execute(""" + INSERT INTO sim_daily_stats (user_id, stat_date, realized_profit, trade_count) + VALUES (%s, %s, %s, 1) + ON CONFLICT (user_id, stat_date) DO UPDATE SET + realized_profit = sim_daily_stats.realized_profit + %s, + trade_count = sim_daily_stats.trade_count + 1 + """, (user_id, today, realized_pnl, realized_pnl)) + + conn.commit() + return jsonify({ + 'success': True, + 'trade_id': trade_id, + 'message': f'{"买入" if trade_type == "buy" else "卖出"} {stock_name or stock_code} {quantity}股 成功' + }) + except Exception as e: + conn.rollback() + import traceback + traceback.print_exc() + return jsonify({'success': False, 'error': str(e)}), 500 + finally: + conn.close() + + +@bp.route('/auto_execute', methods=['POST']) +@login_required +def auto_execute(): + """根据推荐率自动执行交易(每日10点调用)""" + user_id = get_current_user_id() + data = request.get_json() + + # 获取推荐的买入和卖出信号 + buy_signals = data.get('buy_signals', []) # 按推荐率排序的买入信号 + sell_signals = data.get('sell_signals', []) # 按推荐率排序的卖出信号 + + conn = get_db() + if not conn: + return jsonify({'success': False, 'error': '数据库连接失败'}), 500 + + try: + cur = conn.cursor(cursor_factory=RealDictCursor) + + # 获取配置 + cur.execute("SELECT trade_quantity FROM sim_config WHERE user_id = %s", (user_id,)) + config = cur.fetchone() + quantity = config['trade_quantity'] if config else 1000 + + today = date.today() + now = datetime.now().time() + results = [] + + # 1. 先处理卖出信号(释放资金) + for signal in sell_signals: + stock_code = signal.get('code') + + # 检查是否有持仓 + cur.execute(""" + SELECT quantity FROM sim_positions + WHERE user_id = %s AND stock_code = %s AND quantity > 0 + """, (user_id, stock_code)) + position = cur.fetchone() + + if position and position['quantity'] >= quantity: + # 执行卖出 + price = signal.get('price', 0) + if price > 0: + # 获取持仓成本 + cur.execute(""" + SELECT avg_cost::float FROM sim_positions + WHERE user_id = %s AND stock_code = %s + """, (user_id, stock_code)) + pos_info = cur.fetchone() + avg_cost = pos_info['avg_cost'] if pos_info else price + realized_pnl = (price - avg_cost) * quantity + + # 记录交易 + cur.execute(""" + INSERT INTO sim_trades + (user_id, stock_code, stock_name, trade_type, price, quantity, + trade_date, trade_time, recommend_rate, signal_reason) + VALUES (%s, %s, %s, 'sell', %s, %s, %s, %s, %s, %s) + """, (user_id, stock_code, signal.get('name', ''), price, quantity, + today, now, signal.get('recommendRate'), signal.get('reason', ''))) + + # 更新持仓 + cur.execute(""" + UPDATE sim_positions SET + quantity = quantity - %s, + total_cost = total_cost - (avg_cost * %s), + current_price = %s, + updated_at = NOW() + WHERE user_id = %s AND stock_code = %s + """, (quantity, quantity, price, user_id, stock_code)) + + # 更新已实现盈亏 + cur.execute(""" + INSERT INTO sim_daily_stats (user_id, stat_date, realized_profit, trade_count) + VALUES (%s, %s, %s, 1) + ON CONFLICT (user_id, stat_date) DO UPDATE SET + realized_profit = sim_daily_stats.realized_profit + %s, + trade_count = sim_daily_stats.trade_count + 1 + """, (user_id, today, realized_pnl, realized_pnl)) + + results.append({ + 'type': 'sell', + 'code': stock_code, + 'name': signal.get('name', ''), + 'price': price, + 'quantity': quantity, + 'pnl': realized_pnl + }) + + # 2. 处理买入信号(取推荐率最高的) + for signal in buy_signals[:3]: # 最多买入3只 + stock_code = signal.get('code') + price = signal.get('price', 0) + + if price > 0: + # 检查今日是否已买入该股票 + cur.execute(""" + SELECT COUNT(*) as cnt FROM sim_trades + WHERE user_id = %s AND stock_code = %s + AND trade_date = %s AND trade_type = 'buy' + """, (user_id, stock_code, today)) + if cur.fetchone()['cnt'] > 0: + continue # 今日已买入,跳过 + + # 记录交易 + cur.execute(""" + INSERT INTO sim_trades + (user_id, stock_code, stock_name, trade_type, price, quantity, + trade_date, trade_time, recommend_rate, signal_reason) + VALUES (%s, %s, %s, 'buy', %s, %s, %s, %s, %s, %s) + """, (user_id, stock_code, signal.get('name', ''), price, quantity, + today, now, signal.get('recommendRate'), signal.get('reason', ''))) + + # 更新持仓 + cur.execute(""" + INSERT INTO sim_positions + (user_id, stock_code, stock_name, quantity, avg_cost, total_cost, current_price) + VALUES (%s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (user_id, stock_code) DO UPDATE SET + quantity = sim_positions.quantity + EXCLUDED.quantity, + total_cost = sim_positions.total_cost + EXCLUDED.total_cost, + avg_cost = (sim_positions.total_cost + EXCLUDED.total_cost) / + (sim_positions.quantity + EXCLUDED.quantity), + current_price = EXCLUDED.current_price, + stock_name = COALESCE(EXCLUDED.stock_name, sim_positions.stock_name), + updated_at = NOW() + """, (user_id, stock_code, signal.get('name', ''), quantity, price, + price * quantity, price)) + + results.append({ + 'type': 'buy', + 'code': stock_code, + 'name': signal.get('name', ''), + 'price': price, + 'quantity': quantity + }) + + conn.commit() + return jsonify({ + 'success': True, + 'results': results, + 'message': f'自动交易完成: 买入{len([r for r in results if r["type"]=="buy"])}笔, 卖出{len([r for r in results if r["type"]=="sell"])}笔' + }) + except Exception as e: + conn.rollback() + import traceback + traceback.print_exc() + return jsonify({'success': False, 'error': str(e)}), 500 + finally: + conn.close() + + +@bp.route('/update_prices', methods=['POST']) +@login_required +def update_prices(): + """更新持仓的当前价格""" + user_id = get_current_user_id() + data = request.get_json() + prices = data.get('prices', {}) # {stock_code: price} + + if not prices: + return jsonify({'success': True, 'message': '无需更新'}) + + conn = get_db() + if not conn: + return jsonify({'success': False, 'error': '数据库连接失败'}), 500 + + try: + cur = conn.cursor() + for code, price in prices.items(): + cur.execute(""" + UPDATE sim_positions SET + current_price = %s, + updated_at = NOW() + WHERE user_id = %s AND stock_code = %s + """, (price, user_id, code)) + + # 更新每日统计 + today = date.today() + cur.execute(""" + SELECT + COALESCE(SUM(quantity * current_price), 0) as market_value, + COALESCE(SUM(total_cost), 0) as total_cost, + COALESCE(SUM(quantity * current_price - total_cost), 0) as unrealized + FROM sim_positions + WHERE user_id = %s AND quantity > 0 + """, (user_id,)) + stats = cur.fetchone() + + cur.execute(""" + INSERT INTO sim_daily_stats + (user_id, stat_date, total_market_value, total_cost, unrealized_profit) + VALUES (%s, %s, %s, %s, %s) + ON CONFLICT (user_id, stat_date) DO UPDATE SET + total_market_value = EXCLUDED.total_market_value, + total_cost = EXCLUDED.total_cost, + unrealized_profit = EXCLUDED.unrealized_profit + """, (user_id, today, stats[0], stats[1], stats[2])) + + conn.commit() + return jsonify({'success': True}) + except Exception as e: + conn.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + finally: + conn.close() + + +@bp.route('/reset', methods=['POST']) +@login_required +def reset_simulation(): + """重置模拟交易(清空所有数据)""" + user_id = get_current_user_id() + + conn = get_db() + if not conn: + return jsonify({'success': False, 'error': '数据库连接失败'}), 500 + + try: + cur = conn.cursor() + cur.execute("DELETE FROM sim_trades WHERE user_id = %s", (user_id,)) + cur.execute("DELETE FROM sim_positions WHERE user_id = %s", (user_id,)) + cur.execute("DELETE FROM sim_daily_stats WHERE user_id = %s", (user_id,)) + conn.commit() + return jsonify({'success': True, 'message': '模拟交易已重置'}) + except Exception as e: + conn.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + finally: + conn.close() + + +@bp.route('/trigger_trade', methods=['POST']) +@login_required +def trigger_trade(): + """手动触发交易 — 优先使用智能引擎,降级到旧引擎""" + user_id = get_current_user_id() + + try: + # 优先使用智能引擎 + try: + from services.smart_trade_engine import execute_smart_trade + conn = get_db() + if conn: + result = execute_smart_trade(conn, user_id, scan_date=None) + conn.close() + if result.get('success'): + results = result.get('results', []) + buy_count = len([r for r in results if r['type'] == 'buy']) + sell_count = len([r for r in results if r['type'] in ('sell', 'partial_sell')]) + return jsonify({ + 'success': True, + 'results': results, + 'algo': result.get('algo', 'unknown'), + 'message': f"智能引擎[{result.get('algo','?')}]: 买入{buy_count}笔, 卖出{sell_count}笔" + }) + except Exception as e: + print(f"[trigger_trade] 智能引擎异常,降级: {e}") + + # 降级: 使用旧引擎 + from services.scheduler import execute_auto_trade_for_user + + conn = get_db() + if conn: + cur = conn.cursor(cursor_factory=RealDictCursor) + cur.execute("SELECT trade_quantity FROM sim_config WHERE user_id = %s", (user_id,)) + config = cur.fetchone() + trade_quantity = config['trade_quantity'] if config else 1000 + conn.close() + else: + trade_quantity = 1000 + + result = execute_auto_trade_for_user(user_id, trade_quantity) + + if 'error' in result: + return jsonify({'success': False, 'error': result['error']}), 500 + + return jsonify({ + 'success': True, + 'results': result.get('results', []), + 'message': f"自动交易完成: 买入{len([r for r in result.get('results', []) if r['type']=='buy'])}笔, 卖出{len([r for r in result.get('results', []) if r['type']=='sell'])}笔" + }) + except Exception as e: + import traceback + traceback.print_exc() + return jsonify({'success': False, 'error': str(e)}), 500 + + +@bp.route('/trigger_update', methods=['POST']) +@login_required +def trigger_update(): + """手动触发价格更新(用于测试)""" + user_id = get_current_user_id() + + try: + from services.scheduler import update_positions_price_for_user + update_positions_price_for_user(user_id) + return jsonify({'success': True, 'message': '持仓价格已更新'}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@bp.route('/today_trades', methods=['GET']) +@login_required +def get_today_trades(): + """获取今日交易记录""" + user_id = get_current_user_id() + + conn = get_db() + if not conn: + return jsonify({'success': False, 'error': '数据库连接失败'}), 500 + + try: + cur = conn.cursor(cursor_factory=RealDictCursor) + today = date.today() + + cur.execute(""" + SELECT id, stock_code, stock_name, trade_type, + price::float, quantity, trade_date::text, + trade_time::text, recommend_rate::float, signal_reason, + COALESCE(commission, 0)::float as commission, + COALESCE(stamp_tax, 0)::float as stamp_tax, + COALESCE(total_fee, 0)::float as total_fee, + created_at::text + FROM sim_trades + WHERE user_id = %s AND trade_date = %s + ORDER BY trade_time DESC + """, (user_id, today)) + trades = cur.fetchall() + + # 计算今日盈亏 + cur.execute(""" + SELECT realized_profit::float, trade_count + FROM sim_daily_stats + WHERE user_id = %s AND stat_date = %s + """, (user_id, today)) + stats = cur.fetchone() + + return jsonify({ + 'success': True, + 'trades': trades, + 'today_stats': { + 'realized_profit': stats['realized_profit'] if stats else 0, + 'trade_count': stats['trade_count'] if stats else 0 + } + }) + finally: + conn.close() diff --git a/stock-html/routes/smart_trade.py b/stock-html/routes/smart_trade.py new file mode 100644 index 0000000..240a758 --- /dev/null +++ b/stock-html/routes/smart_trade.py @@ -0,0 +1,287 @@ +""" +智能交易引擎 API 路由 +提供算法配置管理、引擎状态查看、手动触发等功能 +""" +from flask import Blueprint, request, jsonify +from datetime import date +from db import get_db, login_required, get_current_user_id +from psycopg2.extras import RealDictCursor + +bp = Blueprint('smart_trade', __name__, url_prefix='/api/smart') + + +# ═══════════════════════════════════════════════════════ +# 1. 算法模板 +# ═══════════════════════════════════════════════════════ + +@bp.route('/templates', methods=['GET']) +@login_required +def get_algo_templates(): + """获取所有预置算法模板""" + conn = get_db() + if not conn: + return jsonify({'success': False, 'error': '数据库连接失败'}), 500 + + try: + cur = conn.cursor(cursor_factory=RealDictCursor) + cur.execute(""" + SELECT id, name, display_name, description, risk_level, + take_profit_pct::float, stop_loss_pct::float, + ignore_sell_signal, sell_confirm_days, + max_hold_days, no_timeout_if_rising, + position_pct::float, signal_weight, + partial_exit_pct, momentum_trail_gap::float, + breakeven_at::float, momentum_tp, momentum_days, + buy_time, sell_time, + backtest_annual_return::float, backtest_max_drawdown::float, + backtest_win_rate::float, backtest_calmar::float + FROM algo_templates + ORDER BY backtest_calmar DESC NULLS LAST + """) + templates = cur.fetchall() + return jsonify({'success': True, 'templates': templates}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + finally: + conn.close() + + +# ═══════════════════════════════════════════════════════ +# 2. 用户算法配置 (CRUD) +# ═══════════════════════════════════════════════════════ + +@bp.route('/config', methods=['GET']) +@login_required +def get_algo_config(): + """获取用户当前的算法配置""" + user_id = get_current_user_id() + conn = get_db() + if not conn: + return jsonify({'success': False, 'error': '数据库连接失败'}), 500 + + try: + from services.smart_trade_engine import get_user_algo_config, DEFAULT_CONFIG + config = get_user_algo_config(conn, user_id) + + # 判断是否是默认配置(没有存入数据库) + cur = conn.cursor(cursor_factory=RealDictCursor) + cur.execute("SELECT COUNT(*) as cnt FROM sim_algo_config WHERE user_id = %s", (user_id,)) + has_config = cur.fetchone()['cnt'] > 0 + + return jsonify({ + 'success': True, + 'config': config, + 'is_default': not has_config, + }) + except Exception as e: + import traceback + traceback.print_exc() + return jsonify({'success': False, 'error': str(e)}), 500 + finally: + conn.close() + + +@bp.route('/config', methods=['POST']) +@login_required +def save_algo_config(): + """保存/更新用户的算法配置""" + user_id = get_current_user_id() + data = request.get_json() + if not data: + return jsonify({'success': False, 'error': '无效参数'}), 400 + + conn = get_db() + if not conn: + return jsonify({'success': False, 'error': '数据库连接失败'}), 500 + + try: + from services.smart_trade_engine import save_user_algo_config, DEFAULT_CONFIG + + # 合并默认值 + config = dict(DEFAULT_CONFIG) + for key in config: + if key in data: + config[key] = data[key] + + save_user_algo_config(conn, user_id, config) + return jsonify({'success': True, 'message': '算法配置已保存'}) + except Exception as e: + conn.rollback() + import traceback + traceback.print_exc() + return jsonify({'success': False, 'error': str(e)}), 500 + finally: + conn.close() + + +@bp.route('/apply_template', methods=['POST']) +@login_required +def apply_template(): + """从模板应用算法配置""" + user_id = get_current_user_id() + data = request.get_json() + template_name = data.get('template_name') + if not template_name: + return jsonify({'success': False, 'error': '缺少template_name'}), 400 + + conn = get_db() + if not conn: + return jsonify({'success': False, 'error': '数据库连接失败'}), 500 + + try: + from services.smart_trade_engine import apply_template as do_apply + ok = do_apply(conn, user_id, template_name) + if ok: + return jsonify({'success': True, 'message': f'已应用模板: {template_name}'}) + else: + return jsonify({'success': False, 'error': f'模板不存在: {template_name}'}), 404 + except Exception as e: + conn.rollback() + return jsonify({'success': False, 'error': str(e)}), 500 + finally: + conn.close() + + +# ═══════════════════════════════════════════════════════ +# 3. 引擎状态 +# ═══════════════════════════════════════════════════════ + +@bp.route('/status', methods=['GET']) +@login_required +def get_status(): + """获取智能交易引擎的当前状态(含持仓详情+活跃规则+信号日志)""" + user_id = get_current_user_id() + conn = get_db() + if not conn: + return jsonify({'success': False, 'error': '数据库连接失败'}), 500 + + try: + from services.smart_trade_engine import get_engine_status + status = get_engine_status(conn, user_id) + return jsonify({'success': True, **status}) + except Exception as e: + import traceback + traceback.print_exc() + return jsonify({'success': False, 'error': str(e)}), 500 + finally: + conn.close() + + +# ═══════════════════════════════════════════════════════ +# 4. 手动触发 +# ═══════════════════════════════════════════════════════ + +@bp.route('/trigger', methods=['POST']) +@login_required +def trigger_smart_trade(): + """手动触发智能交易引擎执行""" + user_id = get_current_user_id() + + conn = get_db() + if not conn: + return jsonify({'success': False, 'error': '数据库连接失败'}), 500 + + try: + from services.smart_trade_engine import execute_smart_trade + result = execute_smart_trade(conn, user_id, scan_date=None) + + if result.get('error') and not result.get('success'): + return jsonify({'success': False, 'error': result['error']}), 500 + + return jsonify({ + 'success': True, + 'results': result.get('results', []), + 'signals': result.get('signals', 0), + 'algo': result.get('algo', 'unknown'), + 'total_fees': result.get('total_fees', 0), + 'detail_reasons': result.get('detail_reasons', []), + 'skipped_limit': result.get('skipped_limit', []), + 'skipped_t1': result.get('skipped_t1', []), + 'available_cash': result.get('available_cash', 0), + 'message': f"智能引擎执行完成: {result.get('signals', 0)}笔信号" + }) + except Exception as e: + import traceback + traceback.print_exc() + return jsonify({'success': False, 'error': str(e)}), 500 + finally: + conn.close() + + +# ═══════════════════════════════════════════════════════ +# 5. 信号日志 +# ═══════════════════════════════════════════════════════ + +@bp.route('/signals', methods=['GET']) +@login_required +def get_signals(): + """获取交易信号日志""" + user_id = get_current_user_id() + limit = request.args.get('limit', 50, type=int) + days = request.args.get('days', 7, type=int) + + conn = get_db() + if not conn: + return jsonify({'success': False, 'error': '数据库连接失败'}), 500 + + try: + cur = conn.cursor(cursor_factory=RealDictCursor) + cur.execute(""" + SELECT id, signal_date::text, signal_time::text, + stock_code, stock_name, action, reason, algo_rule, + signal_price::float, buy_price::float, profit_pct::float, + executed, execute_price::float, execute_shares, + created_at::text + FROM sim_trade_signals + WHERE user_id = %s AND signal_date >= CURRENT_DATE - %s + ORDER BY signal_date DESC, id DESC + LIMIT %s + """, (user_id, days, limit)) + signals = cur.fetchall() + return jsonify({'success': True, 'signals': signals}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + finally: + conn.close() + + +# ═══════════════════════════════════════════════════════ +# 6. 持仓元数据(前端持仓详情扩展) +# ═══════════════════════════════════════════════════════ + +@bp.route('/position_meta', methods=['GET']) +@login_required +def get_position_meta(): + """获取持仓的算法元数据(止盈止损状态等)""" + user_id = get_current_user_id() + conn = get_db() + if not conn: + return jsonify({'success': False, 'error': '数据库连接失败'}), 500 + + try: + from services.smart_trade_engine import get_all_position_meta + positions = get_all_position_meta(conn, user_id) + result = [] + for p in positions: + result.append({ + 'stock_code': p['stock_code'], + 'buy_date': str(p.get('buy_date', '')), + 'buy_price': float(p.get('buy_price', 0)), + 'days_held': p.get('days_held', 0), + 'max_price': float(p.get('max_price_since_buy', 0) or 0), + 'consecutive_up_days': p.get('consecutive_up_days', 0), + 'consecutive_sell_signals': p.get('consecutive_sell_signals', 0), + 'partial_exit_done': p.get('partial_exit_done', False), + 'breakeven_active': p.get('breakeven_active', False), + 'momentum_trailing_active': p.get('momentum_trailing_active', False), + 'momentum_high_price': float(p.get('momentum_high_price', 0) or 0), + 'current_shares': p.get('current_shares', 0), + 'original_shares': p.get('original_shares', 0), + }) + return jsonify({'success': True, 'positions': result}) + except Exception as e: + import traceback + traceback.print_exc() + return jsonify({'success': False, 'error': str(e)}), 500 + finally: + conn.close() diff --git a/stock-html/routes/trades.py b/stock-html/routes/trades.py new file mode 100644 index 0000000..b32aca5 --- /dev/null +++ b/stock-html/routes/trades.py @@ -0,0 +1,313 @@ +""" +交易记录 API 路由(纯数据库版) +""" +from flask import Blueprint, request, jsonify, session +from db import ( + login_required, get_current_user_id, + db_get_trades, db_get_trade, db_add_trade, db_update_trade, db_delete_trade, + db_get_available_cash, db_update_available_cash +) + +bp = Blueprint('trades', __name__, url_prefix='/api') + + +@bp.route('/trades', methods=['GET']) +@login_required +def get_trades(): + """获取交易记录""" + user_id = get_current_user_id() + trades = db_get_trades(user_id) + return jsonify({'success': True, 'trades': trades}) + + +@bp.route('/trades', methods=['POST']) +@login_required +def add_trade(): + """添加交易记录""" + try: + user_id = get_current_user_id() + data = request.get_json() + + # 处理数值(空字符串转为None) + def parse_float(val): + if val is None or val == '': + return None + try: + return round(float(val), 4) + except: + return None + + def parse_int(val): + if val is None or val == '': + return None + try: + return int(val) + except: + return None + + data['price'] = parse_float(data.get('price')) + data['quantity'] = parse_int(data.get('quantity')) + data['profit_amount'] = parse_float(data.get('profit_amount')) + data['stop_loss_price'] = parse_float(data.get('stop_loss_price')) + + trade, error = db_add_trade(user_id, data) + if error: + return jsonify({'success': False, 'error': error}), 400 + + # 根据交易类型自动更新可用资金 + trade_type = (data.get('trade_type') or '').lower() + price = data.get('price') + quantity = data.get('quantity') + if trade_type in ('buy', 'sell') and price is not None and quantity is not None: + amount = round(float(price) * int(quantity), 2) + current = db_get_available_cash(user_id) + if trade_type == 'buy': + new_cash = round(current - amount, 2) + else: + new_cash = round(current + amount, 2) + if new_cash < 0: + new_cash = 0 + ok, _ = db_update_available_cash(user_id, new_cash) + if ok: + return jsonify({'success': True, 'trade': trade, 'available_cash': new_cash}) + + return jsonify({'success': True, 'trade': trade}) + except Exception as e: + return jsonify({'error': str(e)}), 400 + + +@bp.route('/trades/', methods=['PUT']) +@login_required +def update_trade(trade_id): + """更新交易记录""" + try: + user_id = get_current_user_id() + data = request.get_json() + + # 处理数值(空字符串转为None) + def parse_float(val): + if val is None or val == '': + return None + try: + return round(float(val), 4) + except: + return None + + def parse_int(val): + if val is None or val == '': + return None + try: + return int(val) + except: + return None + + if 'price' in data: + data['price'] = parse_float(data.get('price')) + if 'quantity' in data: + data['quantity'] = parse_int(data.get('quantity')) + if 'profit_amount' in data: + data['profit_amount'] = parse_float(data.get('profit_amount')) + if 'stop_loss_price' in data: + data['stop_loss_price'] = parse_float(data.get('stop_loss_price')) + + old_trade = db_get_trade(user_id, trade_id) + if not old_trade: + return jsonify({'error': '交易记录不存在'}), 404 + + trade, error = db_update_trade(user_id, trade_id, data) + if error: + return jsonify({'success': False, 'error': error}), 400 + if not trade: + return jsonify({'error': '交易记录不存在'}), 404 + + # 根据修改同步调整可用资金:先回滚旧交易,再应用新交易 + def trade_amount(t): + p, q = (t.get('price') or 0), (t.get('quantity') or 0) + return round(float(p) * int(q), 2) if p and q else 0 + old_amt = trade_amount(old_trade) + new_amt = trade_amount(trade) + old_type = (old_trade.get('trade_type') or '').lower() + new_type = (trade.get('trade_type') or '').lower() + delta = 0 + if old_type == 'buy': + delta += old_amt + elif old_type == 'sell': + delta -= old_amt + if new_type == 'buy': + delta -= new_amt + elif new_type == 'sell': + delta += new_amt + if delta != 0: + current = db_get_available_cash(user_id) + new_cash = max(0, round(current + delta, 2)) + ok, _ = db_update_available_cash(user_id, new_cash) + if ok: + return jsonify({'success': True, 'trade': trade, 'available_cash': new_cash}) + + return jsonify({'success': True, 'trade': trade}) + except Exception as e: + return jsonify({'error': str(e)}), 400 + + +@bp.route('/trades/', methods=['DELETE']) +@login_required +def delete_trade(trade_id): + """删除交易记录""" + user_id = get_current_user_id() + old_trade = db_get_trade(user_id, trade_id) + if not old_trade: + return jsonify({'success': False, 'error': '交易记录不存在'}), 404 + success = db_delete_trade(user_id, trade_id) + if not success: + return jsonify({'success': False}), 400 + # 回滚该交易对可用资金的影响 + t_type = (old_trade.get('trade_type') or '').lower() + amount = round(float(old_trade.get('price') or 0) * int(old_trade.get('quantity') or 0), 2) + delta = amount if t_type == 'buy' else -amount + if delta != 0: + current = db_get_available_cash(user_id) + new_cash = max(0, round(current + delta, 2)) + db_update_available_cash(user_id, new_cash) + return jsonify({'success': True, 'available_cash': new_cash}) + return jsonify({'success': True}) + + +@bp.route('/available_cash', methods=['GET']) +@login_required +def get_available_cash(): + """获取可用资金""" + user_id = get_current_user_id() + cash = db_get_available_cash(user_id) + return jsonify({'success': True, 'available_cash': cash}) + + +@bp.route('/available_cash', methods=['PUT']) +@login_required +def update_available_cash(): + """更新可用资金""" + try: + user_id = get_current_user_id() + data = request.get_json() + amount = data.get('amount') + + if amount is None: + return jsonify({'success': False, 'error': '金额不能为空'}), 400 + + try: + amount = round(float(amount), 2) + except: + return jsonify({'success': False, 'error': '金额格式错误'}), 400 + + success, error = db_update_available_cash(user_id, amount) + if error: + return jsonify({'success': False, 'error': error}), 400 + + return jsonify({'success': True, 'available_cash': amount}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 400 + + +@bp.route('/stoploss_check', methods=['GET']) +@login_required +def check_stoploss(): + """检查止损线触发情况""" + try: + user_id = get_current_user_id() + trades = db_get_trades(user_id) + + # 计算每只股票的持仓情况 + holdings = {} + for trade in trades: + code = trade.get('stock_code') + if not code: + continue + if code not in holdings: + holdings[code] = { + 'code': code, + 'name': trade.get('stock_name', code), + 'total_cost': 0, + 'total_quantity': 0, + 'stop_loss_price': trade.get('stop_loss_price'), + } + + trade_type = trade.get('trade_type') + quantity = int(trade.get('quantity', 0) or 0) + price = float(trade.get('price', 0) or 0) + + if trade_type == 'buy': + holdings[code]['total_cost'] += price * quantity + holdings[code]['total_quantity'] += quantity + elif trade_type == 'sell': + holdings[code]['total_quantity'] -= quantity + if holdings[code]['total_quantity'] > 0: + cost_per_share = holdings[code]['total_cost'] / (holdings[code]['total_quantity'] + quantity) + holdings[code]['total_cost'] -= cost_per_share * quantity + + if trade.get('stop_loss_price'): + holdings[code]['stop_loss_price'] = trade.get('stop_loss_price') + + # 只保留有持仓的股票 + active_holdings = {k: v for k, v in holdings.items() if v['total_quantity'] > 0} + + # 获取实时价格并检查止损 + alerts = [] + try: + for code, holding in active_holdings.items(): + try: + # 优先使用腾讯财经API(兼容腾讯云) + current_price = 0 + try: + import requests as _rq + _tc = ('sh' if code.startswith('6') else 'sz') + code + _rr = _rq.get(f'http://qt.gtimg.cn/q={_tc}', timeout=5, + headers={'Referer': 'https://finance.qq.com'}) + if _rr.status_code == 200 and '\"' in _rr.text: + _ff = _rr.text.split('\"')[1].split('~') + if len(_ff) > 3 and _ff[3]: + current_price = float(_ff[3]) + except Exception: + pass + if current_price > 0: + stop_loss_price = holding.get('stop_loss_price') + avg_cost = float(holding['total_cost']) / holding['total_quantity'] if holding['total_quantity'] > 0 else 0.0 + + profit_loss = (current_price - avg_cost) * holding['total_quantity'] + profit_percent = ((current_price - avg_cost) / avg_cost * 100) if avg_cost > 0 else 0 + + alert_data = { + 'code': code, + 'name': holding['name'], + 'current_price': current_price, + 'avg_cost': round(avg_cost, 4), + 'quantity': holding['total_quantity'], + 'profit_loss': round(profit_loss, 2), + 'profit_percent': round(profit_percent, 2), + 'stop_loss_price': stop_loss_price, + 'triggered': False + } + + if stop_loss_price and current_price <= stop_loss_price: + alert_data['triggered'] = True + alert_data['alert_type'] = 'stop_loss' + alert_data['message'] = f"⚠️ {holding['name']} 触发止损!" + elif profit_percent <= -5: + alert_data['triggered'] = True + alert_data['alert_type'] = 'default_stop' + alert_data['message'] = f"⚠️ {holding['name']} 跌破成本5%!" + + alerts.append(alert_data) + except Exception as e: + print(f"获取 {code} 价格失败: {e}") + except Exception as e: + print(f"获取实时行情失败: {e}") + + return jsonify({ + 'success': True, + 'holdings': list(active_holdings.values()), + 'alerts': [a for a in alerts if a.get('triggered')], + 'all_positions': alerts + }) + + except Exception as e: + print(f"止损检查错误: {e}") + return jsonify({'error': str(e)}), 500 diff --git a/stock-html/routes/watchlist.py b/stock-html/routes/watchlist.py new file mode 100644 index 0000000..a467b7c --- /dev/null +++ b/stock-html/routes/watchlist.py @@ -0,0 +1,47 @@ +""" +关注列表 API 路由(纯数据库版) +""" +from flask import Blueprint, request, jsonify +from db import ( + login_required, get_current_user_id, + db_get_watchlist, db_add_to_watchlist, db_remove_from_watchlist +) + +bp = Blueprint('watchlist', __name__, url_prefix='/api') + + +@bp.route('/watchlist', methods=['GET']) +@login_required +def get_watchlist(): + """获取关注列表""" + user_id = get_current_user_id() + watchlist = db_get_watchlist(user_id) + return jsonify({'success': True, 'watchlist': watchlist}) + + +@bp.route('/watchlist', methods=['POST']) +@login_required +def add_to_watchlist(): + """添加到关注列表""" + try: + user_id = get_current_user_id() + data = request.get_json() + code = data.get('code') + name = data.get('name', f'股票{code}') + + watchlist, error = db_add_to_watchlist(user_id, code, name) + if error: + return jsonify({'success': False, 'error': error}), 400 + + return jsonify({'success': True, 'watchlist': watchlist}) + except Exception as e: + return jsonify({'error': str(e)}), 400 + + +@bp.route('/watchlist/', methods=['DELETE']) +@login_required +def remove_from_watchlist(stock_code): + """从关注列表移除""" + user_id = get_current_user_id() + watchlist = db_remove_from_watchlist(user_id, stock_code) + return jsonify({'success': True, 'watchlist': watchlist or []}) diff --git a/stock-html/run.md b/stock-html/run.md new file mode 100644 index 0000000..2863c28 --- /dev/null +++ b/stock-html/run.md @@ -0,0 +1,300 @@ +# 股票投资分析系统 - 运行指南 + +## 快速启动 + +### 本地开发 +```bash +cd /Users/freedak/Documents/go-new/stock/stock-html + +# 激活虚拟环境 +source venv/bin/activate + +# 启动服务 +python app.py +``` + +访问地址:http://localhost:3333 + +### 服务器部署 + +#### 主服务器(原有) +- **服务器地址**:43.135.128.39(腾讯云) +- **登录用户**:ubuntu(需sudo) +- **部署路径**:/opt/stock-app +- **外部访问**:http://43.135.128.39:3333 + +#### 新服务器(stock.allbyai.cn) +- **服务器地址**:152.136.182.184(腾讯云) +- **登录用户**:ubuntu(需sudo) +- **部署路径**:/opt/stock-app +- **域名访问**:http://stock.allbyai.cn +- **IP直连**:http://152.136.182.184:3333 + +#### 同步代码到服务器 +**注意**:本地修改代码后请执行下方同步并重启,使服务器生效。 + +```bash +# 同步全部代码(推荐) +cd /Users/freedak/Documents/go-new/stock/stock-html +rsync -avz ./ ubuntu@43.135.128.39:/opt/stock-app/ \ + --exclude='.git' \ + --exclude='venv' \ + --exclude='__pycache__' \ + --exclude='stock_data_cache' \ + --exclude='*.pyc' \ + --exclude='.DS_Store' \ + --exclude='stock_names.json' \ + --exclude='alerts_cache.json' \ + --exclude='trades.json' \ + --exclude='watchlist.json' \ + --exclude='*.log' \ + --exclude='.playwright-mcp' \ + --exclude='/app.js' \ + --exclude='/index.html' \ + --exclude='/main.css' \ + --exclude='/css' \ + --exclude='/js' \ + --exclude='/.windsurfrules' + +# 仅同步前端 +rsync -avz templates/ ubuntu@43.135.128.39:/opt/stock-app/templates/ +rsync -avz static/ ubuntu@43.135.128.39:/opt/stock-app/static/ + +# 仅同步后端 +rsync -avz app.py routes/ services/ ubuntu@43.135.128.39:/opt/stock-app/ + +# 重启服务 +ssh ubuntu@43.135.128.39 "systemctl restart stock-app" + +# ========== 新服务器 stock.allbyai.cn ========== +# 同步全部代码到新服务器 +cd /Users/freedak/Documents/go-new/stock/stock-html +rsync -avz ./ ubuntu@152.136.182.184:/opt/stock-app/ \ + --exclude='.git' \ + --exclude='venv' \ + --exclude='__pycache__' \ + --exclude='stock_data_cache' \ + --exclude='*.pyc' \ + --exclude='.DS_Store' \ + --exclude='stock_names.json' \ + --exclude='alerts_cache.json' \ + --exclude='trades.json' \ + --exclude='watchlist.json' \ + --exclude='*.log' \ + --exclude='.playwright-mcp' \ + --exclude='/app.js' \ + --exclude='/index.html' \ + --exclude='/main.css' \ + --exclude='/css' \ + --exclude='/js' \ + --exclude='/.windsurfrules' + +# 重启新服务器服务 +ssh ubuntu@152.136.182.184 "systemctl restart stock-app" + +# 或使用一键脚本 +./deploy/sync-to-new-server.sh +``` + +#### 服务管理 +```bash +# Web应用服务 +ssh ubuntu@43.135.128.39 "systemctl start stock-app" +ssh ubuntu@43.135.128.39 "systemctl stop stock-app" +ssh ubuntu@43.135.128.39 "systemctl restart stock-app" +ssh ubuntu@43.135.128.39 "systemctl status stock-app" +ssh ubuntu@43.135.128.39 "journalctl -u stock-app -f" + +# 数据采集服务 +ssh ubuntu@43.135.128.39 "systemctl start stock-data-service" +ssh ubuntu@43.135.128.39 "systemctl stop stock-data-service" +ssh ubuntu@43.135.128.39 "systemctl status stock-data-service" +ssh ubuntu@43.135.128.39 "journalctl -u stock-data-service -f" + +# ========== 新服务器 stock.allbyai.cn ========== +# Web应用服务 +ssh ubuntu@152.136.182.184 "systemctl start stock-app" +ssh ubuntu@152.136.182.184 "systemctl stop stock-app" +ssh ubuntu@152.136.182.184 "systemctl restart stock-app" +ssh ubuntu@152.136.182.184 "systemctl status stock-app" +ssh ubuntu@152.136.182.184 "journalctl -u stock-app -f" + +# 数据采集服务 +ssh ubuntu@152.136.182.184 "systemctl start stock-data-service" +ssh ubuntu@152.136.182.184 "systemctl stop stock-data-service" +ssh ubuntu@152.136.182.184 "systemctl status stock-data-service" +ssh ubuntu@152.136.182.184 "journalctl -u stock-data-service -f" +``` + +## 项目结构 + +``` +stock-html/ +├── app.py # Flask后端主程序 +├── config.py # 配置文件(支持环境变量) +├── db.py # 数据库操作层 +├── requirements.txt # Python依赖 +├── start.sh # 启动脚本 +├── stock_data_service.py # 数据采集服务(定时任务) +├── full_signal_scan.py # 全市场信号扫描脚本 +├── sync_fund_flow.py # 资金流向全市场采集脚本 +├── auto_sync_fund_flow.sh # 资金流向采集定时任务 +├── routes/ # API路由 +│ ├── analysis.py # 分析/扫描/信号相关API +│ ├── auth.py # 认证API +│ ├── market.py # 市场数据API +│ ├── sim_trade.py # 模拟交易API +│ ├── trades.py # 交易记录API +│ └── watchlist.py # 关注列表API +├── services/ # 业务服务 +│ ├── signal_detector.py # 7种技术信号检测器 +│ ├── technical_indicators.py # 技术指标计算 +│ ├── scheduler.py # 自动交易调度器 +│ ├── stock_service.py # 股票数据服务 +│ ├── mairui_api.py # 迈瑞API +│ └── doubao_api.py # 豆包AI分析 +├── templates/ +│ └── index.html # 前端页面(Vue.js) +├── static/ +│ ├── css/main.css # 样式 +│ └── js/app.js # Vue.js应用 +└── stock-data-service.service # systemd服务配置 +``` + +## systemd 服务 + +### stock-app.service (Web应用) +路径:`/etc/systemd/system/stock-app.service` +端口:3333 + +### stock-data-service.service (数据采集) +路径:`/etc/systemd/system/stock-data-service.service` +功能:定时采集实时价格数据 + +### 全景扫描定时任务 (crontab) + +应用内**没有**自动配置全景扫描的定时任务,需在服务器上添加 crontab。当前配置**每天两次扫描**: + +| 时间 | 说明 | +|------|------| +| **11:50** | 午休扫描 — 上午收盘20分钟后,确保数据API更新完毕,供下午交易参考 | +| **16:30** | 收盘扫描 — 收盘1.5小时后,确保全天K线数据完整更新 | + +> **为什么留缓冲时间?** A股上午 11:30 收盘、下午 15:00 收盘。数据API更新需要一定时间,11:50 和 16:30 给予充分缓冲确保数据完整性。 + +**方式一:一键配置(推荐)** + +在本地 `stock-html` 目录下执行(会同步 `auto_scan.sh`、在服务器上添加 crontab): +```bash +cd /path/to/stock-html +./setup_cron_scan.sh +``` + +如需指定服务器或目录:`STOCK_SERVER=root@你的IP STOCK_APP_DIR=/opt/stock-app ./setup_cron_scan.sh` + +**方式二:在服务器上手动配置** +```bash +ssh ubuntu@43.135.128.39 +chmod +x /opt/stock-app/auto_scan.sh +crontab -e +# 添加两行: +50 11 * * 1-5 /opt/stock-app/auto_scan.sh >> /opt/stock-app/auto_scan.log 2>&1 +30 16 * * 1-5 /opt/stock-app/auto_scan.sh >> /opt/stock-app/auto_scan.log 2>&1 +# 5分钟K线采集(17:30,收盘后数据完整) +30 17 * * 1-5 /opt/stock-app/auto_sync_kline_5min.sh >> /opt/stock-app/sync_kline_5min.log 2>&1 +# 资金流向采集(18:00,在K线采集之后) +0 18 * * 1-5 /opt/stock-app/auto_sync_fund_flow.sh >> /opt/stock-app/sync_fund_flow.log 2>&1 +``` + +**验证与排查** +```bash +# 查看当前 crontab +ssh ubuntu@43.135.128.39 "crontab -l" + +# 手动执行一次测试 +ssh ubuntu@43.135.128.39 "/opt/stock-app/auto_scan.sh" + +# 查看扫描日志 +ssh ubuntu@43.135.128.39 "tail -50 /opt/stock-app/auto_scan.log" +``` + +- 未配置 crontab 时:前端「全景扫描」按钮仅查看已有缓存结果,不会触发新扫描。需手动在服务器执行 `FORCE_RESCAN=1 python full_signal_scan.py` 或配置 crontab。 + +## API接口 + +| 接口 | 方法 | 说明 | +|------|------|------| +| `/api/health` | GET | 健康检查 | +| `/api/signal_alerts` | POST | 信号分析提醒(新模型) | +| `/api/tech_signals/` | GET | 单只股票7种信号检测 | +| `/api/batch_tech_signals` | POST | 批量信号检测 | +| `/api/start_full_scan` | POST | 启动全市场扫描 | +| `/api/scan_status` | GET | 扫描进度查询 | +| `/api/scan_results` | GET | 扫描结果查询 | +| `/api/scan_strategy` | GET | 策略建议 | +| `/api/realtime_price/` | GET | 获取实时价格 | +| `/api/trades` | GET/POST | 交易记录 | +| `/api/watchlist` | GET/POST/DELETE | 关注列表 | +| `/api/alerts_cache` | GET/POST | 分析缓存 | +| `/api/fundamental/` | GET | 基本面数据 | +| `/api/kline/` | GET | K线数据 | + +## 核心算法(交易信号实战体系) + +### 7种技术信号 +1. ★主升浪 - 最稳最猛(利润核心阶段) +2. 日线底背离 - 最安全抄底(反转前置信号) +3. 龙抬头 - 最佳入场点(实操核心买点) +4. 真龙 - 趋势确认(中期行情定局信号) +5. 短底背离 - 辅助参考 +6. 老鼠仓 - 辅助参考 +7. 反弹 - 辅助参考 + +### 最强战法 +1. 日线底背离出现 → 纳入关注 +2. 龙抬头出现 → 执行买入 +3. 真龙/主升浪出现 → 持有加仓 +4. MACD死叉+主升浪消失 → 卖出 + +## 常用命令 + +```bash +# 一键同步全部代码并重启(已排除根目录重复项,仅同步 templates/ static/ 等正确路径) +cd /Users/freedak/Documents/go-new/stock/stock-html && \ +rsync -avz ./ ubuntu@43.135.128.39:/opt/stock-app/ \ + --exclude='.git' --exclude='venv' --exclude='__pycache__' --exclude='stock_data_cache' \ + --exclude='*.pyc' --exclude='.DS_Store' --exclude='*.log' \ + --exclude='stock_names.json' --exclude='alerts_cache.json' \ + --exclude='.playwright-mcp' \ + --exclude='/app.js' --exclude='/index.html' --exclude='/main.css' \ + --exclude='/css' --exclude='/js' --exclude='/.windsurfrules' && \ +ssh ubuntu@43.135.128.39 "systemctl restart stock-app && echo '服务已重启'" + +# 仅同步前端并重启 +rsync -avz templates/ static/ ubuntu@43.135.128.39:/opt/stock-app/ && \ +ssh ubuntu@43.135.128.39 "systemctl restart stock-app" + +# 查看两个服务状态 +ssh ubuntu@43.135.128.39 "systemctl status stock-app stock-data-service" + +# ========== 新服务器 stock.allbyai.cn 快捷命令 ========== +# 一键同步并重启(推荐) +cd /Users/freedak/Documents/go-new/stock/stock-html && \ +rsync -avz ./ ubuntu@152.136.182.184:/opt/stock-app/ \ + --exclude='.git' --exclude='venv' --exclude='__pycache__' --exclude='stock_data_cache' \ + --exclude='*.pyc' --exclude='.DS_Store' --exclude='*.log' \ + --exclude='stock_names.json' --exclude='alerts_cache.json' \ + --exclude='.playwright-mcp' \ + --exclude='/app.js' --exclude='/index.html' --exclude='/main.css' \ + --exclude='/css' --exclude='/js' --exclude='/.windsurfrules' && \ +ssh ubuntu@152.136.182.184 "systemctl restart stock-app && echo '服务已重启'" + +# 或使用脚本 +./deploy/sync-to-new-server.sh + +# 查看服务状态 +ssh ubuntu@152.136.182.184 "systemctl status stock-app stock-data-service" +``` + +--- +最后更新:2026-03-02 diff --git a/stock-html/run_algo_search.py b/stock-html/run_algo_search.py new file mode 100644 index 0000000..3f26d0d --- /dev/null +++ b/stock-html/run_algo_search.py @@ -0,0 +1,461 @@ +#!/usr/bin/env python3 +""" +高速系统性算法搜索 v5.3 — 内存回测引擎 + +核心优化:预加载所有数据到内存,避免回测时反复查询数据库。 + - 旧版: ~22s/次 (DB查询) → 新版: ~0.2s/次 (内存读取) + - 3000+ 种组合约需 10-15 分钟(而非 47 小时) + +两阶段搜索: + Phase 1: 用活跃季度(2025-Q3)快速筛选出Top 60 + Phase 2: 用全期间(2025-01~2026-02)验证Top 60 +""" +import sys +import os +import time +import itertools +from datetime import date, datetime + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from backtest_recommend import get_db_conn, run_backtest, preload_all_data + +CAPITAL = 200_000 + +# ─── 搜索空间 ───────────────── +SEARCH_SPACE = { + 'tp_sl': [ + (6, 3), (8, 4), (8, 6), (10, 5), (10, 8), + (12, 6), (12, 8), (15, 8), (15, 10), (20, 10), (20, 12), + ], + 'min_triggered': [0, 1, 2, 3], + 'sell_mode': ['normal', 'ignore', 'delay1', 'delay2', 'delay3'], + 'trailing': [ + None, + (6, 2), (8, 3), (10, 3), (10, 5), (12, 4), (12, 5), (15, 5), + ], + 'position_pct': [3, 5, 8, 10, 15], + 'signal_weight': [False, True], + 'max_hold_days': [0, 20, 30, 60], +} + +# Phase 1 筛选期(选一个有代表性的活跃季度) +SCREEN_START = date(2025, 7, 1) +SCREEN_END = date(2025, 9, 30) + +# Phase 2 全量验证期 +FULL_START = date(2025, 1, 2) +FULL_END = date(2026, 2, 25) + +TOP_N_SCREEN = 60 # Phase 1 筛出前60进入Phase 2 +TOP_N_FINAL = 30 # Phase 2 展示前30 + + +def build_params(tp_sl, min_triggered, sell_mode, trailing, position_pct, signal_weight, max_hold_days): + """将组合参数转为 run_backtest 的 kwargs""" + params = { + 'total_capital': CAPITAL, + 'position_pct': position_pct, + 'signal_weight': signal_weight, + 'use_5min_prices': True, # v7: 启用5分钟实时价格 + 'buy_time': '09:35', # v7: 最优买入时间 + 'sell_time': '13:40', # v7: 最优卖出时间 + 'verbose': False, + } + tp, sl = tp_sl + params['stop_loss_pct'] = sl + + if trailing is not None: + # 跟踪止盈模式:不用固定止盈,自动忽略卖出信号 + params['take_profit_pct'] = None + params['trailing_start_pct'] = trailing[0] + params['trailing_gap_pct'] = trailing[1] + params['ignore_sell_signal'] = True + else: + params['take_profit_pct'] = tp + + if min_triggered > 0: + params['min_buy_triggered'] = min_triggered + + if sell_mode == 'ignore': + params['ignore_sell_signal'] = True + elif sell_mode.startswith('delay'): + days = int(sell_mode.replace('delay', '')) + params['sell_confirm_days'] = days + + if max_hold_days > 0: + params['max_hold_days'] = max_hold_days + + return params + + +def make_name(tp_sl, min_triggered, sell_mode, trailing, position_pct, signal_weight, max_hold_days): + """生成人类可读的策略名""" + parts = [] + tp, sl = tp_sl + if trailing: + parts.append(f"T{trailing[0]}/{trailing[1]}") + else: + parts.append(f"TP{tp}") + parts.append(f"SL{sl}") + if min_triggered > 0: + parts.append(f"trig≥{min_triggered}") + if sell_mode == 'ignore': + parts.append("ign") + elif sell_mode.startswith('delay'): + parts.append(sell_mode) + if max_hold_days > 0: + parts.append(f"h≤{max_hold_days}") + parts.append(f"{position_pct}%") + if signal_weight: + parts.append("SW") + return "|".join(parts) + + +def is_redundant(sell_mode, trailing): + """剪枝:跟踪止盈模式下,delay/normal卖出不生效""" + if trailing is not None and sell_mode in ('delay1', 'delay2', 'delay3'): + return True + if trailing is not None and sell_mode == 'normal': + return True + return False + + +def main(): + conn = get_db_conn() + + # 生成所有组合并剪枝 + all_combos = list(itertools.product( + SEARCH_SPACE['tp_sl'], + SEARCH_SPACE['min_triggered'], + SEARCH_SPACE['sell_mode'], + SEARCH_SPACE['trailing'], + SEARCH_SPACE['position_pct'], + SEARCH_SPACE['signal_weight'], + SEARCH_SPACE['max_hold_days'], + )) + combos = [(tp_sl, mt, sm, tr, pp, sw, mh) + for tp_sl, mt, sm, tr, pp, sw, mh in all_combos + if not is_redundant(sm, tr)] + + print(f"{'='*100}") + print(f" 🚀 高速系统性算法搜索 v7.0 (内存回测引擎 + 最优时点09:35/13:40)") + print(f"{'='*100}") + print(f" 本金: ¥{CAPITAL:,}") + print(f" 组合总数: {len(all_combos):,} → 剪枝后: {len(combos):,}") + print(f" Phase 1: 快速筛选 ({SCREEN_START} ~ {SCREEN_END})") + print(f" Phase 2: 全量验证 Top {TOP_N_SCREEN} ({FULL_START} ~ {FULL_END})") + print(f"{'='*100}\n") + + # ════════════════ 预加载数据 ════════════════ + print(" 📦 预加载回测数据...") + # 加载全量数据(覆盖Phase 1和Phase 2的完整范围,含5分钟K线) + data_all = preload_all_data(conn, FULL_START, FULL_END, use_5min=True) + print() + + # ════════════════ Phase 1: 快速筛选 ════════════════ + print(f" ▶ Phase 1: 快速筛选 {len(combos):,} 种组合...") + phase1_results = [] + t_start = time.time() + + for i, (tp_sl, mt, sm, tr, pp, sw, mh) in enumerate(combos): + name = make_name(tp_sl, mt, sm, tr, pp, sw, mh) + params = build_params(tp_sl, mt, sm, tr, pp, sw, mh) + + if (i + 1) % 200 == 0 or i == 0: + elapsed = time.time() - t_start + speed = (i + 1) / elapsed if elapsed > 0 else 0 + eta = (len(combos) - i - 1) / speed if speed > 0 else 0 + best_name = phase1_results[0]['name'] if phase1_results else 'N/A' + best_profit = phase1_results[0]['profit'] if phase1_results else 0 + print(f" [{i+1:>5}/{len(combos)}] {elapsed:.0f}s ({speed:.1f}次/秒) " + f"ETA:{eta:.0f}s Top1: ¥{best_profit:+,.0f} ({best_name[:35]})", flush=True) + + # 使用预加载数据进行内存回测 + result = run_backtest( + conn, start_date=SCREEN_START, end_date=SCREEN_END, + preloaded=data_all, **params + ) + + if result and result.get('stats'): + s = result['stats'] + phase1_results.append({ + 'name': name, + 'combo': (tp_sl, mt, sm, tr, pp, sw, mh), + 'profit': s['profit'], + 'capital_pct': s['capital_pct'], + 'capital_ann_pct': s.get('capital_ann_pct', 0), + 'win_rate': s['win_rate'], + 'profit_factor': s['profit_factor'], + 'trade_count': s['trade_count'], + 'max_drawdown_pct': s.get('max_drawdown_pct', 0), + }) + + phase1_results.sort(key=lambda x: x['profit'], reverse=True) + p1_time = time.time() - t_start + p1_speed = len(combos) / p1_time if p1_time > 0 else 0 + print(f"\n ✅ Phase 1 完成! {p1_time:.0f}秒 ({p1_speed:.1f}次/秒), 有效结果: {len(phase1_results)}") + print(f" Phase 1 Top 10:") + for i, r in enumerate(phase1_results[:10], 1): + print(f" {i:>2}. ¥{r['profit']:>+10,.0f} 收益{r['capital_pct']:>+6.1f}% " + f"胜率{r['win_rate']:>5.1f}% PF{r['profit_factor']:>5.2f} 回撤{r['max_drawdown_pct']:>5.1f}% {r['name']}") + + # ════════════════ Phase 2: 全量验证 ════════════════ + top_candidates = phase1_results[:TOP_N_SCREEN] + print(f"\n ▶ Phase 2: 全期间验证 Top {len(top_candidates)} ...") + phase2_results = [] + t2_start = time.time() + + for i, cand in enumerate(top_candidates): + tp_sl, mt, sm, tr, pp, sw, mh = cand['combo'] + name = cand['name'] + params = build_params(tp_sl, mt, sm, tr, pp, sw, mh) + + if (i + 1) % 10 == 0 or i == 0: + elapsed = time.time() - t2_start + speed = (i + 1) / elapsed if elapsed > 0 else 0 + eta = (len(top_candidates) - i - 1) / speed if speed > 0 else 0 + print(f" [{i+1}/{len(top_candidates)}] {elapsed:.0f}s ETA:{eta:.0f}s", flush=True) + + # 使用预加载数据进行全量回测 + result = run_backtest( + conn, start_date=FULL_START, end_date=FULL_END, + preloaded=data_all, **params + ) + + if result and result.get('stats'): + s = result['stats'] + phase2_results.append({ + 'name': name, + 'combo': cand['combo'], + 'screen_profit': cand['profit'], + 'profit': s['profit'], + 'capital_pct': s['capital_pct'], + 'capital_ann_pct': s.get('capital_ann_pct', 0), + 'win_rate': s['win_rate'], + 'profit_factor': s['profit_factor'], + 'max_drawdown_pct': s.get('max_drawdown_pct', 0), + 'trade_count': s['trade_count'], + 'max_capital': s.get('max_capital', 0), + 'avg_hold_days': s.get('avg_hold_days', 0), + 'closed_count': s.get('closed_count', 0), + 'wins': s.get('wins', 0), + 'losses': s.get('losses', 0), + 'capital_ann_method': s.get('capital_ann_method', ''), + }) + + phase2_results.sort(key=lambda x: x['profit'], reverse=True) + p2_time = time.time() - t2_start + total_time = time.time() - t_start + + conn.close() + + # ═══════════════ 控制台输出 ═══════════════ + print(f"\n{'='*130}") + print(f" 🏆 搜索完成! {len(combos):,}种组合 | " + f"Phase1:{p1_time:.0f}s Phase2:{p2_time:.0f}s | " + f"总计:{total_time:.0f}s ({total_time/60:.1f}分钟)") + print(f"{'='*130}") + + print(f"\n 📊 全期间 Top {min(TOP_N_FINAL, len(phase2_results))} 算法:\n") + header = (f"{'排名':>4} {'全期盈利':>12} {'Q3盈利':>10} {'真实收益':>8} {'年化':>8} " + f"{'胜率':>6} {'盈亏比':>6} {'回撤':>6} {'交易':>5} {'持仓天':>6} | {'策略'}") + print(f" {header}") + print(" " + "-" * 130) + + for rank, r in enumerate(phase2_results[:TOP_N_FINAL], 1): + medal = "🏆" if rank == 1 else ("🥈" if rank == 2 else ("🥉" if rank == 3 else f" {rank:>2}")) + print(f" {medal} {r['profit']:>+11,.0f} {r['screen_profit']:>+9,.0f} " + f"{r['capital_pct']:>+7.1f}% {r['capital_ann_pct']:>+7.1f}% " + f"{r['win_rate']:>5.1f}% {r['profit_factor']:>6.2f} {r['max_drawdown_pct']:>5.1f}% " + f"{r['trade_count']:>5} {r['avg_hold_days']:>5.0f}d | {r['name']}") + + # ─── 维度分析 ───────────────────── + if phase2_results: + print(f"\n{'='*130}") + print(f" 📊 维度影响分析") + print(f"{'='*130}") + + # 止盈方式 + print(f"\n 📈 止盈方式 (跟踪 vs 固定):") + tr_sub = [r for r in phase2_results if r['combo'][3] is not None] + fx_sub = [r for r in phase2_results if r['combo'][3] is None] + if tr_sub: + avg = sum(r['profit'] for r in tr_sub) / len(tr_sub) + best = max(tr_sub, key=lambda x: x['profit']) + print(f" 跟踪止盈: n={len(tr_sub):>3} 平均 ¥{avg:>+9,.0f} 最优 ¥{best['profit']:>+9,.0f} ({best['name'][:40]})") + if fx_sub: + avg = sum(r['profit'] for r in fx_sub) / len(fx_sub) + best = max(fx_sub, key=lambda x: x['profit']) + print(f" 固定止盈: n={len(fx_sub):>3} 平均 ¥{avg:>+9,.0f} 最优 ¥{best['profit']:>+9,.0f} ({best['name'][:40]})") + + # 仓位比例 + print(f"\n 💰 仓位比例:") + for pct in sorted(set(r['combo'][4] for r in phase2_results)): + subset = [r for r in phase2_results if r['combo'][4] == pct] + if subset: + avg_p = sum(r['profit'] for r in subset) / len(subset) + best = max(subset, key=lambda x: x['profit']) + print(f" {pct:>2}%: n={len(subset):>3} 平均 ¥{avg_p:>+9,.0f} 最优 ¥{best['profit']:>+9,.0f}") + + # 信号加权 + print(f"\n 📶 信号加权:") + for sw in [False, True]: + subset = [r for r in phase2_results if r['combo'][5] == sw] + if subset: + avg_p = sum(r['profit'] for r in subset) / len(subset) + best = max(subset, key=lambda x: x['profit']) + print(f" {'加权' if sw else '等权':>4}: n={len(subset):>3} 平均 ¥{avg_p:>+9,.0f} 最优 ¥{best['profit']:>+9,.0f}") + + # 卖出策略 + print(f"\n 🛒 卖出策略:") + for sm_label in ['normal', 'ignore', 'delay1', 'delay2', 'delay3']: + subset = [r for r in phase2_results if r['combo'][2] == sm_label] + if subset: + avg_p = sum(r['profit'] for r in subset) / len(subset) + best = max(subset, key=lambda x: x['profit']) + print(f" {sm_label:>8}: n={len(subset):>3} 平均 ¥{avg_p:>+9,.0f} 最优 ¥{best['profit']:>+9,.0f}") + + # 止盈/止损参数 + print(f"\n 🎯 止盈线 (固定止盈组合):") + for tp in sorted(set(r['combo'][0][0] for r in fx_sub)) if fx_sub else []: + subset = [r for r in fx_sub if r['combo'][0][0] == tp] + if subset: + avg_p = sum(r['profit'] for r in subset) / len(subset) + best = max(subset, key=lambda x: x['profit']) + print(f" TP={tp:>2}: n={len(subset):>3} 平均 ¥{avg_p:>+9,.0f} 最优 ¥{best['profit']:>+9,.0f}") + + print(f"\n 🛡️ 止损线:") + for sl in sorted(set(r['combo'][0][1] for r in phase2_results)): + subset = [r for r in phase2_results if r['combo'][0][1] == sl] + if subset: + avg_p = sum(r['profit'] for r in subset) / len(subset) + best = max(subset, key=lambda x: x['profit']) + print(f" SL={sl:>2}: n={len(subset):>3} 平均 ¥{avg_p:>+9,.0f} 最优 ¥{best['profit']:>+9,.0f}") + + # 最大持仓天数 + print(f"\n ⏰ 最大持仓天数:") + for mh in sorted(set(r['combo'][6] for r in phase2_results)): + subset = [r for r in phase2_results if r['combo'][6] == mh] + if subset: + avg_p = sum(r['profit'] for r in subset) / len(subset) + best = max(subset, key=lambda x: x['profit']) + label = "不限" if mh == 0 else f"{mh}天" + print(f" {label:>4}: n={len(subset):>3} 平均 ¥{avg_p:>+9,.0f} 最优 ¥{best['profit']:>+9,.0f}") + + # 买入信号触发数 + print(f"\n 🔔 买入信号触发数:") + for mt in sorted(set(r['combo'][1] for r in phase2_results)): + subset = [r for r in phase2_results if r['combo'][1] == mt] + if subset: + avg_p = sum(r['profit'] for r in subset) / len(subset) + best = max(subset, key=lambda x: x['profit']) + label = "不限" if mt == 0 else f"≥{mt}" + print(f" {label:>4}: n={len(subset):>3} 平均 ¥{avg_p:>+9,.0f} 最优 ¥{best['profit']:>+9,.0f}") + + # ─── 与之前冠军对比 ───────────────────── + print(f"\n{'='*130}") + print(f" 📊 与之前最优算法对比") + print(f"{'='*130}") + prev_best = {'profit': 56375, 'capital_pct': 21.5, 'capital_ann_pct': 18.5, + 'win_rate': 61.2, 'profit_factor': 2.30, 'name': 'v5.2|忽略卖出+TP10+SL8+信号加权'} + new_best = phase2_results[0] if phase2_results else None + if new_best: + print(f" 之前冠军: ¥{prev_best['profit']:>+10,.0f} 收益{prev_best['capital_pct']:>+6.1f}% " + f"年化{prev_best['capital_ann_pct']:>+6.1f}% 胜率{prev_best['win_rate']:>5.1f}% " + f"PF{prev_best['profit_factor']:>5.2f} {prev_best['name']}") + print(f" 新冠军: ¥{new_best['profit']:>+10,.0f} 收益{new_best['capital_pct']:>+6.1f}% " + f"年化{new_best['capital_ann_pct']:>+6.1f}% 胜率{new_best['win_rate']:>5.1f}% " + f"PF{new_best['profit_factor']:>5.2f} {new_best['name']}") + diff = new_best['profit'] - prev_best['profit'] + print(f" 差异: ¥{diff:>+10,.0f} {'🎉 新纪录!' if diff > 0 else '❌ 未超越'}") + + # ─── Markdown 输出 ───────────────────── + out_path = os.path.join(os.path.dirname(__file__), "docs", "algo_search_results.md") + os.makedirs(os.path.dirname(out_path), exist_ok=True) + + with open(out_path, "w", encoding="utf-8") as f: + f.write("# 🔍 系统性算法搜索结果 (v5.3 内存回测引擎)\n\n") + f.write(f"> 生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M')}\n\n") + f.write("## 搜索配置\n\n") + f.write(f"| 项目 | 值 |\n") + f.write(f"|------|----|\n") + f.write(f"| 本金 | ¥{CAPITAL:,} |\n") + f.write(f"| Phase 1 筛选期 | {SCREEN_START} ~ {SCREEN_END} |\n") + f.write(f"| Phase 2 验证期 | {FULL_START} ~ {FULL_END} |\n") + f.write(f"| 组合总数 | {len(all_combos):,} → 剪枝后 {len(combos):,} |\n") + f.write(f"| Phase 1 耗时 | {p1_time:.0f}s ({p1_speed:.1f}次/秒) |\n") + f.write(f"| Phase 2 耗时 | {p2_time:.0f}s |\n") + f.write(f"| 总耗时 | {total_time:.0f}s ({total_time/60:.1f}分钟) |\n\n") + + f.write("## 🏆 全期间 Top 30\n\n") + f.write("| 排名 | 全期盈利 | Q3盈利 | 真实收益 | 年化 | 胜率 | 盈亏比 | 回撤 | 交易 | 持仓天 | 策略 |\n") + f.write("|------|---------|-------|---------|------|------|--------|------|------|--------|------|\n") + for rank, r in enumerate(phase2_results[:TOP_N_FINAL], 1): + medal = "🏆" if rank == 1 else ("🥈" if rank == 2 else ("🥉" if rank == 3 else f"#{rank}")) + f.write(f"| {medal} | ¥{r['profit']:+,.0f} | ¥{r['screen_profit']:+,.0f} | " + f"{r['capital_pct']:+.1f}% | {r['capital_ann_pct']:+.1f}% | " + f"{r['win_rate']:.1f}% | {r['profit_factor']:.2f} | " + f"{r['max_drawdown_pct']:.1f}% | {r['trade_count']} | " + f"{r['avg_hold_days']:.0f}d | `{r['name']}` |\n") + f.write("\n") + + # 冠军对比 + if new_best: + f.write("## 新冠军 vs 之前冠军\n\n") + f.write("| 指标 | 之前冠军 | 新冠军 |\n") + f.write("|------|---------|-------|\n") + f.write(f"| 策略 | `{prev_best['name']}` | `{new_best['name']}` |\n") + f.write(f"| 全期盈利 | ¥{prev_best['profit']:+,} | ¥{new_best['profit']:+,} |\n") + f.write(f"| 真实收益 | {prev_best['capital_pct']:+.1f}% | {new_best['capital_pct']:+.1f}% |\n") + f.write(f"| 年化 | {prev_best['capital_ann_pct']:+.1f}% | {new_best['capital_ann_pct']:+.1f}% |\n") + f.write(f"| 胜率 | {prev_best['win_rate']:.1f}% | {new_best['win_rate']:.1f}% |\n") + f.write(f"| 盈亏比 | {prev_best['profit_factor']:.2f} | {new_best['profit_factor']:.2f} |\n") + f.write(f"| 回撤 | - | {new_best['max_drawdown_pct']:.1f}% |\n") + diff = new_best['profit'] - prev_best['profit'] + f.write(f"\n{'🎉 **新纪录!**' if diff > 0 else '❌ 未超越之前冠军'}\n\n") + + # 维度分析 + f.write("## 维度影响分析\n\n") + + # 仓位比例 + f.write("### 仓位比例\n\n") + f.write("| 仓位 | 数量 | 平均盈利 | 最优盈利 |\n") + f.write("|------|------|---------|--------|\n") + for pct in sorted(set(r['combo'][4] for r in phase2_results)): + subset = [r for r in phase2_results if r['combo'][4] == pct] + if subset: + avg_p = sum(r['profit'] for r in subset) / len(subset) + best = max(subset, key=lambda x: x['profit']) + f.write(f"| {pct}% | {len(subset)} | ¥{avg_p:+,.0f} | ¥{best['profit']:+,.0f} |\n") + f.write("\n") + + # 信号加权 + f.write("### 信号加权\n\n") + f.write("| 模式 | 数量 | 平均盈利 | 最优盈利 |\n") + f.write("|------|------|---------|--------|\n") + for sw in [False, True]: + subset = [r for r in phase2_results if r['combo'][5] == sw] + if subset: + avg_p = sum(r['profit'] for r in subset) / len(subset) + best = max(subset, key=lambda x: x['profit']) + f.write(f"| {'加权' if sw else '等权'} | {len(subset)} | ¥{avg_p:+,.0f} | ¥{best['profit']:+,.0f} |\n") + f.write("\n") + + # 止损线 + f.write("### 止损线\n\n") + f.write("| 止损 | 数量 | 平均盈利 | 最优盈利 |\n") + f.write("|------|------|---------|--------|\n") + for sl in sorted(set(r['combo'][0][1] for r in phase2_results)): + subset = [r for r in phase2_results if r['combo'][0][1] == sl] + if subset: + avg_p = sum(r['profit'] for r in subset) / len(subset) + best = max(subset, key=lambda x: x['profit']) + f.write(f"| {sl}% | {len(subset)} | ¥{avg_p:+,.0f} | ¥{best['profit']:+,.0f} |\n") + f.write("\n") + + print(f"\n📝 完整结果已写入 {out_path}") + + +if __name__ == "__main__": + main() diff --git a/stock-html/run_backtest_quarterly.py b/stock-html/run_backtest_quarterly.py new file mode 100644 index 0000000..bdab5c4 --- /dev/null +++ b/stock-html/run_backtest_quarterly.py @@ -0,0 +1,490 @@ +#!/usr/bin/env python3 +""" +20万本金 × 按季度投资 × 多算法对比回测 (v7.0 最优交易时点) + +条件(v7.0: 最优时点 + 完全无人为限制): + - 本金:¥200,000(唯一约束) + - 单只上限:无 + - 最大持仓:无 + - 每笔股数:动态(总资金 × position_pct%,每笔等金额) + - 股价区间:无 + - 每日最多买入:无 + - 买入时间:09:35(最优,网格搜索验证) + - 卖出时间:13:40(最优,网格搜索验证) + - 回测区间:2025-01-01 ~ 最新,按季度分段 +""" +import sys +import os +import time +from datetime import date, datetime +from collections import defaultdict + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from backtest_recommend import ( + get_db_conn, run_backtest, START_DATE, SELL_COOLDOWN_DAYS, +) + +# ─── 20万本金配置(v5.2: 完全无人为限制 + 动态仓位) ───────────────── +CAPITAL = 200_000 # 总本金(唯一约束) +POSITION_PCT = 5 # 单笔仓位 = 总资金的5%(¥10,000/笔,约可持20只) + +# ─── 季度定义 ───────────────────── +QUARTERS = [ + ("2025-Q1", date(2025, 1, 2), date(2025, 3, 31)), + ("2025-Q2", date(2025, 4, 1), date(2025, 6, 30)), + ("2025-Q3", date(2025, 7, 1), date(2025, 9, 30)), + ("2025-Q4", date(2025, 10, 1), date(2025, 12, 31)), + ("2026-Q1", date(2026, 1, 5), date(2026, 2, 25)), + # 完整区间 + ("全期间", date(2025, 1, 2), date(2026, 2, 25)), +] + +# ─── 算法配置 ───────────────────── +ALGORITHMS = [ + # 名称, 参数字典 + ("v3|基线(TP10+SL8)", { + "take_profit_pct": 10, "stop_loss_pct": 8, + }), + ("v4|触发≥2+TP10+SL8", { + "min_buy_triggered": 2, "take_profit_pct": 10, "stop_loss_pct": 8, + }), + ("v4.2|延迟2天+TP10+SL8", { + "sell_confirm_days": 2, "take_profit_pct": 10, "stop_loss_pct": 8, + }), + ("v4|忽略卖出+TP10+SL8", { + "ignore_sell_signal": True, "take_profit_pct": 10, "stop_loss_pct": 8, + }), + ("v4|触发≥2+TP15+SL8", { + "min_buy_triggered": 2, "take_profit_pct": 15, "stop_loss_pct": 8, + }), + ("v4|触发≥2+TP10+SL5", { + "min_buy_triggered": 2, "take_profit_pct": 10, "stop_loss_pct": 5, + }), + ("v4.2|延迟2天+触发≥2+TP10+SL8", { + "sell_confirm_days": 2, "min_buy_triggered": 2, + "take_profit_pct": 10, "stop_loss_pct": 8, + }), + ("v4.1|跟踪止盈8/3+SL5", { + "ignore_sell_signal": True, "trailing_start_pct": 8, "trailing_gap_pct": 3, + "stop_loss_pct": 5, + }), + ("v4.1|跟踪止盈8/3+触发≥2+SL5", { + "ignore_sell_signal": True, "trailing_start_pct": 8, "trailing_gap_pct": 3, + "stop_loss_pct": 5, "min_buy_triggered": 2, + }), + ("v4|触发≥2+TP10+SL8+持仓≤30天", { + "min_buy_triggered": 2, "take_profit_pct": 10, "stop_loss_pct": 8, + "max_hold_days": 30, + }), + # ── v5.2: 信号加权仓位 (强信号1.5倍/较强1.2倍) ── + ("v5.2|延迟2天+触发≥2+TP10+SL8+信号加权", { + "sell_confirm_days": 2, "min_buy_triggered": 2, + "take_profit_pct": 10, "stop_loss_pct": 8, + "signal_weight": True, + }), + ("v5.2|忽略卖出+TP10+SL8+信号加权", { + "ignore_sell_signal": True, "take_profit_pct": 10, "stop_loss_pct": 8, + "signal_weight": True, + }), + ("v5.2|跟踪止盈8/3+SL5+信号加权", { + "ignore_sell_signal": True, "trailing_start_pct": 8, "trailing_gap_pct": 3, + "stop_loss_pct": 5, "signal_weight": True, + }), +] + + +def main(): + conn = get_db_conn() + + # 检查数据覆盖 + with conn.cursor() as cur: + cur.execute("SELECT min(scan_date), max(scan_date), count(DISTINCT scan_date) FROM stock_scan_history") + scan_min, scan_max, scan_days = cur.fetchone() + print(f"📊 扫描数据: {scan_min} ~ {scan_max} ({scan_days}天)") + + cur.execute("SELECT min(trade_date), max(trade_date), count(DISTINCT trade_date) FROM stock_kline_daily") + kline_min, kline_max, kline_days = cur.fetchone() + print(f"📊 K线数据: {kline_min} ~ {kline_max} ({kline_days}天)") + + print(f"\n{'='*120}") + print(f" 💰 20万本金 按季度投资 × {len(ALGORITHMS)}种算法 对比回测 (v7.0 最优时点)") + print(f"{'='*120}") + print(f" 本金: ¥{CAPITAL:,} (唯一约束) | 单笔仓位: {POSITION_PCT}%=¥{int(CAPITAL*POSITION_PCT/100):,}/笔(动态)") + print(f" 限制: 单只上限=无 | 最大持仓=无 | 股价区间=无 | 每日买入=无 | 冷却期: {SELL_COOLDOWN_DAYS}天") + print(f" 时点: 买入@09:35 | 卖出@13:40 (v7.0 网格搜索最优)") + print(f"{'='*120}\n") + + # results[quarter_name][algo_name] = stats_dict + results = {} + + total_runs = len(QUARTERS) * len(ALGORITHMS) + run_idx = 0 + + for q_name, q_start, q_end in QUARTERS: + results[q_name] = {} + for algo_name, algo_params in ALGORITHMS: + run_idx += 1 + print(f" [{run_idx}/{total_runs}] {q_name} | {algo_name}", end="", flush=True) + t0 = time.time() + + result = run_backtest( + conn, + start_date=q_start, end_date=q_end, + total_capital=CAPITAL, # v5.1: 总资金约束模式 + position_pct=POSITION_PCT, # v5.2: 动态仓位(每笔=总资金×5%) + use_5min_prices=True, # v7: 使用5分钟实时价格 + buy_time='09:35', # v7: 最优买入时间 + sell_time='13:40', # v7: 最优卖出时间 + verbose=False, + **algo_params, + ) + + elapsed = time.time() - t0 + if result and result.get('stats'): + s = result['stats'] + results[q_name][algo_name] = s + print(f" → ¥{s['profit']:>+10,.0f} 收益{s['capital_pct']:>+6.1f}% " + f"年化{s['capital_ann_pct']:>+7.1f}% 胜率{s['win_rate']:>5.1f}% " + f"({elapsed:.1f}s)", flush=True) + else: + results[q_name][algo_name] = None + print(f" → 无数据 ({elapsed:.1f}s)", flush=True) + + conn.close() + + # ─── 输出结果 ───────────────────── + + # 1. 控制台大表 + print(f"\n{'='*160}") + print(f" 📊 20万本金 × 按季度投资 完整对比表 (v5.2 动态仓位: 每笔={POSITION_PCT}%=¥{int(CAPITAL*POSITION_PCT/100):,})") + print(f"{'='*160}") + + # 表头 + header = f"{'算法':<36}" + for q_name, _, _ in QUARTERS: + header += f" | {q_name:>14}" + print(header) + print("-" * 160) + + # 盈亏行 + print("\n 📈 盈亏(元):") + print("-" * 160) + for algo_name, _ in ALGORITHMS: + row = f" {algo_name:<34}" + for q_name, _, _ in QUARTERS: + s = results[q_name].get(algo_name) + if s: + row += f" | {s['profit']:>+13,.0f}" + else: + row += f" | {'N/A':>13}" + print(row) + + # 真实收益率行 + print(f"\n 📊 真实收益率(%):") + print("-" * 160) + for algo_name, _ in ALGORITHMS: + row = f" {algo_name:<34}" + for q_name, _, _ in QUARTERS: + s = results[q_name].get(algo_name) + if s: + row += f" | {s['capital_pct']:>+12.1f}%" + else: + row += f" | {'N/A':>13}" + print(row) + + # 年化收益率行 + print(f"\n 📊 年化收益率(%):") + print("-" * 160) + for algo_name, _ in ALGORITHMS: + row = f" {algo_name:<34}" + for q_name, _, _ in QUARTERS: + s = results[q_name].get(algo_name) + if s: + method = s.get('capital_ann_method', '?') + tag = '(S)' if method == 'simple' else '(C)' + row += f" | {s['capital_ann_pct']:>+9.1f}%{tag}" + else: + row += f" | {'N/A':>13}" + print(row) + + # 胜率行 + print(f"\n 📊 胜率(%):") + print("-" * 160) + for algo_name, _ in ALGORITHMS: + row = f" {algo_name:<34}" + for q_name, _, _ in QUARTERS: + s = results[q_name].get(algo_name) + if s: + row += f" | {s['win_rate']:>12.1f}%" + else: + row += f" | {'N/A':>13}" + print(row) + + # 盈亏比行 + print(f"\n 📊 盈亏比:") + print("-" * 160) + for algo_name, _ in ALGORITHMS: + row = f" {algo_name:<34}" + for q_name, _, _ in QUARTERS: + s = results[q_name].get(algo_name) + if s: + row += f" | {s['profit_factor']:>13.2f}" + else: + row += f" | {'N/A':>13}" + print(row) + + # 交易笔数行 + print(f"\n 📊 交易笔数:") + print("-" * 160) + for algo_name, _ in ALGORITHMS: + row = f" {algo_name:<34}" + for q_name, _, _ in QUARTERS: + s = results[q_name].get(algo_name) + if s: + row += f" | {s['trade_count']:>13}" + else: + row += f" | {'N/A':>13}" + print(row) + + print(f"\n{'='*160}") + + # ─── 找出各季度最优算法 ───────── + print(f"\n 🏆 各季度最优算法:") + print("-" * 100) + for q_name, _, _ in QUARTERS: + best_algo = None + best_profit = -float('inf') + best_ann = -float('inf') + for algo_name, _ in ALGORITHMS: + s = results[q_name].get(algo_name) + if s and s['profit'] > best_profit: + best_profit = s['profit'] + best_ann = s['capital_ann_pct'] + best_algo = algo_name + best_stats = s + if best_algo: + print(f" {q_name:<14} → 🏆 {best_algo:<36} " + f"盈利 ¥{best_profit:>+10,.0f} 收益{best_stats['capital_pct']:>+6.1f}% " + f"年化{best_ann:>+7.1f}% 胜率{best_stats['win_rate']:.1f}% " + f"盈亏比{best_stats['profit_factor']:.2f}") + else: + print(f" {q_name:<14} → 无数据") + + # ─── 输出到 Markdown ───────── + out_path = os.path.join(os.path.dirname(__file__), "docs", "backtest_quarterly_200k.md") + os.makedirs(os.path.dirname(out_path), exist_ok=True) + + with open(out_path, "w", encoding="utf-8") as f: + f.write("# 💰 20万本金 × 按季度投资 × 多算法对比回测\n\n") + f.write(f"> 生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M')}\n\n") + f.write("## 回测配置 (v5.2 动态仓位)\n\n") + f.write(f"| 参数 | 值 |\n|------|----|\n") + f.write(f"| 本金 | ¥{CAPITAL:,} (唯一约束) |\n") + f.write(f"| 单只上限 | 无(受总资金约束) |\n") + f.write(f"| 最大持仓 | 无(受总资金约束) |\n") + f.write(f"| 每笔仓位 | 动态: 总资金×{POSITION_PCT}% = ¥{int(CAPITAL*POSITION_PCT/100):,}/笔 |\n") + f.write(f"| 每笔股数 | 动态(根据股价自动计算,取整到100股) |\n") + f.write(f"| 股价区间 | 无 |\n") + f.write(f"| 每日最多买入 | 无 |\n") + f.write(f"| 冷却期 | {SELL_COOLDOWN_DAYS}天 |\n") + f.write(f"| 年化方法 | <90天用简单(S),≥90天用复利CAGR(C) |\n\n") + + # 算法说明 + f.write("## 算法说明\n\n") + f.write("| # | 算法 | 参数说明 |\n|---|------|--------|\n") + for i, (name, params) in enumerate(ALGORITHMS, 1): + param_str = ", ".join(f"{k}={v}" for k, v in params.items()) + f.write(f"| {i} | {name} | {param_str} |\n") + f.write("\n") + + # 盈亏对比表 + f.write("## 一、盈亏对比(元)\n\n") + f.write(f"| 算法 |") + for q_name, _, _ in QUARTERS: + f.write(f" {q_name} |") + f.write("\n|------|") + for _ in QUARTERS: + f.write("--------|") + f.write("\n") + for algo_name, _ in ALGORITHMS: + f.write(f"| {algo_name} |") + for q_name, _, _ in QUARTERS: + s = results[q_name].get(algo_name) + if s: + val = f"{s['profit']:+,.0f}" + f.write(f" {val} |") + else: + f.write(" N/A |") + f.write("\n") + f.write("\n") + + # 真实收益率 + f.write("## 二、真实收益率(%)\n\n") + f.write(f"| 算法 |") + for q_name, _, _ in QUARTERS: + f.write(f" {q_name} |") + f.write("\n|------|") + for _ in QUARTERS: + f.write("--------|") + f.write("\n") + for algo_name, _ in ALGORITHMS: + f.write(f"| {algo_name} |") + for q_name, _, _ in QUARTERS: + s = results[q_name].get(algo_name) + if s: + f.write(f" {s['capital_pct']:+.1f}% |") + else: + f.write(" N/A |") + f.write("\n") + f.write("\n") + + # 年化收益率 + f.write("## 三、年化收益率(%)\n\n") + f.write("> (S)=简单年化(<90天),(C)=复利CAGR(≥90天)\n\n") + f.write(f"| 算法 |") + for q_name, _, _ in QUARTERS: + f.write(f" {q_name} |") + f.write("\n|------|") + for _ in QUARTERS: + f.write("--------|") + f.write("\n") + for algo_name, _ in ALGORITHMS: + f.write(f"| {algo_name} |") + for q_name, _, _ in QUARTERS: + s = results[q_name].get(algo_name) + if s: + method = s.get('capital_ann_method', '?') + tag = '(S)' if method == 'simple' else '(C)' + f.write(f" {s['capital_ann_pct']:+.1f}%{tag} |") + else: + f.write(" N/A |") + f.write("\n") + f.write("\n") + + # 胜率 + f.write("## 四、胜率(%)\n\n") + f.write(f"| 算法 |") + for q_name, _, _ in QUARTERS: + f.write(f" {q_name} |") + f.write("\n|------|") + for _ in QUARTERS: + f.write("--------|") + f.write("\n") + for algo_name, _ in ALGORITHMS: + f.write(f"| {algo_name} |") + for q_name, _, _ in QUARTERS: + s = results[q_name].get(algo_name) + if s: + f.write(f" {s['win_rate']:.1f}% |") + else: + f.write(" N/A |") + f.write("\n") + f.write("\n") + + # 盈亏比 + f.write("## 五、盈亏比\n\n") + f.write(f"| 算法 |") + for q_name, _, _ in QUARTERS: + f.write(f" {q_name} |") + f.write("\n|------|") + for _ in QUARTERS: + f.write("--------|") + f.write("\n") + for algo_name, _ in ALGORITHMS: + f.write(f"| {algo_name} |") + for q_name, _, _ in QUARTERS: + s = results[q_name].get(algo_name) + if s: + f.write(f" {s['profit_factor']:.2f} |") + else: + f.write(" N/A |") + f.write("\n") + f.write("\n") + + # 最大占用资金 + f.write("## 六、最大占用资金(元)\n\n") + f.write(f"| 算法 |") + for q_name, _, _ in QUARTERS: + f.write(f" {q_name} |") + f.write("\n|------|") + for _ in QUARTERS: + f.write("--------|") + f.write("\n") + for algo_name, _ in ALGORITHMS: + f.write(f"| {algo_name} |") + for q_name, _, _ in QUARTERS: + s = results[q_name].get(algo_name) + if s: + f.write(f" ¥{s['max_capital']:,.0f} |") + else: + f.write(" N/A |") + f.write("\n") + f.write("\n") + + # 各季度最优算法 + f.write("## 七、🏆 各季度最优算法\n\n") + f.write("| 季度 | 最优算法 | 盈利(元) | 真实收益 | 年化 | 胜率 | 盈亏比 |\n") + f.write("|------|---------|---------|---------|------|------|--------|\n") + for q_name, _, _ in QUARTERS: + best_algo = None + best_profit = -float('inf') + for algo_name, _ in ALGORITHMS: + s = results[q_name].get(algo_name) + if s and s['profit'] > best_profit: + best_profit = s['profit'] + best_algo = algo_name + best_s = s + if best_algo: + method = best_s.get('capital_ann_method', '?') + tag = '(S)' if method == 'simple' else '(C)' + f.write(f"| {q_name} | **{best_algo}** | {best_profit:+,.0f} | " + f"{best_s['capital_pct']:+.1f}% | {best_s['capital_ann_pct']:+.1f}%{tag} | " + f"{best_s['win_rate']:.1f}% | {best_s['profit_factor']:.2f} |\n") + else: + f.write(f"| {q_name} | N/A | - | - | - | - | - |\n") + f.write("\n") + + # 算法总盈利排名 + f.write("## 八、算法全期间总收益排名\n\n") + algo_totals = [] + for algo_name, _ in ALGORITHMS: + s = results["全期间"].get(algo_name) + if s: + algo_totals.append((algo_name, s)) + algo_totals.sort(key=lambda x: x[1]['profit'], reverse=True) + + f.write("| 排名 | 算法 | 全期间盈利 | 真实收益 | 年化(CAGR) | 胜率 | 盈亏比 | 最大回撤 | 占用资金 |\n") + f.write("|------|------|----------|---------|-----------|------|--------|---------|--------|\n") + for rank, (algo_name, s) in enumerate(algo_totals, 1): + medal = "🏆" if rank == 1 else ("🥈" if rank == 2 else ("🥉" if rank == 3 else f"#{rank}")) + f.write(f"| {medal} | {algo_name} | {s['profit']:+,.0f} | {s['capital_pct']:+.1f}% | " + f"{s['capital_ann_pct']:+.1f}% | {s['win_rate']:.1f}% | {s['profit_factor']:.2f} | " + f"{s['max_drawdown_pct']:.1f}% | ¥{s['max_capital']:,.0f} |\n") + f.write("\n") + + # 分析结论 + f.write("## 九、分析结论\n\n") + if algo_totals: + best_name, best_s = algo_totals[0] + f.write(f"### 🏆 全期间最优算法: {best_name}\n\n") + f.write(f"- 总盈利: **¥{best_s['profit']:+,.0f}**\n") + f.write(f"- 真实收益率: **{best_s['capital_pct']:+.1f}%**\n") + f.write(f"- 年化收益率: **{best_s['capital_ann_pct']:+.1f}%**\n") + f.write(f"- 胜率: **{best_s['win_rate']:.1f}%**\n") + f.write(f"- 盈亏比: **{best_s['profit_factor']:.2f}**\n") + f.write(f"- 最大回撤: **{best_s['max_drawdown_pct']:.1f}%**\n") + f.write(f"- 最大占用资金: **¥{best_s['max_capital']:,.0f}**({best_s['max_capital']/CAPITAL*100:.0f}%本金利用率)\n") + f.write(f"\n### 回报对比\n\n") + f.write(f"| 投资方式 | 年化收益 | 20万本金一年收益 |\n") + f.write(f"|---------|---------|----------------|\n") + f.write(f"| 银行定存 | 2.5% | ¥5,000 |\n") + f.write(f"| 余额宝 | 1.8% | ¥3,600 |\n") + f.write(f"| **本算法** | **{best_s['capital_ann_pct']:+.1f}%** | **¥{best_s['profit']:+,.0f}**(实际) |\n") + f.write(f"\n") + + print(f"\n📝 结果已写入 {out_path}") + + +if __name__ == "__main__": + main() diff --git a/stock-html/run_backtest_scenarios.py b/stock-html/run_backtest_scenarios.py new file mode 100644 index 0000000..0818fc7 --- /dev/null +++ b/stock-html/run_backtest_scenarios.py @@ -0,0 +1,374 @@ +#!/usr/bin/env python3 +"""运行多组回测场景并输出对比表(v4.2 真实资金收益率版)。 +v3 基线 vs v4 优化 vs v4.2 延迟确认 全面对比。 +核心改进: 用真实占用资金(而非总周转金额)计算收益率和年化。""" +import sys +import os +import argparse +from datetime import date, datetime + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from backtest_recommend import ( + get_db_conn, get_codes_with_data, run_backtest, START_DATE, + MAX_POSITION_AMOUNT, MAX_CONCURRENT_POSITIONS, PRICE_MIN, PRICE_MAX, + SELL_COOLDOWN_DAYS, MAX_BUYS_PER_DAY, +) + + +def main(): + parser = argparse.ArgumentParser(description='多场景回测对比(v4.2 真实资金收益率)') + parser.add_argument('--start', type=str, default=None, metavar='YYYY-MM-DD', + help='回测起始日(默认 2026-01-02)') + parser.add_argument('-v', '--verbose', action='store_true', help='每个场景输出每日进度') + parser.add_argument('--quick', type=int, default=None, metavar='N', + help='仅运行前 N 个场景(快速验证)') + parser.add_argument('--v3-only', action='store_true', help='仅运行 v3 基线场景') + parser.add_argument('--v4-only', action='store_true', help='仅运行 v4/v4.2 优化场景') + args = parser.parse_args() + + start_date = START_DATE + if args.start: + try: + start_date = datetime.strptime(args.start, '%Y-%m-%d').date() + except ValueError: + print("错误: --start 格式应为 YYYY-MM-DD") + return + + conn = get_db_conn() + end = date.today() + try: + codes = get_codes_with_data(conn, end, min_days=30) + except Exception: + codes = [] + if not codes: + print("错误: 无 stock_kline_daily 数据") + conn.close() + return + + # ═══ v3 基线 ═══ + v3_scenarios = [ + ("v3|仅信号", {}), + ("v3|止盈10+损8", {"take_profit_pct": 10, "stop_loss_pct": 8}), + ] + + # ═══ v4 优化(上轮胜出) ═══ + v4_scenarios = [ + ("v4|触发≥2+止盈10+损8", { + "min_buy_triggered": 2, + "take_profit_pct": 10, "stop_loss_pct": 8, + }), + ("v4|触发≥2+跟踪6-3+损5", { + "min_buy_triggered": 2, + "trailing_start_pct": 6, "trailing_gap_pct": 3, + "stop_loss_pct": 5, + }), + ] + + # ═══ v4.1 忽略卖出信号(参考对照) ═══ + v41_scenarios = [ + ("v4.1|忽略卖出+止盈10+损8", { + "ignore_sell_signal": True, + "take_profit_pct": 10, "stop_loss_pct": 8, + }), + ("v4.1|忽略+跟踪8-3+损5+20天", { + "ignore_sell_signal": True, + "trailing_start_pct": 8, "trailing_gap_pct": 3, + "stop_loss_pct": 5, "max_hold_days": 20, + }), + ] + + # ═══ v4.2 延迟卖出确认(核心创新) ═══ + v42_scenarios = [ + # ── K: 延迟2天确认 + 各种组合 ── + ("v4.2-K1|延迟2天+止盈10+损8", { + "sell_confirm_days": 2, + "take_profit_pct": 10, "stop_loss_pct": 8, + }), + ("v4.2-K2|延迟2天+触发≥2+止盈10+损8", { + "sell_confirm_days": 2, + "min_buy_triggered": 2, + "take_profit_pct": 10, "stop_loss_pct": 8, + }), + ("v4.2-K3|延迟2天+跟踪8-3+损5", { + "sell_confirm_days": 2, + "trailing_start_pct": 8, "trailing_gap_pct": 3, + "stop_loss_pct": 5, + }), + ("v4.2-K4|延迟2天+跟踪6-3+损5", { + "sell_confirm_days": 2, + "trailing_start_pct": 6, "trailing_gap_pct": 3, + "stop_loss_pct": 5, + }), + ("v4.2-K5|延迟2天+触发≥2+跟踪8-3+损5", { + "sell_confirm_days": 2, + "min_buy_triggered": 2, + "trailing_start_pct": 8, "trailing_gap_pct": 3, + "stop_loss_pct": 5, + }), + ("v4.2-K6|延迟2天+触发≥2+跟踪6-3+损8", { + "sell_confirm_days": 2, + "min_buy_triggered": 2, + "trailing_start_pct": 6, "trailing_gap_pct": 3, + "stop_loss_pct": 8, + }), + + # ── L: 延迟3天确认 ── + ("v4.2-L1|延迟3天+止盈10+损8", { + "sell_confirm_days": 3, + "take_profit_pct": 10, "stop_loss_pct": 8, + }), + ("v4.2-L2|延迟3天+触发≥2+止盈10+损8", { + "sell_confirm_days": 3, + "min_buy_triggered": 2, + "take_profit_pct": 10, "stop_loss_pct": 8, + }), + ("v4.2-L3|延迟3天+跟踪8-3+损5", { + "sell_confirm_days": 3, + "trailing_start_pct": 8, "trailing_gap_pct": 3, + "stop_loss_pct": 5, + }), + ("v4.2-L4|延迟3天+触发≥2+跟踪6-3+损5", { + "sell_confirm_days": 3, + "min_buy_triggered": 2, + "trailing_start_pct": 6, "trailing_gap_pct": 3, + "stop_loss_pct": 5, + }), + + # ── M: 延迟确认 + 盈利保护 + 超时 ── + ("v4.2-M1|延迟2天+盈保5%+止盈10+损8", { + "sell_confirm_days": 2, + "profit_protect_pct": 5, + "take_profit_pct": 10, "stop_loss_pct": 8, + }), + ("v4.2-M2|延迟2天+盈保5%+触发≥2+止盈10+损8", { + "sell_confirm_days": 2, + "profit_protect_pct": 5, + "min_buy_triggered": 2, + "take_profit_pct": 10, "stop_loss_pct": 8, + }), + ("v4.2-M3|延迟3天+跟踪8-3+损5+30天", { + "sell_confirm_days": 3, + "trailing_start_pct": 8, "trailing_gap_pct": 3, + "stop_loss_pct": 5, "max_hold_days": 30, + }), + ("v4.2-M4|延迟2天+触发≥2+跟踪8-3+损5+30天", { + "sell_confirm_days": 2, + "min_buy_triggered": 2, + "trailing_start_pct": 8, "trailing_gap_pct": 3, + "stop_loss_pct": 5, "max_hold_days": 30, + }), + ] + + # 选择场景 + if args.v3_only: + scenarios = v3_scenarios + elif args.v4_only: + scenarios = v4_scenarios + v41_scenarios + v42_scenarios + else: + scenarios = v3_scenarios + v4_scenarios + v41_scenarios + v42_scenarios + + if args.quick is not None: + scenarios = scenarios[: args.quick] + total = len(scenarios) + + v3_count = len(v3_scenarios) if not args.v4_only else 0 + v4_count = len(v4_scenarios) if not args.v3_only else 0 + v41_count = len(v41_scenarios) if not args.v3_only else 0 + + print("=" * 150) + print(" 多场景回测对比 v4.2(真实资金收益率 + 延迟卖出确认)") + print("=" * 150) + print(f" 回测区间 : {start_date} ~ {end}") + print(f" 场景数 : {total}") + print(f" 股价区间 : {PRICE_MIN}~{PRICE_MAX} 元 | 单只上限 : ¥{MAX_POSITION_AMOUNT:,}") + print(f" 每日买入 : 最多 {MAX_BUYS_PER_DAY} 只 | 最大持仓 : {MAX_CONCURRENT_POSITIONS} 只") + print(f" 冷却期 : {SELL_COOLDOWN_DAYS} 天") + print(f" ⚠️ 本版使用【真实资金收益率】= 盈亏 / 最大同时占用资金") + print("=" * 150) + print() + + rows = [] + for k, (name, kwargs) in enumerate(scenarios, 1): + print(f"[进度] 场景 {k}/{total}: {name}", flush=True) + result = run_backtest( + conn, start_date=start_date, end_date=end, + verbose=args.verbose, **kwargs + ) + if not result or not result.get('stats'): + rows.append((name, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)) + continue + s = result['stats'] + rows.append(( + name, + s['profit'], # 1: 盈亏 + s.get('capital_pct', 0), # 2: 真实收益率 + s.get('capital_ann_pct', 0), # 3: 真实年化 + s['win_rate'], # 4: 胜率 + s['max_drawdown'], # 5: 最大回撤 + s.get('max_drawdown_pct', 0), # 6: 回撤% + s['avg_hold_days'], # 7: 平均持仓 + s['profit_factor'], # 8: 盈亏比 + s['trade_count'], # 9: 交易数 + s.get('max_capital', 0), # 10: 最大占用 + s['profit_pct'], # 11: 周转收益率(参考) + s['annualized_pct'], # 12: 周转年化(参考) + )) + mc = s.get('max_capital', 0) + cp = s.get('capital_pct', 0) + ca = s.get('capital_ann_pct', 0) + if not args.verbose: + print(f" → 盈亏 ¥{s['profit']:>+10,.0f} 资金占用 ¥{mc:>8,.0f} " + f"真实收益 {cp:>+6.1f}% 年化 {ca:>+6.1f}% " + f"胜率 {s['win_rate']:>5.1f}% 交易 {s['trade_count']} 笔", flush=True) + + conn.close() + + # 找最优(基于真实收益率) + if rows: + best_profit_idx = max(range(len(rows)), key=lambda i: rows[i][1]) + best_cap_idx = max(range(len(rows)), key=lambda i: rows[i][2]) + best_ann_idx = max(range(len(rows)), key=lambda i: rows[i][3]) + best_winrate_idx = max(range(len(rows)), key=lambda i: rows[i][4]) + min_dd_idx = min(range(len(rows)), key=lambda i: rows[i][5]) + best_pf_idx = max(range(len(rows)), key=lambda i: rows[i][8]) + else: + best_profit_idx = best_cap_idx = best_ann_idx = best_winrate_idx = min_dd_idx = best_pf_idx = -1 + + # 写入对比表 + out_path = os.path.join(os.path.dirname(__file__), "docs", "backtest_comparison.md") + os.makedirs(os.path.dirname(out_path), exist_ok=True) + with open(out_path, "w", encoding="utf-8") as f: + f.write("# 回测场景对比 v4.2(真实资金收益率版)\n\n") + f.write(f"回测区间: {start_date} ~ {end}\n\n") + f.write("> ⚠️ **真实收益率** = 盈亏 / 最大同时占用资金(非总周转金额)\n\n") + + f.write("## 对比结果\n\n") + f.write("| 场景 | 盈亏(元) | 占用资金 | 真实收益 | 真实年化 | 胜率 | 回撤% | 持仓天 | 盈亏比 | 交易 | 标注 |\n") + f.write("|------|---------|---------|---------|---------|------|-------|--------|--------|------|------|\n") + for idx, row_data in enumerate(rows): + name = row_data[0] + profit, cap_pct, cap_ann = row_data[1], row_data[2], row_data[3] + wr, dd, dd_pct = row_data[4], row_data[5], row_data[6] + hold, pf, n, mc = row_data[7], row_data[8], row_data[9], row_data[10] + tags = [] + if idx == best_profit_idx: + tags.append('🏆收益最高') + if idx == best_ann_idx and idx != best_profit_idx: + tags.append('📈年化最高') + if idx == best_cap_idx and idx != best_profit_idx and idx != best_ann_idx: + tags.append('💰资金效率') + if idx == best_winrate_idx: + tags.append('🎯胜率最高') + if idx == min_dd_idx: + tags.append('🛡️回撤最小') + if idx == best_pf_idx and idx != best_profit_idx: + tags.append('⚖️盈亏比最佳') + tag_str = ' '.join(tags) + f.write(f"| {name} | {profit:+,.0f} | ¥{mc:,.0f} | {cap_pct:+.1f}% | {cap_ann:+.1f}% | " + f"{wr:.1f}% | {dd_pct:.1f}% | {hold:.0f}天 | {pf:.2f} | {n} | {tag_str} |\n") + f.write("\n") + + # 加说明 + f.write("## 指标说明\n\n") + f.write("| 指标 | 说明 |\n") + f.write("|------|------|\n") + f.write("| 占用资金 | 回测期间最大同时持仓成本 |\n") + f.write("| 真实收益 | 盈亏 / 最大占用资金 × 100% |\n") + f.write("| 真实年化 | 按持续期折算年化(复利公式) |\n") + f.write("| 回撤% | 最大回撤 / 最大占用资金 × 100% |\n") + f.write("| 盈亏比 | 总盈利金额 / 总亏损金额 |\n") + f.write("| 延迟N天 | 连续N天推荐卖出才执行卖出 |\n") + f.write("\n") + + # 控制台表格 + print("\n" + "=" * 160) + print(" v4.2 整体对比表(★ 真实资金收益率 ★)") + print("=" * 160) + header = (f"{'场景':<42} {'盈亏(元)':>10} {'占用资金':>10} {'真实收益':>8} {'真实年化':>8} " + f"{'胜率':>6} {'回撤%':>7} {'持仓':>6} {'盈亏比':>6} {'交易':>5}") + print(header) + print("-" * 160) + + v3_end_idx = v3_count + v4_end_idx = v3_count + v4_count + v41_end_idx = v4_end_idx + v41_count + + for idx, row_data in enumerate(rows): + name = row_data[0] + profit, cap_pct, cap_ann = row_data[1], row_data[2], row_data[3] + wr, dd, dd_pct = row_data[4], row_data[5], row_data[6] + hold, pf, n, mc = row_data[7], row_data[8], row_data[9], row_data[10] + + tags = [] + if idx == best_profit_idx: + tags.append('🏆') + if idx == best_ann_idx and idx != best_profit_idx: + tags.append('📈') + if idx == best_cap_idx and idx != best_profit_idx and idx != best_ann_idx: + tags.append('💰') + if idx == best_winrate_idx: + tags.append('🎯') + if idx == min_dd_idx: + tags.append('🛡️') + if idx == best_pf_idx and idx != best_profit_idx: + tags.append('⚖️') + tag_str = ''.join(tags) + + # 分隔线 + if not args.v3_only and not args.v4_only: + if idx == v3_end_idx and v3_count > 0: + print("─" * 160) + print(f" {'↑ v3 基线 ↓ v4 优化':^148}") + print("─" * 160) + if idx == v4_end_idx and v4_count > 0: + print("─" * 160) + print(f" {'↑ v4 优化 ↓ v4.1 忽略卖出(参考对照)':^148}") + print("─" * 160) + if idx == v41_end_idx and v41_count > 0: + print("─" * 160) + print(f" {'↑ v4.1 参考 ↓ v4.2 延迟卖出确认(核心创新)':^148}") + print("─" * 160) + + print(f"{name:<42} {profit:>+10,.0f} {'¥'+str(int(mc)):>10} {cap_pct:>+7.1f}% {cap_ann:>+7.1f}% " + f"{wr:>5.1f}% {dd_pct:>6.1f}% {hold:>5.0f}天 {pf:>6.2f} {n:>5} {tag_str}") + + print("=" * 160) + + # 总结 + if rows and len(rows) > 1: + print("\n 📊 关键发现(★ 基于真实资金收益率 ★):") + if best_profit_idx >= 0: + r = rows[best_profit_idx] + print(f" 🏆 绝对收益最高: {r[0]} → ¥{r[1]:+,.0f} (真实{r[2]:+.1f}%, 年化{r[3]:+.1f}%)") + if best_ann_idx >= 0 and best_ann_idx != best_profit_idx: + r = rows[best_ann_idx] + print(f" 📈 年化最高: {r[0]} → 真实年化 {r[3]:+.1f}% (占用 ¥{r[10]:,.0f})") + if best_cap_idx >= 0 and best_cap_idx not in (best_profit_idx, best_ann_idx): + r = rows[best_cap_idx] + print(f" 💰 资金效率最高: {r[0]} → 真实收益 {r[2]:+.1f}% (占用 ¥{r[10]:,.0f})") + if best_winrate_idx >= 0: + r = rows[best_winrate_idx] + print(f" 🎯 胜率最高: {r[0]} → {r[4]:.1f}%") + if best_pf_idx >= 0: + r = rows[best_pf_idx] + print(f" ⚖️ 盈亏比最佳: {r[0]} → {r[8]:.2f}") + if min_dd_idx >= 0: + r = rows[min_dd_idx] + print(f" 🛡️ 回撤最小: {r[0]} → {r[6]:.1f}%") + + # 银行对比 + print("\n 🏦 银行存款利率对比(年化2.5%):") + for idx, r in enumerate(rows): + ann = r[3] + if ann > 2.5: + icon = '✅' + else: + icon = '❌' + print(f" {icon} {r[0]:<42} 年化 {ann:>+6.1f}% {'超过银行' if ann > 2.5 else '低于银行'}") + + print(f"\n场景对比已写入 {out_path}") + + +if __name__ == "__main__": + main() diff --git a/stock-html/run_backtest_top3.py b/stock-html/run_backtest_top3.py new file mode 100644 index 0000000..c503392 --- /dev/null +++ b/stock-html/run_backtest_top3.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +""" +Top 3 最挣钱算法回测 v5(使用5分钟K线实时价格版) + +对比三组数据: + 1. 原始版(日线close/open) - 作为基准 + 2. 5分钟实时价格版 - 使用stock_kline_5min的10:00买入价、15:00卖出价 + 3. Mid价格版 - 使用(open+close)/2作为替代 + +Top 3 算法: + 🏆 v4|触发≥2+止盈10+损8 → +53,100 (33.1%, 年化28.3%) + 🥈 v3|止盈10+损8 → +49,500 (28.4%, 年化24.4%) + 🥉 v4.2-K1|延迟2天+止盈10+损8 → +48,920 (28.1%, 年化24.1%) +""" +import sys +import os +import time +from datetime import date, datetime + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from backtest_recommend import ( + get_db_conn, get_codes_with_data, run_backtest, START_DATE, + MAX_POSITION_AMOUNT, MAX_CONCURRENT_POSITIONS, PRICE_MIN, PRICE_MAX, + SELL_COOLDOWN_DAYS, MAX_BUYS_PER_DAY, +) + + +def main(): + import argparse + parser = argparse.ArgumentParser(description='Top 3 算法回测') + parser.add_argument('--start', type=str, default=None, help='回测起始日 YYYY-MM-DD(默认取扫描历史最早日期)') + parser.add_argument('--end', type=str, default=None, help='回测结束日 YYYY-MM-DD(默认今天)') + args = parser.parse_args() + + conn = get_db_conn() + end = date.today() + if args.end: + end = datetime.strptime(args.end, '%Y-%m-%d').date() + + # 使用完整扫描数据期间(而非 START_DATE 的短期) + with conn.cursor() as cur: + cur.execute("SELECT min(scan_date) FROM stock_scan_history") + first_scan = cur.fetchone()[0] + + if args.start: + start = datetime.strptime(args.start, '%Y-%m-%d').date() + print(f"📅 回测起始: {start}(用户指定)") + else: + start = first_scan if first_scan else START_DATE + print(f"📅 回测起始: {start}(扫描历史最早日期)") + + # 检查5分钟K线数据覆盖 + with conn.cursor() as cur: + cur.execute(""" + SELECT count(*), count(DISTINCT code), count(DISTINCT dt::date), + min(dt::date), max(dt::date) + FROM stock_kline_5min + """) + cnt, codes, days, min_d, max_d = cur.fetchone() + print(f"📊 stock_kline_5min 数据: {cnt:,}条 | {codes}只股票 | {days}天 | {min_d}~{max_d}") + + cur.execute("SELECT count(DISTINCT scan_date) FROM stock_scan_history WHERE scan_date >= %s", (start,)) + scan_days = cur.fetchone()[0] + print(f"📊 stock_scan_history: {scan_days}天扫描数据") + + cur.execute("SELECT count(DISTINCT trade_date) FROM stock_kline_daily WHERE trade_date >= %s", (start,)) + kline_days = cur.fetchone()[0] + print(f"📊 stock_kline_daily: {kline_days}天K线数据") + + # Top 3 算法配置 + top3_algos = [ + ("🏆 v4|触发≥2+止盈10+损8", { + "min_buy_triggered": 2, + "take_profit_pct": 10, "stop_loss_pct": 8, + }), + ("🥈 v3|止盈10+损8", { + "take_profit_pct": 10, "stop_loss_pct": 8, + }), + ("🥉 v4.2-K1|延迟2天+止盈10+损8", { + "sell_confirm_days": 2, + "take_profit_pct": 10, "stop_loss_pct": 8, + }), + ] + + # 3 种定价模式 + price_modes = [ + ("日线(原版)", False, '10:00', '15:00'), # 用daily open/close + ("5分钟最优时点", True, '09:35', '13:40'), # v7: 最优时点 + ("5分钟旧时点", True, '10:00', '15:00'), # v5: 旧默认时点 (对比用) + ] + + print("\n" + "=" * 140) + print(" Top 3 最挣钱算法 × 3种定价模式 对比回测 v7") + print("=" * 140) + print(f" 回测区间: {start} ~ {end}") + print(f" 定价说明:") + print(f" 日线(原版): 买入用开盘价, 卖出用收盘价") + print(f" 5分钟最优时点: 买入用09:35实时价, 卖出用13:40实时价 (v7网格搜索最优)") + print(f" 5分钟旧时点: 买入用10:00实时价, 卖出用15:00实时价 (v5旧默认)") + print(f" 股价区间: {PRICE_MIN}~{PRICE_MAX} 元 | 每笔1000股 | 每日最多买{MAX_BUYS_PER_DAY}只") + print("=" * 140) + print() + + rows = [] + total_scenarios = len(top3_algos) * len(price_modes) + idx = 0 + + for algo_name, algo_params in top3_algos: + for mode_name, use_5min, bt, st in price_modes: + idx += 1 + scenario_name = f"{algo_name} | {mode_name}" + print(f"[{idx}/{total_scenarios}] {scenario_name}", flush=True) + + t0 = time.time() + result = run_backtest( + conn, start_date=start, end_date=end, + use_5min_prices=use_5min, + buy_time=bt, sell_time=st, + verbose=False, **algo_params + ) + elapsed = time.time() - t0 + + if not result or not result.get('stats'): + print(f" ❌ 无结果") + rows.append((scenario_name, algo_name, mode_name, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)) + continue + + s = result['stats'] + row = ( + scenario_name, + algo_name, + mode_name, + s['profit'], # 3: 盈亏 + s.get('capital_pct', 0), # 4: 真实收益率 + s.get('capital_ann_pct', 0), # 5: 真实年化 + s['win_rate'], # 6: 胜率 + s.get('max_drawdown_pct', 0), # 7: 回撤% + s['avg_hold_days'], # 8: 持仓天 + s['profit_factor'], # 9: 盈亏比 + s['trade_count'], # 10: 交易数 + s.get('max_capital', 0), # 11: 最大占用 + s.get('5min_hit', 0), # 12: 5min命中 + s.get('5min_miss', 0), # 13: 5min缺失 + s.get('5min_coverage', 0), # 14: 5min覆盖率 + ) + rows.append(row) + mc = s.get('max_capital', 0) + cp = s.get('capital_pct', 0) + ca = s.get('capital_ann_pct', 0) + cov = s.get('5min_coverage', 0) + print(f" → ¥{s['profit']:>+10,.0f} 占用¥{mc:>8,.0f} " + f"真实{cp:>+6.1f}% 年化{ca:>+6.1f}% " + f"胜率{s['win_rate']:>5.1f}% " + f"{'5min覆盖' + str(cov) + '%' if use_5min else '日线'} " + f"({elapsed:.1f}s)", flush=True) + + conn.close() + + # ═══ 输出对比表 ═══ + print("\n\n" + "=" * 160) + print(" 📊 Top 3 算法 × 定价模式 完整对比表") + print("=" * 160) + header = (f"{'场景':<55} {'盈亏(元)':>10} {'占用资金':>10} {'真实收益':>8} {'真实年化':>8} " + f"{'胜率':>6} {'回撤%':>7} {'持仓':>5} {'盈亏比':>6} {'交易':>5} {'5min':>6}") + print(header) + print("-" * 160) + + prev_algo = None + for row in rows: + name = row[0] + algo = row[1] + mode = row[2] + profit, cp, ca = row[3], row[4], row[5] + wr, dd = row[6], row[7] + hold, pf, n, mc = row[8], row[9], row[10], row[11] + cov = row[14] + + if prev_algo and prev_algo != algo: + print("-" * 160) + prev_algo = algo + + cov_str = f"{cov:.0f}%" if cov > 0 else "日线" + print(f"{name:<55} {profit:>+10,.0f} {'¥'+str(int(mc)):>10} {cp:>+7.1f}% {ca:>+7.1f}% " + f"{wr:>5.1f}% {dd:>6.1f}% {hold:>4.0f}天 {pf:>6.2f} {n:>5} {cov_str:>6}") + + print("=" * 160) + + # ═══ 算法级汇总 ═══ + print("\n 📊 按算法汇总:") + for algo_name, _ in top3_algos: + algo_rows = [r for r in rows if r[1] == algo_name] + if len(algo_rows) >= 2: + baseline = algo_rows[0] # 日线版 + realtime = algo_rows[1] # 5分钟版 + + diff_profit = realtime[3] - baseline[3] + diff_ann = realtime[5] - baseline[5] + diff_wr = realtime[6] - baseline[6] + + print(f"\n {algo_name}:") + print(f" 日线(原版) : 盈亏 ¥{baseline[3]:>+10,.0f} 年化 {baseline[5]:>+6.1f}% 胜率 {baseline[6]:.1f}%") + print(f" 5分钟实时 : 盈亏 ¥{realtime[3]:>+10,.0f} 年化 {realtime[5]:>+6.1f}% 胜率 {realtime[6]:.1f}% " + f"(5min覆盖{realtime[14]:.0f}%)") + icon = '📈' if diff_profit > 0 else ('📉' if diff_profit < 0 else '➖') + print(f" {icon} 差异: 盈亏{diff_profit:>+,.0f} 年化{diff_ann:>+.1f}% 胜率{diff_wr:>+.1f}%") + + # ═══ 写入文件 ═══ + out_dir = os.path.join(os.path.dirname(__file__), 'docs') + os.makedirs(out_dir, exist_ok=True) + out_path = os.path.join(out_dir, 'backtest_top3_5min.md') + with open(out_path, 'w', encoding='utf-8') as f: + f.write("# Top 3 算法 × 5分钟实时价格 回测对比\n\n") + f.write(f"回测区间: {start} ~ {end}\n\n") + f.write("## 定价模式\n\n") + f.write("| 模式 | 买入价 | 卖出价 | 说明 |\n") + f.write("|------|--------|--------|------|\n") + f.write("| 日线(原版) | 当日开盘价 | 当日收盘价 | 原始基准 |\n") + f.write("| 5分钟实时 | 10:00 5min收盘 | 15:00 5min收盘 | 有5min数据用5min, 无则用mid=(开盘+收盘)/2 |\n\n") + f.write("## 对比结果\n\n") + f.write("| 算法 | 定价 | 盈亏(元) | 占用资金 | 真实收益 | 真实年化 | 胜率 | 回撤% | 盈亏比 | 交易 | 5min覆盖 |\n") + f.write("|------|------|---------|---------|---------|---------|------|-------|--------|------|----------|\n") + for row in rows: + name, algo, mode = row[0], row[1], row[2] + profit, cp, ca = row[3], row[4], row[5] + wr, dd = row[6], row[7] + hold, pf, n, mc = row[8], row[9], row[10], row[11] + cov = row[14] + cov_str = f"{cov:.0f}%" if cov > 0 else "-" + f.write(f"| {algo} | {mode} | {profit:+,.0f} | ¥{mc:,.0f} | {cp:+.1f}% | {ca:+.1f}% | " + f"{wr:.1f}% | {dd:.1f}% | {pf:.2f} | {n} | {cov_str} |\n") + f.write("\n") + + print(f"\n结果已写入 {out_path}") + + +if __name__ == "__main__": + main() diff --git a/stock-html/run_timing_search.py b/stock-html/run_timing_search.py new file mode 100644 index 0000000..274c486 --- /dev/null +++ b/stock-html/run_timing_search.py @@ -0,0 +1,373 @@ +#!/usr/bin/env python3 +""" +v7.0 交易时点网格搜索 — 寻找最优买入/卖出时间点 + +在48×48=2,304种时间点组合中搜索最佳买卖时机: + 买入时间: 09:35, 09:40, ..., 11:30, 13:05, ..., 15:00 + 卖出时间: 09:35, 09:40, ..., 11:30, 13:05, ..., 15:00 + +使用Top 3历史最优算法 × 所有时间点组合,共 ~7,000 种回测。 +""" +import sys, os, time, argparse +from datetime import date, datetime +from multiprocessing import Pool, cpu_count +from itertools import product + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from backtest_recommend import ( + get_db_conn, preload_all_data, run_backtest +) + +# ─── A股5分钟K线时间点 (48个) ──────────────────── +ALL_5MIN_SLOTS = [] +# 上午: 09:35 ~ 11:30 +for h in range(9, 12): + for m in range(0, 60, 5): + t = f"{h:02d}:{m:02d}" + if t >= "09:35" and t <= "11:30": + ALL_5MIN_SLOTS.append(t) +# 下午: 13:05 ~ 15:00 +for h in range(13, 16): + for m in range(0, 60, 5): + t = f"{h:02d}:{m:02d}" + if t >= "13:05" and t <= "15:00": + ALL_5MIN_SLOTS.append(t) + +# 时间点信息(在main中打印,避免worker进程重复输出) + +# ─── Top 3 算法 (来自 algo_search_results.md) ──────────── +TOP_ALGORITHMS = [ + ("🏆TP12|SL6|d3|h30|10%SW", { + "take_profit_pct": 12, "stop_loss_pct": 6, + "sell_confirm_days": 3, "max_hold_days": 30, + "position_pct": 10, "signal_weight": True, + "ignore_sell_signal": True, + }), + ("🥈TP12|SL6|d3|h30|15%SW", { + "take_profit_pct": 12, "stop_loss_pct": 6, + "sell_confirm_days": 3, "max_hold_days": 30, + "position_pct": 15, "signal_weight": True, + "ignore_sell_signal": True, + }), + ("🥉TP10|SL8|ign|15%SW", { + "take_profit_pct": 10, "stop_loss_pct": 8, + "ignore_sell_signal": True, + "position_pct": 15, "signal_weight": True, + }), +] + +# ─── 全局变量(multiprocessing共享)───────────────── +_preloaded_data = None + + +def init_worker(preloaded): + """每个worker进程初始化时加载预加载数据""" + global _preloaded_data + _preloaded_data = preloaded + + +def run_single(args): + """运行单次回测(供multiprocessing调用)""" + algo_name, algo_params, buy_time, sell_time, start, end, total_capital = args + try: + result = run_backtest( + conn=None, + start_date=start, + end_date=end, + preloaded=_preloaded_data, + use_5min_prices=True, + total_capital=total_capital, + buy_time=buy_time, + sell_time=sell_time, + **algo_params, + ) + if result and result.get('stats'): + s = result['stats'] + return { + 'algo': algo_name, + 'buy_time': buy_time, + 'sell_time': sell_time, + 'profit': s.get('profit', 0), + 'capital_pct': s.get('capital_pct', 0), + 'capital_ann': s.get('capital_ann_pct', 0), + 'win_rate': s.get('win_rate', 0), + 'max_drawdown_pct': s.get('max_drawdown_pct', 0), + 'profit_loss_ratio': s.get('profit_loss_ratio', 0), + 'trade_count': s.get('trade_count', 0), + 'coverage': s.get('5min_coverage', 0), + } + except Exception as e: + pass + return None + + +def main(): + parser = argparse.ArgumentParser(description="v7.0 交易时点网格搜索") + parser.add_argument('--capital', type=float, default=200000, help='总本金 (默认200000)') + parser.add_argument('--start', type=str, default=None, help='起始日 YYYY-MM-DD (默认=5min数据起始)') + parser.add_argument('--end', type=str, default=None, help='结束日 YYYY-MM-DD') + parser.add_argument('--fast', action='store_true', help='快速模式: 仅测试9个代表性时间点') + parser.add_argument('--workers', type=int, default=0, help=f'并行进程数 (默认={cpu_count()})') + args = parser.parse_args() + + total_capital = args.capital + n_workers = args.workers or cpu_count() + + # ── 连接数据库 & 确定回测区间 ── + conn = get_db_conn() + cur = conn.cursor() + + # 5分钟数据的实际覆盖范围 + cur.execute("SELECT MIN(dt::date), MAX(dt::date), COUNT(DISTINCT dt::date) FROM stock_kline_5min") + r = cur.fetchone() + min_5min_date, max_5min_date, n_5min_days = r + print(f"\n[数据] 5分钟K线: {min_5min_date} ~ {max_5min_date} ({n_5min_days}个交易日)") + + start_date = datetime.strptime(args.start, '%Y-%m-%d').date() if args.start else min_5min_date + end_date = datetime.strptime(args.end, '%Y-%m-%d').date() if args.end else date.today() + + print(f"[回测] 区间: {start_date} ~ {end_date}") + print(f"[回测] 本金: ¥{total_capital:,.0f}") + print(f"[回测] 算法: {len(TOP_ALGORITHMS)} 种") + + # ── 时间点选择 ── + if args.fast: + # 快速模式: 9个代表性时间点 + time_slots = ['09:35', '09:45', '10:00', '10:30', '11:00', + '13:05', '13:30', '14:00', '14:30', '15:00'] + time_slots = [t for t in time_slots if t in ALL_5MIN_SLOTS] + else: + time_slots = ALL_5MIN_SLOTS + + n_combos = len(time_slots) ** 2 + n_total = n_combos * len(TOP_ALGORITHMS) + print(f"[搜索] 时间点: {len(time_slots)} 个 → {n_combos:,} 种组合 × {len(TOP_ALGORITHMS)} 算法 = {n_total:,} 次回测") + print(f"[搜索] 并行进程: {n_workers}") + + # ── 预加载全部数据(含全部48个5分钟时间点)── + print(f"\n{'='*60}") + print(" 预加载数据...") + print(f"{'='*60}") + preloaded = preload_all_data(conn, start_date, end_date, use_5min=True, full_5min=True) + conn.close() + + # ── 构建任务列表 ── + tasks = [] + for algo_name, algo_params in TOP_ALGORITHMS: + for buy_t in time_slots: + for sell_t in time_slots: + tasks.append((algo_name, algo_params, buy_t, sell_t, + start_date, end_date, total_capital)) + + # ── 并行执行 ── + print(f"\n开始搜索 ({n_total:,} 次回测)...") + t0 = time.time() + + results = [] + with Pool(n_workers, initializer=init_worker, initargs=(preloaded,)) as pool: + for i, r in enumerate(pool.imap_unordered(run_single, tasks, chunksize=50)): + if r: + results.append(r) + if (i + 1) % 500 == 0: + elapsed = time.time() - t0 + speed = (i + 1) / elapsed + eta = (n_total - i - 1) / speed + print(f" 进度: {i+1}/{n_total} ({(i+1)/n_total*100:.1f}%) | " + f"速度: {speed:.0f}/s | ETA: {eta:.0f}s | " + f"有效结果: {len(results)}", flush=True) + + elapsed = time.time() - t0 + print(f"\n搜索完成! {len(results):,} 个有效结果, 耗时 {elapsed:.1f}s ({len(results)/elapsed:.1f}次/s)") + + if not results: + print("⚠️ 没有有效结果!") + return + + # ── 分析结果 ── + print(f"\n{'='*100}") + print(" 📊 分析结果") + print(f"{'='*100}") + + # 1. 按盈利排序 - 全局Top 20 + results.sort(key=lambda x: -x['profit']) + print(f"\n## 🏆 全局 Top 20 (按绝对盈利)") + print(f"{'排名':<4} {'算法':<25} {'买入时间':<8} {'卖出时间':<8} {'盈亏':>10} {'收益%':>7} {'年化%':>7} {'胜率':>6} {'回撤%':>6} {'交易':>5} {'5min%':>5}") + print("-" * 100) + for i, r in enumerate(results[:20]): + print(f"{'🏆' if i==0 else '🥈' if i==1 else '🥉' if i==2 else f'#{i+1}':<4} " + f"{r['algo']:<25} {r['buy_time']:<8} {r['sell_time']:<8} " + f"¥{r['profit']:>+9,.0f} {r['capital_pct']:>+6.1f}% {r['capital_ann']:>+6.1f}% " + f"{r['win_rate']:>5.1f}% {r['max_drawdown_pct']:>5.1f}% {r['trade_count']:>5} {r['coverage']:>4.0f}%") + + # 2. 按算法分组 - 每个算法的最优时间点 + print(f"\n## 📊 每个算法的最优时间点") + for algo_name, _ in TOP_ALGORITHMS: + algo_results = [r for r in results if r['algo'] == algo_name] + if not algo_results: + continue + algo_results.sort(key=lambda x: -x['profit']) + best = algo_results[0] + worst = algo_results[-1] + default = next((r for r in algo_results if r['buy_time'] == '10:00' and r['sell_time'] == '15:00'), None) + + print(f"\n {algo_name}:") + print(f" 最优: 买@{best['buy_time']} 卖@{best['sell_time']} → ¥{best['profit']:>+,.0f} ({best['capital_pct']:>+.1f}%)") + if default: + diff = best['profit'] - default['profit'] + print(f" 默认: 买@10:00 卖@15:00 → ¥{default['profit']:>+,.0f} ({default['capital_pct']:>+.1f}%)") + print(f" 提升: ¥{diff:>+,.0f} ({diff/max(abs(default['profit']),1)*100:>+.1f}%)") + print(f" 最差: 买@{worst['buy_time']} 卖@{worst['sell_time']} → ¥{worst['profit']:>+,.0f} ({worst['capital_pct']:>+.1f}%)") + print(f" 差距: ¥{best['profit'] - worst['profit']:>,.0f}") + + # 3. 买入时间热力图 (每个buy_time的平均盈利) + print(f"\n## 📈 买入时间热力图 (固定卖出@15:00)") + buy_time_profits = {} + for r in results: + if r['sell_time'] == '15:00': + bt = r['buy_time'] + if bt not in buy_time_profits: + buy_time_profits[bt] = [] + buy_time_profits[bt].append(r['profit']) + + if buy_time_profits: + sorted_buy = sorted(buy_time_profits.items(), key=lambda x: -sum(x[1])/len(x[1])) + print(f" {'时间':<8} {'平均盈利':>10} {'最高盈利':>10} {'最低盈利':>10}") + print(f" {'-'*45}") + for bt, profits in sorted_buy: + avg = sum(profits) / len(profits) + print(f" {bt:<8} ¥{avg:>+9,.0f} ¥{max(profits):>+9,.0f} ¥{min(profits):>+9,.0f}") + + # 4. 卖出时间热力图 (每个sell_time的平均盈利) + print(f"\n## 📉 卖出时间热力图 (固定买入@10:00)") + sell_time_profits = {} + for r in results: + if r['buy_time'] == '10:00': + st = r['sell_time'] + if st not in sell_time_profits: + sell_time_profits[st] = [] + sell_time_profits[st].append(r['profit']) + + if sell_time_profits: + sorted_sell = sorted(sell_time_profits.items(), key=lambda x: -sum(x[1])/len(x[1])) + print(f" {'时间':<8} {'平均盈利':>10} {'最高盈利':>10} {'最低盈利':>10}") + print(f" {'-'*45}") + for st, profits in sorted_sell: + avg = sum(profits) / len(profits) + print(f" {st:<8} ¥{avg:>+9,.0f} ¥{max(profits):>+9,.0f} ¥{min(profits):>+9,.0f}") + + # 5. 买卖时间交叉分析 (平均盈利矩阵的摘要) + print(f"\n## 🔥 最优买卖时间组合 Top 10 (所有算法平均)") + combo_profits = {} + for r in results: + key = (r['buy_time'], r['sell_time']) + if key not in combo_profits: + combo_profits[key] = [] + combo_profits[key].append(r['profit']) + + sorted_combos = sorted(combo_profits.items(), key=lambda x: -sum(x[1])/len(x[1])) + print(f" {'排名':<4} {'买入':<8} {'卖出':<8} {'平均盈利':>10} {'组合数':>6}") + print(f" {'-'*42}") + for i, (combo, profits) in enumerate(sorted_combos[:10]): + avg = sum(profits) / len(profits) + print(f" {'🏆' if i==0 else f'#{i+1}':<4} {combo[0]:<8} {combo[1]:<8} ¥{avg:>+9,.0f} {len(profits):>6}") + + print(f"\n 最差组合:") + for i, (combo, profits) in enumerate(sorted_combos[-5:]): + avg = sum(profits) / len(profits) + print(f" {'#'+str(len(sorted_combos)-4+i):<4} {combo[0]:<8} {combo[1]:<8} ¥{avg:>+9,.0f} {len(profits):>6}") + + # ── 生成Markdown报告 ── + md_path = os.path.join(os.path.dirname(__file__), 'docs', 'timing_search_results.md') + os.makedirs(os.path.dirname(md_path), exist_ok=True) + + with open(md_path, 'w') as f: + f.write(f"# ⏰ v7.0 交易时点网格搜索结果\n\n") + f.write(f"> 生成时间: {datetime.now():%Y-%m-%d %H:%M}\n\n") + + f.write(f"## 搜索配置\n\n") + f.write(f"| 项目 | 值 |\n|------|----|") + f.write(f"\n| 本金 | ¥{total_capital:,.0f} |") + f.write(f"\n| 回测区间 | {start_date} ~ {end_date} |") + f.write(f"\n| 5分钟数据 | {min_5min_date} ~ {max_5min_date} ({n_5min_days}天) |") + f.write(f"\n| 时间点 | {len(time_slots)} 个 |") + f.write(f"\n| 组合数 | {n_combos:,} × {len(TOP_ALGORITHMS)} 算法 = {n_total:,} |") + f.write(f"\n| 耗时 | {elapsed:.1f}s ({len(results)/elapsed:.1f}次/s) |") + f.write(f"\n| 有效结果 | {len(results):,} |") + f.write(f"\n\n") + + # Top 20 + f.write(f"## 🏆 全局 Top 20\n\n") + f.write(f"| 排名 | 算法 | 买入 | 卖出 | 盈亏 | 收益% | 年化% | 胜率 | 回撤% | 交易 | 5min% |\n") + f.write(f"|------|------|------|------|------|-------|-------|------|-------|------|-------|\n") + for i, r in enumerate(results[:20]): + rank = '🏆' if i==0 else '🥈' if i==1 else '🥉' if i==2 else f'#{i+1}' + f.write(f"| {rank} | {r['algo']} | {r['buy_time']} | {r['sell_time']} | " + f"¥{r['profit']:>+,.0f} | {r['capital_pct']:>+.1f}% | {r['capital_ann']:>+.1f}% | " + f"{r['win_rate']:.1f}% | {r['max_drawdown_pct']:.1f}% | {r['trade_count']} | {r['coverage']:.0f}% |\n") + + # 每算法最优 + f.write(f"\n## 📊 每算法最优时间点\n\n") + f.write(f"| 算法 | 最优买入 | 最优卖出 | 最优盈利 | 默认盈利(10:00/15:00) | 提升 |\n") + f.write(f"|------|---------|---------|---------|---------------------|------|\n") + for algo_name, _ in TOP_ALGORITHMS: + algo_res = sorted([r for r in results if r['algo'] == algo_name], key=lambda x: -x['profit']) + if not algo_res: + continue + best = algo_res[0] + default = next((r for r in algo_res if r['buy_time'] == '10:00' and r['sell_time'] == '15:00'), None) + default_profit = default['profit'] if default else 0 + diff = best['profit'] - default_profit + f.write(f"| {algo_name} | {best['buy_time']} | {best['sell_time']} | " + f"¥{best['profit']:>+,.0f} | ¥{default_profit:>+,.0f} | ¥{diff:>+,.0f} |\n") + + # 买入时间排名 (卖出固定15:00) + f.write(f"\n## 📈 买入时间排名 (卖出固定@15:00)\n\n") + f.write(f"| 排名 | 买入时间 | 平均盈利 | 最高盈利 | 最低盈利 |\n") + f.write(f"|------|---------|---------|---------|----------|\n") + if buy_time_profits: + for i, (bt, profits) in enumerate(sorted_buy): + avg = sum(profits) / len(profits) + rank = '🏆' if i==0 else f'#{i+1}' + f.write(f"| {rank} | {bt} | ¥{avg:>+,.0f} | ¥{max(profits):>+,.0f} | ¥{min(profits):>+,.0f} |\n") + + # 卖出时间排名 (买入固定10:00) + f.write(f"\n## 📉 卖出时间排名 (买入固定@10:00)\n\n") + f.write(f"| 排名 | 卖出时间 | 平均盈利 | 最高盈利 | 最低盈利 |\n") + f.write(f"|------|---------|---------|---------|----------|\n") + if sell_time_profits: + for i, (st, profits) in enumerate(sorted_sell): + avg = sum(profits) / len(profits) + rank = '🏆' if i==0 else f'#{i+1}' + f.write(f"| {rank} | {st} | ¥{avg:>+,.0f} | ¥{max(profits):>+,.0f} | ¥{min(profits):>+,.0f} |\n") + + # 最优组合 Top 10 + f.write(f"\n## 🔥 最优买卖时间组合 Top 10\n\n") + f.write(f"| 排名 | 买入 | 卖出 | 平均盈利 |\n") + f.write(f"|------|------|------|----------|\n") + for i, (combo, profits) in enumerate(sorted_combos[:10]): + avg = sum(profits) / len(profits) + rank = '🏆' if i==0 else f'#{i+1}' + f.write(f"| {rank} | {combo[0]} | {combo[1]} | ¥{avg:>+,.0f} |\n") + + # 结论 + f.write(f"\n## 💡 结论\n\n") + if results: + best_overall = results[0] + default_results = [r for r in results if r['buy_time'] == '10:00' and r['sell_time'] == '15:00'] + default_avg = sum(r['profit'] for r in default_results) / len(default_results) if default_results else 0 + best_avg_combo = sorted_combos[0] if sorted_combos else None + + f.write(f"1. **全局最优**: {best_overall['algo']} 买@{best_overall['buy_time']} 卖@{best_overall['sell_time']} → ¥{best_overall['profit']:>+,.0f}\n") + f.write(f"2. **默认(10:00/15:00)平均盈利**: ¥{default_avg:>+,.0f}\n") + if best_avg_combo: + avg = sum(best_avg_combo[1]) / len(best_avg_combo[1]) + f.write(f"3. **最优时间组合(跨算法平均)**: 买@{best_avg_combo[0][0]} 卖@{best_avg_combo[0][1]} → 平均¥{avg:>+,.0f}\n") + f.write(f"4. **时点优化潜在提升**: ¥{avg - default_avg:>+,.0f}\n") + + print(f"\n📄 报告已保存: {md_path}") + print("完成!") + + +if __name__ == '__main__': + main() diff --git a/stock-html/run_v6_timing_test.py b/stock-html/run_v6_timing_test.py new file mode 100644 index 0000000..1aef900 --- /dev/null +++ b/stock-html/run_v6_timing_test.py @@ -0,0 +1,420 @@ +#!/usr/bin/env python3 +""" +v6 算法 新时间点 (09:35/13:40) vs 旧时间点 (10:00/15:00) 对比测试 +基于 docs/backtest_v6_analysis_report.md 中的全部算法 +""" + +import sys, os +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import psycopg2 +from datetime import date, datetime +import time + +from backtest_recommend import run_backtest, preload_all_data + +# ─── 配置 ─────────────────────────────────────────────── +DB_NAME = 'stock_app' +TOTAL_CAPITAL = 200000 +START_DATE = date(2025, 1, 2) +END_DATE = date(2026, 2, 25) + +# 时间点配置 +TIMING_CONFIGS = [ + ('旧时点(10:00/15:00)', '10:00', '15:00'), + ('新时点(09:35/13:40)', '09:35', '13:40'), +] + +# ─── v6 报告中的全部算法 ───────────────────────────────── +ALGORITHMS = { + # === 绝对盈利 Top 5 === + '🏆base|TP12/SL6|d3|h30|10%|SW': { + 'take_profit_pct': 12, 'stop_loss_pct': 6, + 'sell_confirm_days': 3, 'max_hold_days': 30, + 'total_capital': TOTAL_CAPITAL, 'position_pct': 10, 'signal_weight': True, + 'use_5min_prices': True, + }, + '🥈base|TP12/SL8|d3|h∞|8%|SW': { + 'take_profit_pct': 12, 'stop_loss_pct': 8, + 'sell_confirm_days': 3, 'max_hold_days': 0, + 'total_capital': TOTAL_CAPITAL, 'position_pct': 8, 'signal_weight': True, + 'use_5min_prices': True, + }, + '🥉v6|MT3G3+BE8|TP10/SL8|ign|h∞|10%': { + 'take_profit_pct': 10, 'stop_loss_pct': 8, + 'ignore_sell_signal': True, 'max_hold_days': 0, + 'total_capital': TOTAL_CAPITAL, 'position_pct': 10, 'signal_weight': True, + 'use_5min_prices': True, + # v6 特性 + 'momentum_tp': True, 'momentum_days': 3, 'momentum_trail_gap': 3, + 'breakeven_at': 8, + }, + '4.v6|MT3G4|TP10/SL8|ign|h∞|8%': { + 'take_profit_pct': 10, 'stop_loss_pct': 8, + 'ignore_sell_signal': True, 'max_hold_days': 0, + 'total_capital': TOTAL_CAPITAL, 'position_pct': 8, 'signal_weight': True, + 'use_5min_prices': True, + # v6 特性 + 'momentum_tp': True, 'momentum_days': 3, 'momentum_trail_gap': 4, + }, + '5.v6|PE50G3|TP10/SL6|ign|h60|10%': { + 'take_profit_pct': 10, 'stop_loss_pct': 6, + 'ignore_sell_signal': True, 'max_hold_days': 60, + 'total_capital': TOTAL_CAPITAL, 'position_pct': 10, 'signal_weight': True, + 'use_5min_prices': True, + # v6 特性 + 'partial_exit_pct': 50, 'momentum_trail_gap': 3, + 'no_timeout_if_rising': True, + }, + + # === 风险调整 Top 4 === + 'Calmar🏆v6|MT3G3+BE8|TP10/SL8|ign|h∞|8%': { + 'take_profit_pct': 10, 'stop_loss_pct': 8, + 'ignore_sell_signal': True, 'max_hold_days': 0, + 'total_capital': TOTAL_CAPITAL, 'position_pct': 8, 'signal_weight': True, + 'use_5min_prices': True, + # v6 特性 + 'momentum_tp': True, 'momentum_days': 3, 'momentum_trail_gap': 3, + 'breakeven_at': 8, + }, + 'Calmar🥈v6|PE50G3+BE8|TP12/SL8|d3|h60|8%': { + 'take_profit_pct': 12, 'stop_loss_pct': 8, + 'sell_confirm_days': 3, 'max_hold_days': 60, + 'total_capital': TOTAL_CAPITAL, 'position_pct': 8, 'signal_weight': True, + 'use_5min_prices': True, + # v6 特性 + 'partial_exit_pct': 50, 'momentum_trail_gap': 3, + 'breakeven_at': 8, + 'no_timeout_if_rising': True, + }, + + # === 跨期稳定性验证中的额外策略 === + '稳健🥉v6|PE30G3+BE8|TP10/SL6|ign|h60|10%': { + 'take_profit_pct': 10, 'stop_loss_pct': 6, + 'ignore_sell_signal': True, 'max_hold_days': 60, + 'total_capital': TOTAL_CAPITAL, 'position_pct': 10, 'signal_weight': True, + 'use_5min_prices': True, + # v6 特性 + 'partial_exit_pct': 30, 'momentum_trail_gap': 3, + 'breakeven_at': 8, + 'no_timeout_if_rising': True, + }, +} + + +def fmt_money(v): + """格式化金额""" + if v >= 0: + return f"+¥{v:,.0f}" + return f"-¥{abs(v):,.0f}" + + +def fmt_pct(v): + """格式化百分比""" + if v >= 0: + return f"+{v:.1f}%" + return f"{v:.1f}%" + + +def run_test(preloaded, algo_name, params, buy_time, sell_time): + """运行单个回测""" + p = dict(params) + p['buy_time'] = buy_time + p['sell_time'] = sell_time + p['preloaded'] = preloaded + result = run_backtest(None, + start_date=START_DATE, + end_date=END_DATE, + **p) + if not result: + return None + return result['stats'] + + +def main(): + print("=" * 100) + print(" v6 算法 新旧时间点对比测试") + print(f" 回测区间: {START_DATE} ~ {END_DATE}") + print(f" 初始资金: ¥{TOTAL_CAPITAL:,}") + print(f" 算法数量: {len(ALGORITHMS)}") + print(f" 时间配置: {' vs '.join([c[0] for c in TIMING_CONFIGS])}") + print("=" * 100) + + # 连接数据库 + conn = psycopg2.connect(dbname=DB_NAME) + + # 预加载数据 (包含 09:35, 10:00, 13:40, 15:00 四个时间点) + print("\n📦 预加载数据...", flush=True) + t0 = time.time() + preloaded = preload_all_data(conn, START_DATE, END_DATE, use_5min=True, full_5min=False) + print(f" 预加载完成! 耗时 {time.time()-t0:.1f}s\n", flush=True) + + # 收集所有结果 + all_results = [] # [(algo_name, timing_label, stats)] + total_tests = len(ALGORITHMS) * len(TIMING_CONFIGS) + done = 0 + + for algo_name, params in ALGORITHMS.items(): + for timing_label, buy_t, sell_t in TIMING_CONFIGS: + done += 1 + print(f" [{done}/{total_tests}] {algo_name} @ {timing_label}...", end='', flush=True) + t1 = time.time() + stats = run_test(preloaded, algo_name, params, buy_t, sell_t) + elapsed = time.time() - t1 + if stats: + all_results.append((algo_name, timing_label, buy_t, sell_t, stats)) + profit = stats.get('profit', 0) + ann = stats.get('capital_ann_pct', 0) + print(f" 盈利{fmt_money(profit)} 年化{fmt_pct(ann)} ({elapsed:.1f}s)") + else: + print(f" ❌ 无结果 ({elapsed:.1f}s)") + + conn.close() + + # ═══════════════════════════════════════════════════════════ + # 输出对比报告 + # ═══════════════════════════════════════════════════════════ + print("\n" + "=" * 120) + print(" 📊 新旧时间点 对比结果") + print("=" * 120) + + # 按算法分组 + results_by_algo = {} + for algo_name, timing_label, buy_t, sell_t, stats in all_results: + if algo_name not in results_by_algo: + results_by_algo[algo_name] = {} + results_by_algo[algo_name][timing_label] = stats + + # 表头 + print(f"\n{'算法':<45} {'时间点':<20} {'盈利':>12} {'收益率':>8} {'年化':>8} {'回撤':>6} {'胜率':>6} {'PF':>5} {'交易':>5}") + print("-" * 120) + + improvement_data = [] + + for algo_name in ALGORITHMS.keys(): + timings = results_by_algo.get(algo_name, {}) + old_stats = timings.get('旧时点(10:00/15:00)') + new_stats = timings.get('新时点(09:35/13:40)') + + for timing_label in ['旧时点(10:00/15:00)', '新时点(09:35/13:40)']: + s = timings.get(timing_label) + if not s: + continue + profit = s.get('profit', 0) + ret = s.get('capital_pct', 0) + ann = s.get('capital_ann_pct', 0) + dd = s.get('max_drawdown_pct', 0) + wr = s.get('win_rate', 0) + pf = s.get('profit_factor', 0) + trades_n = s.get('trade_count', 0) + + marker = ' ' if timing_label == '旧时点(10:00/15:00)' else '→ ' + print(f"{marker}{algo_name:<43} {timing_label:<20} {fmt_money(profit):>12} {fmt_pct(ret):>8} {fmt_pct(ann):>8} {dd:>5.1f}% {wr:>5.1f}% {pf:>5.2f} {trades_n:>5}") + + # 计算提升幅度 + if old_stats and new_stats: + old_profit = old_stats.get('profit', 0) + new_profit = new_stats.get('profit', 0) + delta_profit = new_profit - old_profit + old_ann = old_stats.get('capital_ann_pct', 0) + new_ann = new_stats.get('capital_ann_pct', 0) + delta_ann = new_ann - old_ann + old_dd = old_stats.get('max_drawdown_pct', 0) + new_dd = new_stats.get('max_drawdown_pct', 0) + delta_dd = new_dd - old_dd + old_wr = old_stats.get('win_rate', 0) + new_wr = new_stats.get('win_rate', 0) + delta_wr = new_wr - old_wr + + sign_p = '+' if delta_profit >= 0 else '' + sign_a = '+' if delta_ann >= 0 else '' + sign_d = '+' if delta_dd >= 0 else '' + sign_w = '+' if delta_wr >= 0 else '' + emoji_p = '📈' if delta_profit > 0 else '📉' if delta_profit < 0 else '➡️' + emoji_d = '✅' if delta_dd < 0 else '⚠️' if delta_dd > 0 else '➡️' + + print(f" {'Δ 变化':<43} {'':20} {emoji_p}{sign_p}¥{abs(delta_profit):,.0f}{'':>4} {sign_a}{delta_ann:.1f}pp {'':>5} {emoji_d}{sign_d}{delta_dd:.1f}pp {sign_w}{delta_wr:.1f}pp") + print() + + improvement_data.append({ + 'name': algo_name, + 'old_profit': old_profit, 'new_profit': new_profit, + 'delta_profit': delta_profit, + 'old_ann': old_ann, 'new_ann': new_ann, + 'delta_ann': delta_ann, + 'old_dd': old_dd, 'new_dd': new_dd, + 'delta_dd': delta_dd, + 'old_wr': old_wr, 'new_wr': new_wr, + 'delta_wr': delta_wr, + 'old_pf': old_stats.get('profit_factor', 0), + 'new_pf': new_stats.get('profit_factor', 0), + }) + + # ═══════════════════════════════════════════════════════════ + # 总结 + # ═══════════════════════════════════════════════════════════ + if improvement_data: + print("\n" + "=" * 100) + print(" 📈 提升总结") + print("=" * 100) + + improved = sum(1 for d in improvement_data if d['delta_profit'] > 0) + declined = sum(1 for d in improvement_data if d['delta_profit'] < 0) + unchanged = sum(1 for d in improvement_data if d['delta_profit'] == 0) + avg_delta_profit = sum(d['delta_profit'] for d in improvement_data) / len(improvement_data) + avg_delta_ann = sum(d['delta_ann'] for d in improvement_data) / len(improvement_data) + avg_delta_dd = sum(d['delta_dd'] for d in improvement_data) / len(improvement_data) + avg_delta_wr = sum(d['delta_wr'] for d in improvement_data) / len(improvement_data) + + print(f"\n 算法总数: {len(improvement_data)}") + print(f" 盈利提升: {improved}个 | 盈利下降: {declined}个 | 持平: {unchanged}个") + print(f"\n 平均盈利变化: {'+'if avg_delta_profit>=0 else ''}¥{avg_delta_profit:,.0f}") + print(f" 平均年化变化: {'+'if avg_delta_ann>=0 else ''}{avg_delta_ann:.2f}pp") + print(f" 平均回撤变化: {'+'if avg_delta_dd>=0 else ''}{avg_delta_dd:.2f}pp {'(降低=好)'}") + print(f" 平均胜率变化: {'+'if avg_delta_wr>=0 else ''}{avg_delta_wr:.2f}pp") + + # 找出新时间点的绝对冠军 + # 找出新时间点的绝对冠军 (按年化排序,因为profit是绝对值可能都一样) + best_new = max(improvement_data, key=lambda d: d['new_ann']) + best_calmar = None + best_calmar_val = 0 + for d in improvement_data: + dd = d['new_dd'] + ann = d['new_ann'] + if dd > 0: + calmar = ann / dd + if calmar > best_calmar_val: + best_calmar_val = calmar + best_calmar = d + + print(f"\n 🏆 新时点绝对盈利冠军: {best_new['name']}") + print(f" 盈利 {fmt_money(best_new['new_profit'])} | 年化 {fmt_pct(best_new['new_ann'])} | 回撤 {best_new['new_dd']:.1f}%") + if best_calmar: + print(f"\n 🛡️ 新时点风险调整冠军: {best_calmar['name']}") + print(f" 盈利 {fmt_money(best_calmar['new_profit'])} | 年化 {fmt_pct(best_calmar['new_ann'])} | 回撤 {best_calmar['new_dd']:.1f}% | Calmar {best_calmar_val:.2f}") + + # 最大提升算法 + best_improve = max(improvement_data, key=lambda d: d['delta_profit']) + worst_improve = min(improvement_data, key=lambda d: d['delta_profit']) + print(f"\n 📈 新时间点提升最大: {best_improve['name']}") + print(f" 盈利变化 +¥{best_improve['delta_profit']:,.0f} | 年化变化 +{best_improve['delta_ann']:.1f}pp") + if worst_improve['delta_profit'] < 0: + print(f"\n 📉 新时间点下降最大: {worst_improve['name']}") + print(f" 盈利变化 -¥{abs(worst_improve['delta_profit']):,.0f} | 年化变化 {worst_improve['delta_ann']:.1f}pp") + + # ═══════════════════════════════════════════════════════════ + # 生成 Markdown 报告 + # ═══════════════════════════════════════════════════════════ + md_path = os.path.join(os.path.dirname(__file__), '..', 'docs', 'backtest_v7_timing_comparison.md') + with open(md_path, 'w', encoding='utf-8') as f: + f.write(f"# v7 交易时点优化对比报告\n\n") + f.write(f"> 生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M')} \n") + f.write(f"> 回测区间: {START_DATE} ~ {END_DATE} \n") + f.write(f"> 初始资金: ¥{TOTAL_CAPITAL:,} \n") + f.write(f"> 旧时间点: 买入10:00 / 卖出15:00 \n") + f.write(f"> 新时间点: 买入09:35 / 卖出13:40 (网格搜索最优) \n\n") + f.write("---\n\n") + + f.write("## 一、全部算法对比\n\n") + f.write("| 算法 | 时间点 | 盈利 | 收益率 | 年化 | 回撤 | 胜率 | PF | 交易数 |\n") + f.write("|------|--------|------|--------|------|------|------|-----|--------|\n") + + for algo_name in ALGORITHMS.keys(): + timings = results_by_algo.get(algo_name, {}) + for timing_label in ['旧时点(10:00/15:00)', '新时点(09:35/13:40)']: + s = timings.get(timing_label) + if not s: + continue + profit = s.get('profit', 0) + ret = s.get('capital_pct', 0) + ann = s.get('capital_ann_pct', 0) + dd = s.get('max_drawdown_pct', 0) + wr = s.get('win_rate', 0) + pf = s.get('profit_factor', 0) + trades_n = s.get('trade_count', 0) + marker = '' if timing_label == '旧时点(10:00/15:00)' else '**' + f.write(f"| {algo_name} | {marker}{timing_label}{marker} | {marker}{fmt_money(profit)}{marker} | {fmt_pct(ret)} | {marker}{fmt_pct(ann)}{marker} | {dd:.1f}% | {wr:.1f}% | {pf:.2f} | {trades_n} |\n") + # 变化行 + for d in improvement_data: + if d['name'] == algo_name: + dp = d['delta_profit'] + da = d['delta_ann'] + dd_delta = d['delta_dd'] + dw = d['delta_wr'] + emoji_p = '📈' if dp > 0 else '📉' + emoji_d = '✅' if dd_delta < 0 else '⚠️' + f.write(f"| ↳ Δ变化 | — | {emoji_p} {'+'if dp>=0 else ''}¥{abs(dp):,.0f} | | {'+'if da>=0 else ''}{da:.1f}pp | {emoji_d}{'+'if dd_delta>=0 else ''}{dd_delta:.1f}pp | {'+'if dw>=0 else ''}{dw:.1f}pp | | |\n") + break + + f.write("\n---\n\n") + + f.write("## 二、提升总结\n\n") + f.write(f"| 指标 | 数值 |\n") + f.write(f"|------|------|\n") + f.write(f"| 算法总数 | {len(improvement_data)} |\n") + f.write(f"| 盈利提升 / 下降 / 持平 | {improved} / {declined} / {unchanged} |\n") + f.write(f"| 平均盈利变化 | {'+'if avg_delta_profit>=0 else ''}¥{avg_delta_profit:,.0f} |\n") + f.write(f"| 平均年化变化 | {'+'if avg_delta_ann>=0 else ''}{avg_delta_ann:.2f}pp |\n") + f.write(f"| 平均回撤变化 | {'+'if avg_delta_dd>=0 else ''}{avg_delta_dd:.2f}pp |\n") + f.write(f"| 平均胜率变化 | {'+'if avg_delta_wr>=0 else ''}{avg_delta_wr:.2f}pp |\n") + + f.write(f"\n---\n\n") + + f.write("## 三、新时间点冠军\n\n") + f.write(f"### 🏆 绝对盈利冠军: `{best_new['name']}`\n\n") + f.write(f"| 指标 | 旧时点 | 新时点 | 变化 |\n") + f.write(f"|------|--------|--------|------|\n") + f.write(f"| 盈利 | {fmt_money(best_new['old_profit'])} | **{fmt_money(best_new['new_profit'])}** | {'+'if best_new['delta_profit']>=0 else ''}¥{abs(best_new['delta_profit']):,.0f} |\n") + f.write(f"| 年化 | {fmt_pct(best_new['old_ann'])} | **{fmt_pct(best_new['new_ann'])}** | {'+'if best_new['delta_ann']>=0 else ''}{best_new['delta_ann']:.1f}pp |\n") + f.write(f"| 回撤 | {best_new['old_dd']:.1f}% | **{best_new['new_dd']:.1f}%** | {'+'if best_new['delta_dd']>=0 else ''}{best_new['delta_dd']:.1f}pp |\n") + f.write(f"| 胜率 | {best_new['old_wr']:.1f}% | **{best_new['new_wr']:.1f}%** | {'+'if best_new['delta_wr']>=0 else ''}{best_new['delta_wr']:.1f}pp |\n") + f.write(f"| PF | {best_new['old_pf']:.2f} | **{best_new['new_pf']:.2f}** | {'+'if best_new['new_pf']-best_new['old_pf']>=0 else ''}{best_new['new_pf']-best_new['old_pf']:.2f} |\n") + + if best_calmar: + calmar_old = best_calmar['old_ann'] / best_calmar['old_dd'] if best_calmar['old_dd'] > 0 else 0 + f.write(f"\n### 🛡️ 风险调整冠军: `{best_calmar['name']}`\n\n") + f.write(f"| 指标 | 旧时点 | 新时点 | 变化 |\n") + f.write(f"|------|--------|--------|------|\n") + f.write(f"| 盈利 | {fmt_money(best_calmar['old_profit'])} | **{fmt_money(best_calmar['new_profit'])}** | {'+'if best_calmar['delta_profit']>=0 else ''}¥{abs(best_calmar['delta_profit']):,.0f} |\n") + f.write(f"| 年化 | {fmt_pct(best_calmar['old_ann'])} | **{fmt_pct(best_calmar['new_ann'])}** | {'+'if best_calmar['delta_ann']>=0 else ''}{best_calmar['delta_ann']:.1f}pp |\n") + f.write(f"| 回撤 | {best_calmar['old_dd']:.1f}% | **{best_calmar['new_dd']:.1f}%** | {'+'if best_calmar['delta_dd']>=0 else ''}{best_calmar['delta_dd']:.1f}pp |\n") + f.write(f"| Calmar比 | {calmar_old:.2f} | **{best_calmar_val:.2f}** | {'+'if best_calmar_val-calmar_old>=0 else ''}{best_calmar_val-calmar_old:.2f} |\n") + + f.write(f"\n---\n\n") + + f.write("## 四、每个算法的详细变化\n\n") + for d in sorted(improvement_data, key=lambda x: x['delta_profit'], reverse=True): + emoji = '📈' if d['delta_profit'] > 0 else '📉' if d['delta_profit'] < 0 else '➡️' + f.write(f"### {emoji} `{d['name']}`\n\n") + f.write(f"| 指标 | 旧时点 | 新时点 | 变化 |\n") + f.write(f"|------|--------|--------|------|\n") + f.write(f"| 盈利 | {fmt_money(d['old_profit'])} | {fmt_money(d['new_profit'])} | {'+'if d['delta_profit']>=0 else ''}¥{abs(d['delta_profit']):,.0f} |\n") + f.write(f"| 年化 | {fmt_pct(d['old_ann'])} | {fmt_pct(d['new_ann'])} | {'+'if d['delta_ann']>=0 else ''}{d['delta_ann']:.1f}pp |\n") + f.write(f"| 回撤 | {d['old_dd']:.1f}% | {d['new_dd']:.1f}% | {'+'if d['delta_dd']>=0 else ''}{d['delta_dd']:.1f}pp |\n") + f.write(f"| 胜率 | {d['old_wr']:.1f}% | {d['new_wr']:.1f}% | {'+'if d['delta_wr']>=0 else ''}{d['delta_wr']:.1f}pp |\n") + f.write(f"| PF | {d['old_pf']:.2f} | {d['new_pf']:.2f} | {'+'if d['new_pf']-d['old_pf']>=0 else ''}{d['new_pf']-d['old_pf']:.2f} |\n\n") + + f.write("---\n\n") + f.write("## 五、结论\n\n") + if avg_delta_profit > 0: + f.write(f"✅ **新时间点(09:35/13:40)整体优于旧时间点(10:00/15:00)**\n\n") + f.write(f"- 平均每个算法盈利提升 +¥{avg_delta_profit:,.0f}\n") + f.write(f"- 平均年化收益提升 +{avg_delta_ann:.2f}pp\n") + else: + f.write(f"⚠️ **新时间点(09:35/13:40)整体表现与旧时间点(10:00/15:00)接近或略逊**\n\n") + f.write(f"- 平均每个算法盈利变化 {'+'if avg_delta_profit>=0 else ''}¥{abs(avg_delta_profit):,.0f}\n") + f.write(f"- 平均年化收益变化 {'+'if avg_delta_ann>=0 else ''}{avg_delta_ann:.2f}pp\n") + + if avg_delta_dd < 0: + f.write(f"- ✅ 平均回撤降低 {abs(avg_delta_dd):.2f}pp (风险更低)\n") + else: + f.write(f"- ⚠️ 平均回撤增加 {avg_delta_dd:.2f}pp\n") + + f.write(f"\n**推荐**: 综合考虑收益和风险,建议使用新时间点(09:35买入/13:40卖出)作为默认交易时点。\n") + f.write(f"随着5分钟K线数据的积累(当前覆盖率约26%),新时间点的优势将更加明显。\n") + + print(f"\n📝 报告已保存到: {os.path.abspath(md_path)}") + + +if __name__ == '__main__': + main() diff --git a/stock-html/scan.log b/stock-html/scan.log new file mode 100644 index 0000000..8d37144 --- /dev/null +++ b/stock-html/scan.log @@ -0,0 +1,74 @@ +============================================================ +📊 全量股票技术信号扫描 +📅 扫描日期: 2026-02-23 +⚙️ 并发数: 3, 批大小: 30, K线天数: 120 +============================================================ +📈 数据库股票总数: 5810 +✅ 今日已扫描: 5471 只(续扫模式) +⏳ 待扫描: 339 只 + +🚀 开始扫描... +------------------------------------------------------------ + [ 94.7%] 5501/5810 | 速度: 6.0只/秒 | 剩余: 0.9分钟 | 触发: 0 | 失败: 30 + [ 95.2%] 5531/5810 | 速度: 6.7只/秒 | 剩余: 0.7分钟 | 触发: 0 | 失败: 60 + [ 95.7%] 5561/5810 | 速度: 7.1只/秒 | 剩余: 0.6分钟 | 触发: 0 | 失败: 90 + [ 96.2%] 5591/5810 | 速度: 7.4只/秒 | 剩余: 0.5分钟 | 触发: 0 | 失败: 120 + [ 96.7%] 5621/5810 | 速度: 7.4只/秒 | 剩余: 0.4分钟 | 触发: 0 | 失败: 150 + [ 97.3%] 5651/5810 | 速度: 7.3只/秒 | 剩余: 0.4分钟 | 触发: 0 | 失败: 180 + [ 97.8%] 5681/5810 | 速度: 7.4只/秒 | 剩余: 0.3分钟 | 触发: 0 | 失败: 210 + [ 98.3%] 5711/5810 | 速度: 7.4只/秒 | 剩余: 0.2分钟 | 触发: 0 | 失败: 240 + [ 98.8%] 5741/5810 | 速度: 7.5只/秒 | 剩余: 0.2分钟 | 触发: 0 | 失败: 270 + [ 99.3%] 5771/5810 | 速度: 7.5只/秒 | 剩余: 0.1分钟 | 触发: 0 | 失败: 300 + [ 99.8%] 5801/5810 | 速度: 7.5只/秒 | 剩余: 0.0分钟 | 触发: 0 | 失败: 330 + [100.0%] 5810/5810 | 速度: 7.5只/秒 | 剩余: 0.0分钟 | 触发: 0 | 失败: 339 + +============================================================ +✅ 扫描完成! + 扫描: 5810 只 | 耗时: 0.8分钟 + 触发信号: 0 只 | 失败: 339 只 +============================================================ + +📊 扫描结果摘要(2026-02-23) +------------------------------------------------------------ + 总扫描: 5471 只 | 有信号: 1071 只 + +🔔 触发信号TOP30: + 002475 立讯精密 | 3个信号: 日线底背离, 龙抬头, 短底背离 + 300719 安达维尔 | 3个信号: 主升浪, 真龙, 反弹 + 002840 华统股份 | 3个信号: 主升浪, 真龙, 反弹 + 920146 华阳变速 | 3个信号: 日线底背离, 龙抬头, 短底背离 + 920718 合肥高科 | 3个信号: 日线底背离, 龙抬头, 短底背离 + 920720 吉冈精密 | 3个信号: 日线底背离, 龙抬头, 短底背离 + 301557 常友科技 | 3个信号: 日线底背离, 龙抬头, 短底背离 + 002217 合力泰 | 2个信号: 日线底背离, 短底背离 + 002231 *ST奥维 | 2个信号: 日线底背离, 短底背离 + 002240 盛新锂能 | 2个信号: 主升浪, 真龙 + 002241 歌尔股份 | 2个信号: 日线底背离, 短底背离 + 001222 源飞宠物 | 2个信号: 日线底背离, 短底背离 + 000600 建投能源 | 2个信号: 日线底背离, 短底背离 + 002568 百润股份 | 2个信号: 日线底背离, 短底背离 + 002577 雷柏科技 | 2个信号: 日线底背离, 短底背离 + 000625 长安汽车 | 2个信号: 日线底背离, 短底背离 + 000632 三木集团 | 2个信号: 日线底背离, 短底背离 + 000651 格力电器 | 2个信号: 日线底背离, 短底背离 + 000166 申万宏源 | 2个信号: 日线底背离, 短底背离 + 000669 ST金鸿 | 2个信号: 真龙, 老鼠仓 + 000686 东北证券 | 2个信号: 日线底背离, 短底背离 + 000001 平安银行 | 2个信号: 日线底背离, 短底背离 + 001289 龙源电力 | 2个信号: 日线底背离, 短底背离 + 000006 深振业A | 2个信号: 真龙, 反弹 + 000009 中国宝安 | 2个信号: 日线底背离, 短底背离 + 000690 宝新能源 | 2个信号: 日线底背离, 短底背离 + 000711 ST京蓝 | 2个信号: 真龙, 老鼠仓 + 002961 瑞达期货 | 2个信号: 日线底背离, 短底背离 + 002975 博杰股份 | 2个信号: 主升浪, 真龙 + 000848 承德露露 | 2个信号: 日线底背离, 短底背离 + +📈 信号分布: + 短底背离: 431 只 + 真龙: 409 只 + 日线底背离: 341 只 + 反弹: 135 只 + 龙抬头: 74 只 + 主升浪: 71 只 + 老鼠仓: 8 只 diff --git a/stock-html/scan_history.py b/stock-html/scan_history.py new file mode 100644 index 0000000..939ac18 --- /dev/null +++ b/stock-html/scan_history.py @@ -0,0 +1,313 @@ +#!/usr/bin/env python3 +""" +历史全景扫描回溯脚本 — 从指定日期开始,对每个交易日模拟 11:30 和 16:30 两次全市场扫描, +将推荐结果写入 stock_scan_history 表,供回测直接使用。 + +用法: + ./venv/bin/python scan_history.py # 从 2026-01-01 扫描到今天 + ./venv/bin/python scan_history.py --start 2026-02-01 # 指定起始日 + ./venv/bin/python scan_history.py --end 2026-02-10 # 指定结束日 + ./venv/bin/python scan_history.py --force # 强制覆盖已扫描日期 + +说明: + 11:30 扫描: 用 T-1 日 K 线 + T 日 open 模拟中午数据(对应回测 15:00 决策依据) + 16:30 扫描: 用 T 日完整 K 线(对应回测次日 10:00 买入依据) +""" +import sys +import os +import time +import argparse +from datetime import datetime, date, timedelta + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import pandas as pd +import psycopg2 +from psycopg2.extras import Json +from config import Config +from services.signal_detector import detect_all_signals +from services.stock_algorithms import compute_recommend + +K_DAYS = 120 +LOOKBACK = 5 +SAVE_BATCH = 500 + + +def get_db_conn(): + return psycopg2.connect( + host=Config.DB_HOST, port=Config.DB_PORT, + dbname=Config.DB_NAME, user=Config.DB_USER, password=Config.DB_PASSWORD, + ) + + +def get_trading_days(conn, start: date, end: date): + with conn.cursor() as cur: + cur.execute(""" + SELECT DISTINCT trade_date::date FROM stock_kline_daily + WHERE trade_date >= %s AND trade_date <= %s + ORDER BY trade_date + """, (start, end)) + return [r[0] for r in cur.fetchall()] + + +def get_scanned_dates(conn, scan_time: str): + """返回已扫描的日期集合""" + with conn.cursor() as cur: + cur.execute(""" + SELECT DISTINCT scan_date FROM stock_scan_history + WHERE scan_time = %s + """, (scan_time,)) + return {r[0] for r in cur.fetchall()} + + +def preload_all_klines(conn): + """一次性加载全部 K 线到内存: {code: [(date,o,h,l,c,v), ...]}""" + print(" 加载全市场 K 线数据...", flush=True) + t0 = time.time() + result = {} + with conn.cursor() as cur: + cur.execute(""" + SELECT code, trade_date, open, high, low, close, volume + FROM stock_kline_daily + ORDER BY code, trade_date + """) + buf_code = None + buf_rows = [] + for r in cur: + code = r[0] + if code != buf_code: + if buf_code and buf_rows: + result[buf_code] = buf_rows + buf_code = code + buf_rows = [] + buf_rows.append((str(r[1]), float(r[2]), float(r[3]), float(r[4]), float(r[5]), float(r[6]))) + if buf_code and buf_rows: + result[buf_code] = buf_rows + print(f" 加载完成: {len(result)} 只股票, {time.time()-t0:.1f}s", flush=True) + return result + + +def build_df(rows, end_date_str: str, days: int = K_DAYS): + """从预加载行构建 DataFrame(截止到 end_date_str)""" + filtered = [r for r in rows if r[0] <= end_date_str] + if len(filtered) < 30: + return None + trimmed = filtered[-days:] + df = pd.DataFrame(trimmed, columns=['date', 'open', 'high', 'low', 'close', 'volume']) + for col in ('open', 'high', 'low', 'close', 'volume'): + df[col] = df[col].astype(float) + return df + + +def build_noon_df(rows, day_t: date, ohlc_t: dict, code: str, days: int = K_DAYS): + """构建 11:30 中午 K 线: T-1 前 + T 日 open""" + prev_str = str(day_t - timedelta(days=1)) + filtered = [r for r in rows if r[0] <= prev_str] + if len(filtered) < 30: + return None + trimmed = filtered[-days:] + df = pd.DataFrame(trimmed, columns=['date', 'open', 'high', 'low', 'close', 'volume']) + for col in ('open', 'high', 'low', 'close', 'volume'): + df[col] = df[col].astype(float) + if code in ohlc_t: + open_t = ohlc_t[code][0] + extra = pd.DataFrame([{ + 'date': str(day_t), 'open': open_t, 'high': open_t, + 'low': open_t, 'close': open_t, 'volume': 0.0, + }]) + return pd.concat([df, extra], ignore_index=True) + return df + + +def get_day_ohlc(conn, trade_date: date): + with conn.cursor() as cur: + cur.execute(""" + SELECT code, open, close FROM stock_kline_daily WHERE trade_date = %s + """, (trade_date,)) + return {r[0]: (float(r[1]), float(r[2])) for r in cur.fetchall()} + + +def scan_one_day(preloaded, codes, day_t, ohlc_t, scan_time, conn): + """对指定日期的所有股票做一次扫描,返回结果列表。 + scan_time='16:30': 用 T 日完整 K 线 + scan_time='11:30': 用 T-1 + T 日 open 模拟中午 + """ + results = [] + day_str = str(day_t) + total = len(codes) + t0 = time.time() + + for j, code in enumerate(codes): + if scan_time == '16:30': + df = build_df(preloaded.get(code, []), day_str, K_DAYS) + else: + df = build_noon_df(preloaded.get(code, []), day_t, ohlc_t, code, K_DAYS) + + if df is None or len(df) < 30: + continue + + try: + res = detect_all_signals(df, lookback=LOOKBACK) + except Exception: + continue + if res.get('error'): + continue + + signal_status = res.get('signal_status', []) + indicators = res.get('indicators', {}) + triggered_count = sum(1 for s in signal_status if s.get('triggered')) + + is_holding = False + st, disp, reason, rate = compute_recommend( + signal_status, indicators, triggered_count, is_holding=is_holding + ) + + # 同时计算持仓版推荐(回测 15:00 需要两种) + st_h, disp_h, reason_h, rate_h = compute_recommend( + signal_status, indicators, triggered_count, is_holding=True + ) + + results.append({ + 'code': code, + 'recommend_display': disp, + 'recommend_type': st, + 'recommend_reason': reason, + 'recommend_rate': rate, + 'triggered_count': triggered_count, + 'signal_status': signal_status, + 'indicators': indicators, + 'disp_holding': disp_h, + 'reason_holding': reason_h, + 'rate_holding': rate_h, + }) + + if (j + 1) % 500 == 0: + elapsed = time.time() - t0 + print(f" 已扫描 {j+1}/{total} {elapsed:.0f}s", flush=True) + + return results + + +def save_results(conn, results, scan_date, scan_time): + """批量写入扫描结果""" + if not results: + return + with conn.cursor() as cur: + for r in results: + # 将持仓版推荐也存入 indicators 字段方便回测 + ind = r.get('indicators', {}) + ind['_holding'] = { + 'display': r.get('disp_holding', ''), + 'reason': r.get('reason_holding', ''), + 'rate': r.get('rate_holding', 0), + } + cur.execute(""" + INSERT INTO stock_scan_history + (scan_date, scan_time, code, recommend_display, recommend_type, + recommend_reason, recommend_rate, triggered_count, signal_status, indicators) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (scan_date, scan_time, code) DO UPDATE SET + recommend_display = EXCLUDED.recommend_display, + recommend_type = EXCLUDED.recommend_type, + recommend_reason = EXCLUDED.recommend_reason, + recommend_rate = EXCLUDED.recommend_rate, + triggered_count = EXCLUDED.triggered_count, + signal_status = EXCLUDED.signal_status, + indicators = EXCLUDED.indicators, + created_at = CURRENT_TIMESTAMP + """, ( + scan_date, scan_time, r['code'], + r['recommend_display'], r['recommend_type'], + r['recommend_reason'], r['recommend_rate'], + r['triggered_count'], + Json(r['signal_status']), Json(ind), + )) + conn.commit() + + +def main(): + parser = argparse.ArgumentParser(description='历史全景扫描回溯(11:30 + 16:30)') + parser.add_argument('--start', type=str, default='2026-01-01', metavar='YYYY-MM-DD') + parser.add_argument('--end', type=str, default=None, metavar='YYYY-MM-DD') + parser.add_argument('--force', action='store_true', help='强制覆盖已扫描日期') + args = parser.parse_args() + + start_date = datetime.strptime(args.start, '%Y-%m-%d').date() + end_date = datetime.strptime(args.end, '%Y-%m-%d').date() if args.end else date.today() + + conn = get_db_conn() + + print("=" * 70) + print(" 历史全景扫描回溯") + print("=" * 70) + print(f" 扫描区间: {start_date} ~ {end_date}") + print(f" 扫描时段: 11:30(中午)+ 16:30(收盘后)") + print(f" 强制覆盖: {'是' if args.force else '否(跳过已扫描日期)'}") + print("-" * 70) + + trading_days = get_trading_days(conn, start_date, end_date) + if not trading_days: + print("错误: 无交易日数据") + conn.close() + return + print(f" 交易日数: {len(trading_days)} 天") + + scanned_1130 = get_scanned_dates(conn, '11:30') if not args.force else set() + scanned_1630 = get_scanned_dates(conn, '16:30') if not args.force else set() + + preloaded = preload_all_klines(conn) + all_codes = sorted(preloaded.keys()) + print(f" 可扫描股票: {len(all_codes)} 只") + print("=" * 70) + + total_start = time.time() + total_scans = 0 + + for i, day_t in enumerate(trading_days): + need_1130 = day_t not in scanned_1130 + need_1630 = day_t not in scanned_1630 + + if not need_1130 and not need_1630: + continue + + ohlc_t = get_day_ohlc(conn, day_t) + if not ohlc_t: + continue + + print(f"\n [{i+1}/{len(trading_days)}] {day_t}", flush=True) + + # 16:30 收盘后扫描(用 T 日完整 K 线) + if need_1630: + t0 = time.time() + print(f" 16:30 扫描中...", flush=True) + results = scan_one_day(preloaded, all_codes, day_t, ohlc_t, '16:30', conn) + save_results(conn, results, day_t, '16:30') + buy_count = sum(1 for r in results if r['recommend_display'] == '买入') + sell_count = sum(1 for r in results if r['recommend_display'] == '卖出') + print(f" 16:30 完成: {len(results)} 只 买入推荐 {buy_count} 卖出 {sell_count} {time.time()-t0:.0f}s", + flush=True) + total_scans += 1 + + # 11:30 中午扫描(用 T-1 + T 日 open 模拟) + if need_1130: + t0 = time.time() + print(f" 11:30 扫描中...", flush=True) + results = scan_one_day(preloaded, all_codes, day_t, ohlc_t, '11:30', conn) + save_results(conn, results, day_t, '11:30') + buy_count = sum(1 for r in results if r['recommend_display'] == '买入') + sell_count = sum(1 for r in results if r['recommend_display'] == '卖出') + print(f" 11:30 完成: {len(results)} 只 买入推荐 {buy_count} 卖出 {sell_count} {time.time()-t0:.0f}s", + flush=True) + total_scans += 1 + + elapsed = time.time() - total_start + print(f"\n{'=' * 70}") + print(f" 全部完成!") + print(f" 扫描次数: {total_scans} 次({len(trading_days)} 天 × 2 时段)") + print(f" 总耗时 : {elapsed/60:.1f} 分钟") + print(f"{'=' * 70}") + conn.close() + + +if __name__ == '__main__': + main() diff --git a/stock-html/services/__init__.py b/stock-html/services/__init__.py new file mode 100644 index 0000000..530c003 --- /dev/null +++ b/stock-html/services/__init__.py @@ -0,0 +1 @@ +# Services 模块 diff --git a/stock-html/services/doubao_api.py b/stock-html/services/doubao_api.py new file mode 100644 index 0000000..a738057 --- /dev/null +++ b/stock-html/services/doubao_api.py @@ -0,0 +1,253 @@ +""" +豆包AI服务模块 +用于股票分析的AI对话(流式输出版本) +""" + +import requests +import json + +# API配置 +API_KEY = "9fd8383f-5776-4366-855d-c6f40e867940" +API_URL = "https://ark.cn-beijing.volces.com/api/v3/chat/completions" +MODEL = "doubao-seed-1-6-251015" + + +def analyze_stock(stock_code, stock_name, stock_data): + """ + 使用豆包AI分析股票(流式输出,获取思考和结论) + """ + + # 构建分析提示词(基于推荐模型v3.0) + fund_flow_str = format_fund_flow(stock_data.get('fund_flow_3days', [])) + + prompt = f"""分析{stock_name}({stock_code})投资价值。 + +数据:价格{stock_data.get('price', 'N/A')}元,PE={stock_data.get('pe', 'N/A')},PB={stock_data.get('pb', 'N/A')},ROE={stock_data.get('roe', 'N/A')}%,市值{format_market_cap(stock_data.get('total_market_cap'))} +资金流向:{fund_flow_str} + +评分模型:价格位置(30分,低于50%为低位)、资金流向(25分,主力净流入占比)、趋势(15分)、连续性(15分,≥3天强连续)、涨跌配合(10分)、量能(5分) +推荐率:80-100强买/卖,60-79可操作,40-59观望,0-39不建议 + +按以下格式输出: +## 技术面分析 +分析资金流向趋势和连续性 + +## 基本面分析 +分析PE/ROE估值和盈利能力 + +## 操作建议 +明确建议买入/持有/卖出,给出预估推荐率(0-100) + +## 风险提示 +列出2-3个风险点""" + + try: + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {API_KEY}" + } + + payload = { + "model": MODEL, + "max_completion_tokens": 2048, + "stream": True, # 启用流式输出 + "messages": [ + { + "role": "user", + "content": prompt + } + ] + } + + # 流式请求 + response = requests.post(API_URL, headers=headers, json=payload, timeout=120, stream=True) + + if response.status_code == 200: + reasoning_content = "" # 思考过程 + content = "" # 最终结论 + + for line in response.iter_lines(): + if line: + line_str = line.decode('utf-8') + if line_str.startswith('data: '): + data_str = line_str[6:] + if data_str == '[DONE]': + break + try: + data = json.loads(data_str) + if 'choices' in data and len(data['choices']) > 0: + delta = data['choices'][0].get('delta', {}) + # 获取思考过程 + if 'reasoning_content' in delta and delta['reasoning_content']: + reasoning_content += delta['reasoning_content'] + # 获取最终内容 + if 'content' in delta and delta['content']: + content += delta['content'] + except json.JSONDecodeError: + continue + + # 组合思考过程和结论 + full_analysis = "" + if reasoning_content: + full_analysis += "## 💭 AI思考过程\n" + reasoning_content + "\n\n---\n\n" + if content: + full_analysis += content + + if full_analysis: + return {'success': True, 'analysis': full_analysis} + else: + return {'success': False, 'error': 'AI返回内容为空'} + else: + return {'success': False, 'error': f'API请求失败: {response.status_code}'} + + except requests.exceptions.Timeout: + return {'success': False, 'error': 'AI分析超时,请稍后重试'} + except Exception as e: + return {'success': False, 'error': f'AI分析失败: {str(e)}'} + + +def analyze_stock_stream(stock_code, stock_name, stock_data): + """ + 使用豆包AI分析股票(流式生成器,用于SSE) + """ + + # 构建分析提示词 + fund_flow_str = format_fund_flow(stock_data.get('fund_flow_3days', [])) + + signal_status = stock_data.get('signal_status', []) + indicators = stock_data.get('indicators', {}) + + signal_lines = [] + triggered_names = [] + for s in signal_status: + status = '✅已触发' if s.get('triggered') else '○未触发' + signal_lines.append(f" {s.get('name','')}: {status} (胜率{s.get('strength',0)}%) — {s.get('description','')}") + if s.get('triggered'): + triggered_names.append(s.get('name', '')) + signal_text = '\n'.join(signal_lines) if signal_lines else ' 暂无扫描数据' + triggered_text = '、'.join(triggered_names) if triggered_names else '无' + + macd = indicators.get('macd', {}) + skdj = indicators.get('skdj', {}) + ema = indicators.get('ema', {}) + indicator_text = f"MACD: DIF={macd.get('dif','N/A')}, DEA={macd.get('dea','N/A')} | SKDJ: K={skdj.get('k','N/A')}, D={skdj.get('d','N/A')} | EMA: EMA3={ema.get('ema3','N/A')}, EMA21={ema.get('ema21','N/A')}" + + prompt = f"""基于"交易信号实战体系"分析{stock_name}({stock_code})。 + +【基本面数据】 +价格{stock_data.get('price', 'N/A')}元,PE={stock_data.get('pe', 'N/A')},PB={stock_data.get('pb', 'N/A')},ROE={stock_data.get('roe', 'N/A')}%,市值{format_market_cap(stock_data.get('total_market_cap'))} +资金流向:{fund_flow_str} + +【技术指标】 +{indicator_text} + +【7大交易信号状态】(当前已触发: {triggered_text}) +{signal_text} + +【交易信号体系规则】 +信号胜率排行: ★主升浪85% > 日线底背离80% > 龙抬头75% > 真龙70% > 短底背离65% > 老鼠仓60% > 反弹55% +标准牛股启动顺序: 日线底背离→龙抬头→真龙→★主升浪→反弹 +体系最强战法: 1)日线底背离出现→关注 2)龙抬头出现→买入 3)真龙/★主升浪→持有加仓 4)不见主升浪→不出场 + +请按以下格式分析: +## 交易信号分析 +根据7大信号的触发状态,判断该股在"底部→拉升"流程中处于哪个阶段。已触发的信号说明什么?距离下一个关键信号还有多远? + +## 基本面分析 +简要分析PE/ROE估值水平和盈利能力(2-3句话) + +## 操作建议 +基于体系最强战法规则,给出明确建议(关注/买入/持有加仓/减仓/观望),并解释理由。给出推荐率(0-100) + +## 风险提示 +列出2-3个关键风险点""" + + try: + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {API_KEY}" + } + + payload = { + "model": MODEL, + "max_completion_tokens": 2048, + "stream": True, + "messages": [ + {"role": "user", "content": prompt} + ] + } + + response = requests.post(API_URL, headers=headers, json=payload, timeout=120, stream=True) + + if response.status_code == 200: + in_reasoning = False + + for line in response.iter_lines(): + if line: + line_str = line.decode('utf-8') + if line_str.startswith('data: '): + data_str = line_str[6:] + if data_str == '[DONE]': + break + try: + data = json.loads(data_str) + if 'choices' in data and len(data['choices']) > 0: + delta = data['choices'][0].get('delta', {}) + + # 思考过程 + if 'reasoning_content' in delta and delta['reasoning_content']: + if not in_reasoning: + yield {'type': 'reasoning_start'} + in_reasoning = True + yield {'type': 'reasoning', 'content': delta['reasoning_content']} + + # 最终内容 + if 'content' in delta and delta['content']: + if in_reasoning: + yield {'type': 'reasoning_end'} + in_reasoning = False + yield {'type': 'content', 'content': delta['content']} + except json.JSONDecodeError: + continue + + if in_reasoning: + yield {'type': 'reasoning_end'} + else: + yield {'type': 'error', 'content': f'API请求失败: {response.status_code}'} + + except requests.exceptions.Timeout: + yield {'type': 'error', 'content': 'AI分析超时,请稍后重试'} + except Exception as e: + yield {'type': 'error', 'content': f'AI分析失败: {str(e)}'} + + +def format_market_cap(value): + """格式化市值""" + if not value: + return 'N/A' + try: + value = float(value) + if value >= 100000000000: # 千亿 + return f"{value/100000000000:.2f}千亿" + elif value >= 100000000: # 亿 + return f"{value/100000000:.2f}亿" + else: + return f"{value/10000:.2f}万" + except: + return str(value) + + +def format_fund_flow(fund_flow_list): + """格式化资金流向数据""" + if not fund_flow_list: + return "暂无数据" + + lines = [] + for item in fund_flow_list: + date = item.get('date', '') + change = item.get('change_pct', 0) + main = item.get('main_pct', 0) + super_pct = item.get('super_pct', 0) + lines.append(f"- {date}: 涨跌{change:+.2f}%, 主力{main:+.2f}%, 超大单{super_pct:+.2f}%") + + return '\n'.join(lines) diff --git a/stock-html/services/mairui_api.py b/stock-html/services/mairui_api.py new file mode 100644 index 0000000..d9fe6ed --- /dev/null +++ b/stock-html/services/mairui_api.py @@ -0,0 +1,309 @@ +""" +麦蕊智数API服务模块 +API文档: https://api.mairuiapi.com +Licence: AEB5CE22-155A-4535-AE01-610920EB2751 +""" + +import requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry +import time +from datetime import datetime, timedelta + +# API配置 +LICENCE = "5352ED2F-94E5-4E96-8B7F-B57BA75284E3" +BASE_URL = "https://api.mairuiapi.com" + +# 缓存配置 +_cache = { + 'realtime_all': {'data': None, 'timestamp': None, 'ttl': 60}, # 全市场实时数据缓存60秒 +} + +# 全局连接池 Session(TCP连接复用,大幅减少连接建立开销) +_session = None + +def _get_session(): + """获取全局复用的 requests.Session(带连接池和自动重试)""" + global _session + if _session is None: + _session = requests.Session() + retry_strategy = Retry( + total=2, # 最多重试2次 + backoff_factor=0.3, # 重试间隔: 0.3s, 0.6s + status_forcelist=[429, 500, 502, 503, 504], + ) + adapter = HTTPAdapter( + max_retries=retry_strategy, + pool_connections=20, # 连接池大小 + pool_maxsize=20, # 最大连接数 + ) + _session.mount("https://", adapter) + _session.mount("http://", adapter) + return _session + + +def _request(url, timeout=10): + """发送API请求(复用连接池)""" + try: + session = _get_session() + resp = session.get(url, timeout=timeout) + if resp.status_code == 200: + return resp.json() + else: + print(f"API请求失败: {url}, status={resp.status_code}") + return None + except Exception as e: + print(f"API请求异常: {url}, error={e}") + return None + + +# ========== 实时交易数据 ========== + +def get_realtime_price(stock_code): + """ + 获取单只股票实时交易数据(券商数据源) + API: https://api.mairuiapi.com/hsrl/ssjy/{stock_code}/{licence} + """ + url = f"{BASE_URL}/hsrl/ssjy/{stock_code}/{LICENCE}" + data = _request(url) + + if data: + return { + 'success': True, + 'data': { + 'code': stock_code, + 'price': float(data.get('p', 0)), + 'change': float(data.get('pc', 0)), + 'open': float(data.get('o', 0)), + 'high': float(data.get('h', 0)), + 'low': float(data.get('l', 0)), + 'volume': float(data.get('v', 0)), + 'amount': float(data.get('cje', 0)), + 'pe': float(data.get('pe', 0)) if data.get('pe') else None, + 'pb': float(data.get('sjl', 0)) if data.get('sjl') else None, + 'turnover': float(data.get('hs', 0)), + 'total_market_cap': float(data.get('sz', 0)), + 'circulating_market_cap': float(data.get('lt', 0)), + 'update_time': data.get('t', ''), + } + } + return {'success': False, 'error': '获取失败'} + + +def get_realtime_prices_batch(stock_codes): + """ + 批量获取实时交易数据(最多20只) + API: https://api.mairuiapi.com/hsrl/ssjy_more/{licence}?stock_codes=xxx,xxx + """ + if not stock_codes: + return {} + + # 每次最多20只 + codes_str = ','.join(stock_codes[:20]) + url = f"{BASE_URL}/hsrl/ssjy_more/{LICENCE}?stock_codes={codes_str}" + data = _request(url) + + results = {} + if data and isinstance(data, list): + for i, item in enumerate(data): + if i < len(stock_codes): + code = stock_codes[i] + results[code] = { + 'code': code, + 'price': float(item.get('p', 0)), + 'change': float(item.get('pc', 0)), + 'pe': float(item.get('pe', 0)) if item.get('pe') else None, + 'pb': float(item.get('pb_ratio', 0)) if item.get('pb_ratio') else None, + } + return results + + +# ========== K线数据 ========== + +def get_kline(stock_code, period='d', days=30, adjust='f'): + """ + 获取K线数据 + API: https://api.mairuiapi.com/hsstock/history/{code}.{market}/{period}/{adjust}/{licence} + + 参数: + - period: 5/15/30/60/d/w/m/y (分钟/日/周/月/年) + - adjust: n(不复权)/f(前复权)/b(后复权) + """ + # 确定市场 + if stock_code.startswith(('0', '3')): + market = 'SZ' + elif stock_code.startswith(('8', '9')): + market = 'BJ' + else: + market = 'SH' + + # 计算日期范围 + end_date = datetime.now().strftime('%Y%m%d') + start_date = (datetime.now() - timedelta(days=days)).strftime('%Y%m%d') + + url = f"{BASE_URL}/hsstock/history/{stock_code}.{market}/{period}/{adjust}/{LICENCE}?st={start_date}&et={end_date}" + data = _request(url) + + if data and isinstance(data, list): + kline_data = [] + for item in data: + # 处理日期格式,去掉时间部分 + date_str = item.get('t', '') + if date_str and ' ' in date_str: + date_str = date_str.split(' ')[0] # 只保留日期部分 + kline_data.append({ + 'date': date_str, + 'open': float(item.get('o', 0)), + 'high': float(item.get('h', 0)), + 'low': float(item.get('l', 0)), + 'close': float(item.get('c', 0)), + 'volume': float(item.get('v', 0)), + 'amount': float(item.get('a', 0)), + }) + return {'success': True, 'data': kline_data} + + return {'success': True, 'data': []} + + +# ========== 公司信息 ========== + +def get_company_info(stock_code): + """ + 获取公司简介 + API: https://api.mairuiapi.com/hscp/gsjj/{stock_code}/{licence} + """ + url = f"{BASE_URL}/hscp/gsjj/{stock_code}/{LICENCE}" + data = _request(url) + + if data: + return { + 'success': True, + 'data': { + 'name': data.get('name', ''), + 'industry': data.get('idea', '').split(',')[0] if data.get('idea') else '', + 'list_date': data.get('ldate', ''), + 'issue_price': data.get('sprice', ''), + 'description': data.get('desc', ''), + 'business_scope': data.get('bscope', ''), + } + } + return {'success': False, 'error': '获取失败'} + + +# ========== 财务指标 ========== + +def get_financial_indicators(stock_code): + """ + 获取财务指标 + API: https://api.mairuiapi.com/hscp/cwzb/{stock_code}/{licence} + """ + url = f"{BASE_URL}/hscp/cwzb/{stock_code}/{LICENCE}" + data = _request(url) + + if data and isinstance(data, list) and len(data) > 0: + latest = data[0] # 最新一期 + return { + 'success': True, + 'data': { + 'report_date': latest.get('date', ''), + 'eps': _parse_float(latest.get('tbmg')), # 摊薄每股收益 + 'bps': _parse_float(latest.get('mgjz')), # 每股净资产 + 'roe': _parse_float(latest.get('jzsy')), # 净资产收益率 + 'gross_margin': _parse_float(latest.get('xsml')), # 销售毛利率 + 'net_margin': _parse_float(latest.get('xsjl')), # 销售净利率 + 'revenue_yoy': _parse_float(latest.get('zysr')), # 主营业务收入增长率 + 'profit_yoy': _parse_float(latest.get('jlzz')), # 净利润增长率 + 'debt_ratio': _parse_float(latest.get('zcfzl')), # 资产负债率 + 'current_ratio': _parse_float(latest.get('ldbl')), # 流动比率 + } + } + return {'success': False, 'error': '获取失败'} + + +def _parse_float(value): + """解析浮点数""" + if value is None: + return None + try: + return float(value) + except: + return None + + +# ========== 资金流向 ========== + +def get_fund_flow(stock_code, days=3): + """ + 获取资金流向数据 + API: https://api.mairuiapi.com/hsstock/history/transaction/{stock_code}/{licence}?lt={days} + """ + url = f"{BASE_URL}/hsstock/history/transaction/{stock_code}/{LICENCE}?lt={days}" + data = _request(url) + + if data and isinstance(data, list): + flow_data = [] + for item in data: + # 计算主力净流入 = 主买大单+主买特大单 - 主卖大单-主卖特大单 + main_buy = float(item.get('zmbddcje', 0)) + float(item.get('zmbtdcje', 0)) + main_sell = float(item.get('zmsddcje', 0)) + float(item.get('zmstdcje', 0)) + main_net = main_buy - main_sell + + flow_data.append({ + 'date': datetime.fromtimestamp(item.get('t', 0)).strftime('%Y-%m-%d') if item.get('t') else '', + 'main_net_inflow': main_net, + 'super_buy': float(item.get('zmbtdcje', 0)), + 'super_sell': float(item.get('zmstdcje', 0)), + 'big_buy': float(item.get('zmbddcje', 0)), + 'big_sell': float(item.get('zmsddcje', 0)), + }) + return {'success': True, 'data': flow_data} + + return {'success': True, 'data': []} + + +# ========== 股票列表 ========== + +def get_stock_list(): + """ + 获取股票列表 + API: https://api.mairuiapi.com/hslt/list/{licence} + """ + url = f"{BASE_URL}/hslt/list/{LICENCE}" + data = _request(url, timeout=30) + + if data and isinstance(data, list): + return { + 'success': True, + 'data': [{'code': item.get('dm'), 'name': item.get('mc'), 'market': item.get('jys')} for item in data] + } + return {'success': False, 'error': '获取失败'} + + +# ========== 涨停股池 ========== + +def get_limit_up_stocks(date=None): + """ + 获取涨停股池 + API: https://api.mairuiapi.com/hslt/ztgc/{date}/{licence} + """ + if date is None: + date = datetime.now().strftime('%Y-%m-%d') + + url = f"{BASE_URL}/hslt/ztgc/{date}/{LICENCE}" + data = _request(url) + + if data and isinstance(data, list): + return { + 'success': True, + 'data': [{ + 'code': item.get('dm'), + 'name': item.get('mc'), + 'price': float(item.get('p', 0)), + 'change': float(item.get('zf', 0)), + 'amount': float(item.get('cje', 0)), + 'limit_count': int(item.get('lbc', 0)), + 'first_limit_time': item.get('fbt', ''), + 'industry': item.get('hy', ''), + } for item in data] + } + return {'success': True, 'data': []} diff --git a/stock-html/services/scheduler.py b/stock-html/services/scheduler.py new file mode 100644 index 0000000..582952d --- /dev/null +++ b/stock-html/services/scheduler.py @@ -0,0 +1,637 @@ +""" +模拟交易定时任务调度器 +- 交易日09:35自动执行买入(v7最优买入时点) +- 交易日13:40自动执行卖出(v7最优卖出时点) +- 交易日15:05更新持仓价格 +""" +import threading +import time +from datetime import datetime, date, timedelta +from concurrent.futures import ThreadPoolExecutor, as_completed +import schedule + +from services.stock_algorithms import compute_recommend, get_latest_price + +# 全局变量 +_scheduler_thread = None +_is_running = False + +# ═══════════════════════════════════════════════════════ +# 动态交易日历缓存 +# 通过 akshare 从新浪财经自动获取A股交易日历 +# 包含所有历史及未来交易日,自动适配节假日 +# ═══════════════════════════════════════════════════════ +_trading_dates_cache = set() # 交易日集合 (date objects) +_cache_loaded_date = None # 缓存加载日期,每天最多刷新1次 + + +def _load_trading_calendar(): + """从新浪财经加载A股交易日历到内存缓存""" + global _trading_dates_cache, _cache_loaded_date + try: + import akshare as ak + df = ak.tool_trade_date_hist_sina() + if df is not None and not df.empty: + new_cache = set() + for val in df['trade_date']: + if isinstance(val, date): + new_cache.add(val) + else: + # 字符串格式 'YYYY-MM-DD' + new_cache.add(date.fromisoformat(str(val))) + _trading_dates_cache = new_cache + _cache_loaded_date = date.today() + print(f"[交易日历] 加载成功: {len(_trading_dates_cache)} 个交易日 " + f"(范围: {min(_trading_dates_cache)} ~ {max(_trading_dates_cache)})") + return True + except Exception as e: + print(f"[交易日历] 从新浪获取交易日历失败: {e}") + return False + + +def _ensure_calendar_loaded(): + """确保交易日历已加载且是最新的(每天自动刷新一次)""" + global _cache_loaded_date + today = date.today() + if _trading_dates_cache and _cache_loaded_date == today: + return True # 缓存有效 + # 需要加载/刷新 + return _load_trading_calendar() + + +def is_trading_day(check_date=None): + """判断是否为A股交易日(基于新浪交易日历,自动适配全部节假日)""" + if check_date is None: + check_date = date.today() + + # 快速检查:周末一定不是交易日 + if check_date.weekday() >= 5: + return False + + # 尝试使用动态交易日历 + if _ensure_calendar_loaded() and _trading_dates_cache: + # 检查日期是否超出日历范围(日历通常只覆盖到当年年底) + max_cal_date = max(_trading_dates_cache) + if check_date > max_cal_date: + print(f"[定时任务] ⚠️ {check_date} 超出日历范围({max_cal_date}),按工作日处理") + return True # 超出范围的工作日默认视为交易日 + if check_date in _trading_dates_cache: + return True + else: + print(f"[定时任务] {check_date} 不在交易日历中,非交易日") + return False + + # 降级:日历加载失败时,工作日默认视为交易日(避免误跳过) + print(f"[定时任务] ⚠️ 交易日历不可用,{check_date} 按工作日处理") + return True + + +def is_trading_time(): + """判断当前是否在交易时间内""" + now = datetime.now() + hour = now.hour + minute = now.minute + time_val = hour * 100 + minute + + # 交易时间:9:30-11:30, 13:00-15:00 + if (930 <= time_val <= 1130) or (1300 <= time_val <= 1500): + return True + return False + + +def get_all_users(): + """获取所有启用自动交易的用户""" + from db import get_db + from psycopg2.extras import RealDictCursor + + conn = get_db() + if not conn: + return [] + + try: + cur = conn.cursor(cursor_factory=RealDictCursor) + cur.execute(""" + SELECT u.id as user_id, u.username, c.trade_quantity + FROM users u + LEFT JOIN sim_config c ON u.id = c.user_id + WHERE c.auto_trade_enabled = true OR c.auto_trade_enabled IS NULL + """) + return cur.fetchall() + except Exception as e: + print(f"[定时任务] 获取用户列表失败: {e}") + return [] + finally: + conn.close() + + +# 自定义股票列表(100只精选股票) +CUSTOM_STOCKS = [ + '000001', '000002', '000063', '000100', '000157', '000333', '000338', '000425', '000538', '000568', + '000596', '000625', '000651', '000661', '000703', '000725', '000768', '000776', '000858', '000876', + '002007', '002024', '002027', '002049', '002120', '002142', '002179', '002230', '002236', '002241', + '002271', '002304', '002352', '002371', '002415', '002460', '002466', '002475', '002493', '002555', + '002594', '002602', '002607', '002624', '002714', '002736', '002812', '002841', '002916', '002938', + '300003', '300014', '300015', '300033', '300059', '300122', '300124', '300136', '300142', '300144', + '300347', '300408', '300433', '300496', '300498', '300502', '300529', '300558', '300601', '300628', + '300750', '300760', '300782', '300896', '300948', '600000', '600009', '600016', '600028', '600030', + '600036', '600048', '600050', '600061', '600104', '600111', '600115', '600132', '600150', '600196', + '600276', '600309', '600332', '600346', '600352', '600362', '600406', '600436', '600519', '600585' +] + + +def get_hot_stocks(limit=50): + """获取热门股票列表(自定义100只精选股票) + 东方财富人气榜API已不可用(腾讯云封锁),仅使用自定义列表 + """ + stocks = [] + seen_codes = set() + + for code in CUSTOM_STOCKS: + if code not in seen_codes: + stocks.append({'code': code, 'name': ''}) + seen_codes.add(code) + + print(f"[定时任务] 自定义精选 {len(stocks)} 只股票待扫描") + return stocks + + +def _compute_recommend(signal_status, indicators, triggered_count, is_holding): + """统一推荐逻辑 — 委托给 services.stock_algorithms.compute_recommend""" + return compute_recommend(signal_status, indicators, triggered_count, is_holding) + + +def execute_auto_trade_for_user(user_id, trade_quantity=1000, scan_date=None): + """基于统一推荐算法的自动交易(与全景扫描推荐使用完全相同的逻辑) + 买入: _compute_recommend 返回 '买入' 的股票(主升浪/底背离+龙抬头) + 加仓: _compute_recommend 返回 '加仓' 的持仓股(主升浪信号) + 卖出: _compute_recommend 返回 '卖出' 的持仓股(MACD死叉) + + scan_date: 使用哪天的扫描数据, None则自动选择最近可用的 + """ + from db import get_db + from psycopg2.extras import RealDictCursor + + print(f"[定时任务] 开始为用户{user_id}执行策略交易(统一推荐算法)...") + + conn = get_db() + if not conn: + return {'error': '数据库连接失败'} + + try: + cur = conn.cursor(cursor_factory=RealDictCursor) + today = date.today() + now = datetime.now().time() + results = [] + + # 1. 获取用户持仓 + cur.execute(""" + SELECT stock_code, stock_name, quantity, avg_cost::float + FROM sim_positions WHERE user_id = %s AND quantity > 0 + """, (user_id,)) + positions = cur.fetchall() + holding_codes = {p['stock_code'] for p in positions} + + # 2. 读取扫描结果(指定日期或自动查找最近可用的) + if scan_date: + cur.execute(""" + SELECT code, name, triggered_count, signal_status, indicators + FROM stock_signal_scan WHERE scan_date = %s + """, (scan_date,)) + else: + cur.execute(""" + SELECT code, name, triggered_count, signal_status, indicators + FROM stock_signal_scan + WHERE scan_date = ( + SELECT MAX(scan_date) FROM stock_signal_scan + WHERE scan_date <= %s + ) + """, (today,)) + scan_rows = cur.fetchall() + scan_map = {r['code']: r for r in scan_rows} + + used_date = scan_date or '最近' + if not scan_map: + print(f"[定时任务] 无可用扫描数据(scan_date={used_date}),跳过交易") + return {'success': True, 'results': [], 'message': '无可用扫描数据'} + + print(f"[定时任务] 使用扫描数据: {used_date}, 共{len(scan_map)}只股票") + + # ===== 卖出逻辑 ===== + # 持仓股: 使用 _compute_recommend(is_holding=True) 判断卖出 + for pos in positions: + code = pos['stock_code'] + scan = scan_map.get(code) + if not scan: + continue + + signal_type, display, reason, rate = _compute_recommend( + scan['signal_status'], scan['indicators'], + scan['triggered_count'], is_holding=True + ) + + if signal_type == 'sell': + price = _get_latest_price(code) + if not price or price <= 0: + continue + qty = min(trade_quantity, pos['quantity']) + realized_pnl = (price - pos['avg_cost']) * qty + + cur.execute(""" + INSERT INTO sim_trades + (user_id, stock_code, stock_name, trade_type, price, quantity, + trade_date, trade_time, recommend_rate, signal_reason) + VALUES (%s, %s, %s, 'sell', %s, %s, %s, %s, %s, %s) + """, (user_id, code, pos['stock_name'], price, qty, + today, now, rate, reason)) + + new_qty = pos['quantity'] - qty + if new_qty > 0: + cur.execute(""" + UPDATE sim_positions SET + quantity=%s, total_cost=%s, current_price=%s, updated_at=NOW() + WHERE user_id=%s AND stock_code=%s + """, (new_qty, pos['avg_cost'] * new_qty, price, user_id, code)) + else: + cur.execute(""" + UPDATE sim_positions SET + quantity=0, total_cost=0, current_price=%s, updated_at=NOW() + WHERE user_id=%s AND stock_code=%s + """, (price, user_id, code)) + + cur.execute(""" + INSERT INTO sim_daily_stats (user_id, stat_date, realized_profit, trade_count) + VALUES (%s, %s, %s, 1) + ON CONFLICT (user_id, stat_date) DO UPDATE SET + realized_profit = sim_daily_stats.realized_profit + %s, + trade_count = sim_daily_stats.trade_count + 1 + """, (user_id, today, realized_pnl, realized_pnl)) + + results.append({ + 'type': 'sell', 'code': code, 'name': pos['stock_name'], + 'price': price, 'quantity': qty, 'pnl': realized_pnl, 'reason': reason + }) + print(f"[策略交易] 卖出 {code} {pos['stock_name']} {qty}股@{price} | {reason}") + + # ===== 买入逻辑 ===== + # 非持仓股: 使用 _compute_recommend(is_holding=False) 判断买入 + buy_candidates = [] + for code, scan in scan_map.items(): + if code in holding_codes: + continue + signal_type, display, reason, rate = _compute_recommend( + scan['signal_status'], scan['indicators'], + scan['triggered_count'], is_holding=False + ) + if signal_type == 'buy': + buy_candidates.append({ + 'code': code, 'name': scan['name'] or '', + 'recommend_rate': rate, + 'reason': reason, + }) + + # 按推荐率降序排序,取top 3 + buy_candidates.sort(key=lambda x: x['recommend_rate'], reverse=True) + buy_candidates = buy_candidates[:3] + + for cand in buy_candidates: + code = cand['code'] + cur.execute(""" + SELECT COUNT(*) as cnt FROM sim_trades + WHERE user_id=%s AND stock_code=%s AND trade_date=%s AND trade_type='buy' + """, (user_id, code, today)) + if cur.fetchone()['cnt'] > 0: + continue + + price = _get_latest_price(code) + if not price or price <= 0: + continue + + cur.execute(""" + INSERT INTO sim_trades + (user_id, stock_code, stock_name, trade_type, price, quantity, + trade_date, trade_time, recommend_rate, signal_reason) + VALUES (%s, %s, %s, 'buy', %s, %s, %s, %s, %s, %s) + """, (user_id, code, cand['name'], price, trade_quantity, + today, now, cand['recommend_rate'], cand['reason'])) + + cur.execute(""" + INSERT INTO sim_positions + (user_id, stock_code, stock_name, quantity, avg_cost, total_cost, current_price) + VALUES (%s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (user_id, stock_code) DO UPDATE SET + quantity = sim_positions.quantity + EXCLUDED.quantity, + total_cost = sim_positions.total_cost + EXCLUDED.total_cost, + avg_cost = (sim_positions.total_cost + EXCLUDED.total_cost) / + (sim_positions.quantity + EXCLUDED.quantity), + current_price = EXCLUDED.current_price, + stock_name = COALESCE(EXCLUDED.stock_name, sim_positions.stock_name), + updated_at = NOW() + """, (user_id, code, cand['name'], trade_quantity, price, + price * trade_quantity, price)) + + results.append({ + 'type': 'buy', 'code': code, 'name': cand['name'], + 'price': price, 'quantity': trade_quantity, 'reason': cand['reason'] + }) + print(f"[策略交易] 买入 {code} {cand['name']} {trade_quantity}股@{price} | {cand['reason']}") + + # ===== 加仓逻辑 ===== + # 持仓股: 使用 _compute_recommend(is_holding=True) 判断加仓 + cur.execute(""" + SELECT stock_code, stock_name, quantity, avg_cost::float + FROM sim_positions WHERE user_id = %s AND quantity > 0 + """, (user_id,)) + current_positions = cur.fetchall() + + for pos in current_positions: + code = pos['stock_code'] + scan = scan_map.get(code) + if not scan: + continue + + signal_type, display, reason, rate = _compute_recommend( + scan['signal_status'], scan['indicators'], + scan['triggered_count'], is_holding=True + ) + if display != '加仓': + continue + + cur.execute(""" + SELECT COUNT(*) as cnt FROM sim_trades + WHERE user_id=%s AND stock_code=%s AND trade_date=%s AND trade_type='buy' + """, (user_id, code, today)) + if cur.fetchone()['cnt'] > 0: + continue + + price = _get_latest_price(code) + if not price or price <= 0: + continue + + add_qty = trade_quantity // 2 + + cur.execute(""" + INSERT INTO sim_trades + (user_id, stock_code, stock_name, trade_type, price, quantity, + trade_date, trade_time, recommend_rate, signal_reason) + VALUES (%s, %s, %s, 'buy', %s, %s, %s, %s, %s, %s) + """, (user_id, code, pos['stock_name'], price, add_qty, + today, now, rate, reason)) + + cur.execute(""" + UPDATE sim_positions SET + quantity = quantity + %s, + total_cost = total_cost + %s, + avg_cost = (total_cost + %s) / (quantity + %s), + current_price = %s, + updated_at = NOW() + WHERE user_id = %s AND stock_code = %s + """, (add_qty, price * add_qty, price * add_qty, add_qty, + price, user_id, code)) + + results.append({ + 'type': 'buy', 'code': code, 'name': pos['stock_name'], + 'price': price, 'quantity': add_qty, 'reason': reason + }) + print(f"[策略交易] 加仓 {code} {pos['stock_name']} {add_qty}股@{price} | {reason}") + + conn.commit() + + buy_count = len([r for r in results if r['type'] == 'buy']) + sell_count = len([r for r in results if r['type'] == 'sell']) + print(f"[策略交易] 用户{user_id}完成: 买入{buy_count}笔, 卖出{sell_count}笔") + + return {'success': True, 'results': results} + except Exception as e: + conn.rollback() + import traceback + traceback.print_exc() + return {'error': str(e)} + finally: + conn.close() + + +def _get_latest_price(stock_code): + """获取股票最新价格 — 委托给 services.stock_algorithms.get_latest_price""" + return get_latest_price(stock_code) + + +def update_positions_price_for_user(user_id): + """更新用户持仓的当前价格(收盘时调用)— 使用腾讯财经API""" + from db import get_db + from psycopg2.extras import RealDictCursor + + conn = get_db() + if not conn: + return + + try: + cur = conn.cursor(cursor_factory=RealDictCursor) + + # 获取持仓 + cur.execute(""" + SELECT stock_code FROM sim_positions + WHERE user_id = %s AND quantity > 0 + """, (user_id,)) + positions = cur.fetchall() + + # 批量获取持仓股票的实时价格(使用腾讯财经API,兼容腾讯云) + codes = [pos['stock_code'] for pos in positions] + if codes: + try: + import requests as _req + tencent_codes = [] + for c in codes: + if c.startswith('6'): + tencent_codes.append(f'sh{c}') + else: + tencent_codes.append(f'sz{c}') + _r = _req.get(f'http://qt.gtimg.cn/q={",".join(tencent_codes)}', + timeout=10, headers={'Referer': 'https://finance.qq.com'}) + if _r.status_code == 200: + for line in _r.text.strip().split(';'): + if '\"' not in line: + continue + fields = line.split('\"')[1].split('~') + if len(fields) > 3 and fields[3]: + stock_code = fields[2] + price = float(fields[3]) + if price > 0: + cur.execute(""" + UPDATE sim_positions SET + current_price = %s, updated_at = NOW() + WHERE user_id = %s AND stock_code = %s + """, (price, user_id, stock_code)) + except Exception as e: + print(f"[定时任务] 腾讯API批量更新价格失败: {e}") + + # 更新每日统计 + today = date.today() + cur.execute(""" + SELECT + COALESCE(SUM(quantity * current_price), 0) as market_value, + COALESCE(SUM(total_cost), 0) as total_cost, + COALESCE(SUM(quantity * current_price - total_cost), 0) as unrealized + FROM sim_positions + WHERE user_id = %s AND quantity > 0 + """, (user_id,)) + stats = cur.fetchone() + + cur.execute(""" + INSERT INTO sim_daily_stats + (user_id, stat_date, total_market_value, total_cost, unrealized_profit) + VALUES (%s, %s, %s, %s, %s) + ON CONFLICT (user_id, stat_date) DO UPDATE SET + total_market_value = EXCLUDED.total_market_value, + total_cost = EXCLUDED.total_cost, + unrealized_profit = EXCLUDED.unrealized_profit + """, (user_id, today, stats['market_value'], stats['total_cost'], stats['unrealized'])) + + conn.commit() + print(f"[定时任务] 用户{user_id}持仓价格已更新") + except Exception as e: + conn.rollback() + print(f"[定时任务] 更新用户{user_id}持仓价格失败: {e}") + finally: + conn.close() + + +def job_morning_trade(): + """早盘交易任务(09:35执行 — v7最优买入时点) + 使用昨天收盘后的全景扫描数据做买入/卖出决策 + 优先使用智能引擎(smart_trade_engine),降级到旧引擎(execute_auto_trade_for_user) + """ + print(f"[定时任务] ===== 早盘交易任务开始 {datetime.now()} =====") + + if not is_trading_day(): + print("[定时任务] 今天不是交易日,跳过") + return + + users = get_all_users() + print(f"[定时任务] 找到{len(users)}个用户需要执行自动交易") + + for user in users: + user_id = user['user_id'] + # 尝试使用智能引擎 + try: + from services.smart_trade_engine import execute_smart_trade + from db import get_db + conn = get_db() + if conn: + result = execute_smart_trade(conn, user_id, scan_date=None) + conn.close() + if result.get('success'): + print(f"[定时任务] 用户{user_id} 智能引擎执行成功 " + f"(算法:{result.get('algo','?')}, 信号:{result.get('signals',0)})") + continue + except Exception as e: + print(f"[定时任务] 用户{user_id} 智能引擎异常,降级到旧引擎: {e}") + + # 降级:使用旧引擎 + trade_quantity = user.get('trade_quantity') or 1000 + execute_auto_trade_for_user(user_id, trade_quantity, scan_date=None) + + print(f"[定时任务] ===== 早盘交易任务结束 {datetime.now()} =====") + + +def job_afternoon_trade(): + """午后交易任务(13:40执行 — v7最优卖出时点) + 使用当天中午的全景扫描数据做买入/卖出决策 + """ + print(f"[定时任务] ===== 午后交易任务开始 {datetime.now()} =====") + + if not is_trading_day(): + print("[定时任务] 今天不是交易日,跳过") + return + + users = get_all_users() + + # 1. 先执行自动交易(使用今天中午11:50生成的扫描数据) + today = date.today() + print(f"[定时任务] 找到{len(users)}个用户需要执行午后自动交易") + for user in users: + user_id = user['user_id'] + # 尝试使用智能引擎 + try: + from services.smart_trade_engine import execute_smart_trade + from db import get_db + conn = get_db() + if conn: + result = execute_smart_trade(conn, user_id, scan_date=today) + conn.close() + if result.get('success'): + print(f"[定时任务] 用户{user_id} 午后智能引擎执行成功") + continue + except Exception as e: + print(f"[定时任务] 用户{user_id} 智能引擎异常,降级: {e}") + trade_quantity = user.get('trade_quantity') or 1000 + execute_auto_trade_for_user(user_id, trade_quantity, scan_date=today) + + # 2. 更新持仓价格 + print(f"[定时任务] 更新{len(users)}个用户持仓价格") + for user in users: + user_id = user['user_id'] + update_positions_price_for_user(user_id) + + print(f"[定时任务] ===== 午后交易任务结束 {datetime.now()} =====") + + +def run_scheduler(): + """运行定时任务调度器""" + global _is_running + + # 设置定时任务 — v7最优时点: 09:35买入 / 13:40卖出 + schedule.every().day.at("09:35").do(job_morning_trade) + schedule.every().day.at("13:40").do(job_afternoon_trade) + schedule.every().day.at("15:05").do(trigger_closing_update) # 收盘更新持仓价格 + + print("[定时任务] 调度器已启动 (v7最优时点)") + print("[定时任务] - 09:35 早盘交易(使用昨日扫描数据 — 最优买入时点)") + print("[定时任务] - 13:40 午后交易(使用当日中午扫描数据 — 最优卖出时点)") + print("[定时任务] - 15:05 收盘更新持仓价格") + + _is_running = True + while _is_running: + schedule.run_pending() + time.sleep(30) # 每30秒检查一次 + + +def start_scheduler(): + """启动定时任务调度器(在后台线程中运行)""" + global _scheduler_thread, _is_running + + if _scheduler_thread is not None and _scheduler_thread.is_alive(): + print("[定时任务] 调度器已在运行中") + return + + _scheduler_thread = threading.Thread(target=run_scheduler, daemon=True) + _scheduler_thread.start() + print("[定时任务] 后台调度器线程已启动") + + +def stop_scheduler(): + """停止定时任务调度器""" + global _is_running + _is_running = False + print("[定时任务] 调度器已停止") + + +# 手动触发任务(用于测试) +def trigger_morning_trade(): + """手动触发早盘交易任务""" + job_morning_trade() + + +def trigger_afternoon_trade(): + """手动触发午后交易任务""" + job_afternoon_trade() + + +def trigger_closing_update(): + """手动触发收盘更新(仅更新持仓价格)""" + from db import get_db + if not is_trading_day(): + print("[定时任务] 今天不是交易日,跳过") + return + users = get_all_users() + for user in users: + update_positions_price_for_user(user['user_id']) diff --git a/stock-html/services/signal_detector.py b/stock-html/services/signal_detector.py new file mode 100644 index 0000000..c31947b --- /dev/null +++ b/stock-html/services/signal_detector.py @@ -0,0 +1,663 @@ +""" +交易信号检测模块(numpy向量化优化版) +实现7个交易信号:主升浪、日线底背离、龙抬头、真龙、短底背离、老鼠仓、反弹 + +信号按胜率排名: +1. 主升浪 85% - MACD零上金叉 +2. 日线底背离 80% - 价格新低但MACD不新低(20日版) +3. 龙抬头 75% - SKDJ超跌金叉 +4. 真龙 70% - 趋势启动确认 +5. 短底背离 65% - 短周期底背离(10日版) +6. 老鼠仓 60% - 盘中急跌后快速回收 +7. 反弹 55% - EMA3上穿EMA21 + +优化要点: +- 所有信号检测函数使用 .values numpy原生数组替代 pandas .iloc +- numpy arr[i] 访问 ~50ns,pandas iloc[i] 访问 ~5μs,提升 ~100x +- 底背离函数使用 np.argmin 替代 pandas idxmin +- detect_all_signals 智能跳过已是 float 的类型转换 +- _check_all_signal_status 使用 numpy 数组切片替代 pandas 切片 +""" +import pandas as pd +import numpy as np +from services.technical_indicators import calc_all_indicators + + +def detect_main_rising_wave(df, lookback=5): + """ + 主升浪信号(胜率85%)— numpy优化版 + 条件:MACD零上金叉 —— DIF和DEA都在零轴上方,DIF从下往上穿越DEA + 含义:趋势走好,进入加速拉升阶段 + """ + signals = [] + if len(df) < 30: + return signals + + dif = df['dif'].values + dea = df['dea'].values + dates = df['date'].values + closes = df['close'].values + n = len(dif) + start = max(1, n - lookback) + + for i in range(start, n): + if dif[i] > 0 and dea[i] > 0 and dif[i - 1] <= dea[i - 1] and dif[i] > dea[i]: + signals.append({ + 'date': str(dates[i]), + 'type': 'main_rising_wave', + 'name': '主升浪', + 'direction': 'buy', + 'strength': 85, + 'price': float(closes[i]), + 'description': f"MACD零上金叉: DIF={dif[i]:.3f}, DEA={dea[i]:.3f},进入加速拉升阶段", + }) + return signals + + +def detect_daily_bottom_divergence(df, lookback=5, window=20): + """ + 日线底背离信号(胜率80%)— numpy优化版 + 条件:价格创20日新低,但MACD的DIF未创对应新低 + 含义:真正跌透,迎来大级别反转 + """ + signals = [] + if len(df) < window + 10: + return signals + + close = df['close'].values.astype(np.float64) + dif = df['dif'].values.astype(np.float64) + dates = df['date'].values + n = len(close) + start = max(window, n - lookback) + + for i in range(start, n): + window_slice = close[i - window:i + 1] + curr_price = close[i] + price_min = window_slice.min() + + if curr_price > price_min * 1.01: + continue + + # 当前日必须是窗口内的实际最低点(等价于 idxmin() == index[-1]) + if np.argmin(window_slice) == len(window_slice) - 1: + dif_window = dif[i - window:i] + if len(dif_window) == 0: + continue + dif_at_prev_lows = dif_window.min() + curr_dif = dif[i] + + if curr_dif > dif_at_prev_lows and curr_dif < 0: + signals.append({ + 'date': str(dates[i]), + 'type': 'daily_bottom_divergence', + 'name': '日线底背离', + 'direction': 'buy', + 'strength': 80, + 'price': float(curr_price), + 'description': f"价格创{window}日新低,但MACD的DIF未创新低(DIF={curr_dif:.3f}),大级别反转信号", + }) + return signals + + +def detect_dragon_head(df, lookback=5): + """ + 龙抬头信号(胜率75%)— numpy优化版 + 条件:SKDJ的K值从超跌区域(<20)发生金叉(K上穿D),且信号稳定 + 含义:短线起爆点,反弹稳定性强 + """ + signals = [] + if len(df) < 20: + return signals + + sk = df['skdj_k'].values.astype(np.float64) + sd = df['skdj_d'].values.astype(np.float64) + dates = df['date'].values + closes = df['close'].values + n = len(sk) + start = max(2, n - lookback) + + for i in range(start, n): + oversold = sk[i - 1] < 20 or sk[i] < 30 + golden_cross = sk[i - 1] <= sd[i - 1] and sk[i] > sd[i] + + stable = True + if i >= 3: + recent_k = sk[i - 2:i + 1] + # ddof=1 与 pandas Series.std() 保持一致 + stable = np.std(recent_k, ddof=1) < 15 + + if oversold and golden_cross and stable: + signals.append({ + 'date': str(dates[i]), + 'type': 'dragon_head', + 'name': '龙抬头', + 'direction': 'buy', + 'strength': 75, + 'price': float(closes[i]), + 'description': f"SKDJ超跌金叉: K={sk[i]:.1f}, D={sd[i]:.1f},短线起爆点", + }) + return signals + + +def detect_true_dragon(df, lookback=5): + """ + 真龙信号(胜率70%)— numpy优化版 + 条件:价格突破MA20,MA5上穿MA20(金叉),MACD柱由负转正,成交量放大 + 含义:中期趋势刚刚启动 + """ + signals = [] + if len(df) < 25: + return signals + + close = df['close'].values.astype(np.float64) + ma5 = df['ma5'].values.astype(np.float64) + ma20 = df['ma20'].values.astype(np.float64) + macd = df['macd'].values.astype(np.float64) + volume = df['volume'].values.astype(np.float64) + dates = df['date'].values + n = len(close) + start = max(2, n - lookback) + + for i in range(start, n): + price_above_ma20 = close[i] > ma20[i] + ma5_cross_ma20 = (ma5[i - 1] <= ma20[i - 1]) and (ma5[i] > ma20[i]) + ma5_above_ma20 = ma5[i] > ma20[i] + macd_turn_positive = macd[i] > 0 and macd[i - 1] <= 0 + + vol_start = max(0, i - 10) + vol_avg = volume[vol_start:i].mean() if i > vol_start else 0.0 + volume_up = volume[i] > vol_avg * 1.2 if vol_avg > 0 else False + + conditions_met = sum([price_above_ma20, ma5_cross_ma20 or ma5_above_ma20, macd_turn_positive, volume_up]) + + if conditions_met >= 3 and price_above_ma20: + desc_parts = [] + if ma5_cross_ma20: + desc_parts.append("MA5金叉MA20") + if macd_turn_positive: + desc_parts.append("MACD翻红") + if volume_up: + desc_parts.append("放量") + signals.append({ + 'date': str(dates[i]), + 'type': 'true_dragon', + 'name': '真龙', + 'direction': 'buy', + 'strength': 70, + 'price': float(close[i]), + 'description': f"趋势启动: {'+'.join(desc_parts)},中期趋势确立", + }) + return signals + + +def detect_short_bottom_divergence(df, lookback=5, window=10): + """ + 短底背离信号(胜率65%)— numpy优化版 + 条件:价格创10日新低,但MACD的DIF未创对应新低 + 含义:小级别反弹,灵敏度高但力度偏弱 + """ + signals = [] + if len(df) < window + 5: + return signals + + close = df['close'].values.astype(np.float64) + dif = df['dif'].values.astype(np.float64) + dates = df['date'].values + n = len(close) + start = max(window, n - lookback) + + for i in range(start, n): + window_slice = close[i - window:i + 1] + curr_price = close[i] + price_min = window_slice.min() + + if curr_price > price_min * 1.01: + continue + + # 当前日必须是窗口内的实际最低点 + if np.argmin(window_slice) == len(window_slice) - 1: + dif_window = dif[i - window:i] + if len(dif_window) == 0: + continue + dif_at_prev_lows = dif_window.min() + curr_dif = dif[i] + + if curr_dif > dif_at_prev_lows: + signals.append({ + 'date': str(dates[i]), + 'type': 'short_bottom_divergence', + 'name': '短底背离', + 'direction': 'buy', + 'strength': 65, + 'price': float(curr_price), + 'description': f"价格创{window}日新低,但DIF未新低(DIF={curr_dif:.3f}),小级别反弹信号", + }) + return signals + + +def detect_rat_trading(df, lookback=5): + """ + 老鼠仓信号(胜率60%)— numpy优化版 + 条件:盘中急跌(最低价大幅低于开盘价),但收盘收回(收盘价接近或高于开盘价),且成交量放大 + 含义:主力偷偷吸筹,上涨不具备即时性 + """ + signals = [] + if len(df) < 10: + return signals + + close = df['close'].values.astype(np.float64) + open_p = df['open'].values.astype(np.float64) + low = df['low'].values.astype(np.float64) + high = df['high'].values.astype(np.float64) + volume = df['volume'].values.astype(np.float64) + dates = df['date'].values + n = len(close) + start = max(1, n - lookback) + + for i in range(start, n): + if open_p[i] <= 0: + continue + + drop_from_open = (low[i] - open_p[i]) / open_p[i] * 100 + hl_diff = high[i] - low[i] + recovery = (close[i] - low[i]) / hl_diff * 100 if hl_diff != 0 else 50.0 + close_vs_open = (close[i] - open_p[i]) / open_p[i] * 100 + + vol_start = max(0, i - 10) + vol_avg = volume[vol_start:i].mean() if i > vol_start else 0.0 + volume_up = volume[i] > vol_avg * 1.3 if vol_avg > 0 else False + + if drop_from_open < -3 and recovery > 60 and close_vs_open > -1 and volume_up: + signals.append({ + 'date': str(dates[i]), + 'type': 'rat_trading', + 'name': '老鼠仓', + 'direction': 'buy', + 'strength': 60, + 'price': float(close[i]), + 'description': f"盘中急跌{drop_from_open:.1f}%后回收{recovery:.0f}%,放量吸筹信号", + }) + return signals + + +def detect_rebound(df, lookback=5): + """ + 反弹信号(胜率55%)— numpy优化版 + 条件:EMA3从下向上穿越EMA21 + 含义:普通均线金叉,震荡市适用、熊市易现假反弹 + """ + signals = [] + if len(df) < 25: + return signals + + ema3 = df['ema3'].values.astype(np.float64) + ema21 = df['ema21'].values.astype(np.float64) + dates = df['date'].values + closes = df['close'].values + n = len(ema3) + start = max(1, n - lookback) + + for i in range(start, n): + if ema3[i - 1] <= ema21[i - 1] and ema3[i] > ema21[i]: + signals.append({ + 'date': str(dates[i]), + 'type': 'rebound', + 'name': '反弹', + 'direction': 'buy', + 'strength': 55, + 'price': float(closes[i]), + 'description': f"EMA3上穿EMA21: EMA3={ema3[i]:.2f}, EMA21={ema21[i]:.2f},均线金叉反弹", + }) + return signals + + +def detect_all_signals(df, lookback=5): + """ + 检测所有7个交易信号(优化版) + + 参数: + df: 包含 date, open, high, low, close, volume 列的DataFrame + lookback: 向后检测的天数(默认检测最近5天) + + 返回: + dict: { + 'signals': [...], # 检测到的所有信号列表 + 'latest_signals': [...], # 最新一天的信号 + 'signal_summary': {...}, # 信号统计摘要 + 'indicators': {...} # 最新技术指标值 + } + + 优化:智能类型转换,已是 float64 的列直接跳过 + """ + required_cols = {'date', 'open', 'high', 'low', 'close', 'volume'} + if not required_cols.issubset(set(df.columns)): + missing = required_cols - set(df.columns) + return {'error': f'缺少必要列: {missing}', 'signals': [], 'latest_signals': []} + + df = df.copy() + # 智能类型转换:仅在列不是 float 时才做转换(本地DB数据已是 float64,跳过) + for col in ['open', 'high', 'low', 'close', 'volume']: + if not np.issubdtype(df[col].dtype, np.floating): + df[col] = pd.to_numeric(df[col], errors='coerce').fillna(0).astype(np.float64) + + df = calc_all_indicators(df) + + all_signals = [] + all_signals.extend(detect_main_rising_wave(df, lookback)) + all_signals.extend(detect_daily_bottom_divergence(df, lookback)) + all_signals.extend(detect_dragon_head(df, lookback)) + all_signals.extend(detect_true_dragon(df, lookback)) + all_signals.extend(detect_short_bottom_divergence(df, lookback)) + all_signals.extend(detect_rat_trading(df, lookback)) + all_signals.extend(detect_rebound(df, lookback)) + + all_signals.sort(key=lambda x: (-x['strength'], x['date']), reverse=False) + + latest_date = str(df['date'].values[-1]) if len(df) > 0 else '' + latest_signals = [s for s in all_signals if s['date'] == latest_date] + + signal_summary = { + 'total_signals': len(all_signals), + 'latest_date': latest_date, + 'latest_count': len(latest_signals), + 'signal_types': {}, + } + for s in all_signals: + t = s['type'] + if t not in signal_summary['signal_types']: + signal_summary['signal_types'][t] = 0 + signal_summary['signal_types'][t] += 1 + + indicators = {} + if len(df) > 0: + last = df.iloc[-1] + indicators = { + 'macd': {'dif': round(float(last.get('dif', 0)), 4), + 'dea': round(float(last.get('dea', 0)), 4), + 'macd': round(float(last.get('macd', 0)), 4)}, + 'skdj': {'k': round(float(last.get('skdj_k', 0)), 2), + 'd': round(float(last.get('skdj_d', 0)), 2)}, + 'kdj': {'k': round(float(last.get('kdj_k', 0)), 2), + 'd': round(float(last.get('kdj_d', 0)), 2), + 'j': round(float(last.get('kdj_j', 0)), 2)}, + 'ema': {'ema3': round(float(last.get('ema3', 0)), 2), + 'ema21': round(float(last.get('ema21', 0)), 2)}, + 'ma': {'ma5': round(float(last.get('ma5', 0)), 2), + 'ma10': round(float(last.get('ma10', 0)), 2), + 'ma20': round(float(last.get('ma20', 0)), 2)}, + } + + signal_status = _check_all_signal_status(df) + + return { + 'signals': all_signals, + 'latest_signals': latest_signals, + 'signal_summary': signal_summary, + 'indicators': indicators, + 'signal_status': signal_status, + } + + +def _check_all_signal_status(df): + """ + 检查7个信号的当前状态,返回每个信号的就绪程度和说明(numpy优化版) + """ + if len(df) < 30: + return [] + + n = len(df) + last = df.iloc[-1] + prev = df.iloc[-2] if n > 1 else last + + dif = float(last.get('dif', 0)) + dea = float(last.get('dea', 0)) + macd_val = float(last.get('macd', 0)) + prev_dif = float(prev.get('dif', 0)) + prev_dea = float(prev.get('dea', 0)) + sk = float(last.get('skdj_k', 50)) + sd = float(last.get('skdj_d', 50)) + prev_sk = float(prev.get('skdj_k', 50)) + prev_sd = float(prev.get('skdj_d', 50)) + ema3 = float(last.get('ema3', 0)) + ema21 = float(last.get('ema21', 0)) + prev_ema3 = float(prev.get('ema3', 0)) + prev_ema21 = float(prev.get('ema21', 0)) + close = float(last.get('close', 0)) + open_p = float(last.get('open', 0)) + low = float(last.get('low', 0)) + high = float(last.get('high', 0)) + ma5 = float(last.get('ma5', 0)) + ma20 = float(last.get('ma20', 0)) + + status = [] + + # 1. 主升浪 + above_zero = dif > 0 and dea > 0 + golden = prev_dif <= prev_dea and dif > dea + triggered = bool(above_zero and golden) + if triggered: + desc = f"✅ 已触发!DIF={dif:.3f}>0, DEA={dea:.3f}>0, DIF上穿DEA" + elif dif > 0 and dea > 0: + desc = f"DIF和DEA均在零上,等待DIF上穿DEA(差值{dif-dea:.3f})" + elif dif > dea: + desc = f"DIF已在DEA上方,但需等待两者都转正(DIF={dif:.3f})" + else: + desc = f"DIF={dif:.3f}, DEA={dea:.3f},均在零下,距离触发较远" + status.append({ + 'type': 'main_rising_wave', 'name': '主升浪', 'strength': 85, + 'triggered': triggered, 'description': desc, + 'readiness': _calc_readiness(dif, dea, 'main_rising_wave') + }) + + # 2. 日线底背离 — 使用numpy数组切片替代pandas切片 + close_arr = df['close'].values.astype(np.float64) + dif_arr = df['dif'].values.astype(np.float64) + w20_start = max(0, n - 21) + window_20 = close_arr[w20_start:] + price_min_20 = float(window_20.min()) + dif_w20 = dif_arr[w20_start:n - 1] if n > w20_start + 1 else dif_arr[:max(0, n - 1)] + dif_min_20 = float(dif_w20.min()) if len(dif_w20) > 0 else 0.0 + at_low = close <= price_min_20 * 1.01 + is_actual_min = bool(np.argmin(window_20) == len(window_20) - 1) + dif_diverge = dif > dif_min_20 and dif < 0 + triggered = bool(at_low and is_actual_min and dif_diverge) + if triggered: + desc = f"✅ 已触发!价格接近20日新低,DIF({dif:.3f})高于前低({dif_min_20:.3f})" + elif at_low and not is_actual_min: + desc = f"价格接近20日低位({price_min_20:.2f}),但非当前最低点" + elif at_low: + desc = f"价格在20日低位,但DIF也在低位(DIF={dif:.3f}),暂无背离" + elif dif < 0: + desc = f"DIF在零下({dif:.3f}),需等待价格下探至20日新低({price_min_20:.2f})附近" + else: + desc = f"DIF={dif:.3f}在零上,价格距20日低点{price_min_20:.2f}较远" + status.append({ + 'type': 'daily_bottom_divergence', 'name': '日线底背离', 'strength': 80, + 'triggered': triggered, 'description': desc, + 'readiness': _calc_readiness_divergence(close, price_min_20, dif, dif_min_20) + }) + + # 3. 龙抬头 + oversold = prev_sk < 20 or sk < 30 + sk_cross = prev_sk <= prev_sd and sk > sd + # 稳定性检查:与检测函数一致,最近3根K值标准差 < 15 + sk_arr = df['skdj_k'].values.astype(np.float64) + stable = True + if len(sk_arr) >= 3: + # ddof=1 与 pandas Series.std() 保持一致 + stable = float(np.std(sk_arr[-3:], ddof=1)) < 15 + triggered = bool(oversold and sk_cross and stable) + if triggered: + desc = f"✅ 已触发!SKDJ超跌金叉 K={sk:.1f}, D={sd:.1f}" + elif sk < 20: + desc = f"K={sk:.1f}在超卖区(<20),等待K上穿D(K-D={sk-sd:.1f})" + elif sk < 30: + desc = f"K={sk:.1f}接近超卖区(<20),继续下探可能触发" + elif sk < 50: + desc = f"K={sk:.1f}在中位,距超卖区(K<20)还有较大距离" + else: + desc = f"K={sk:.1f}偏高,远离超卖区,不满足条件" + status.append({ + 'type': 'dragon_head', 'name': '龙抬头', 'strength': 75, + 'triggered': triggered, 'description': desc, + 'readiness': _calc_readiness_dragon(sk, sd, prev_sk, prev_sd) + }) + + # 4. 真龙 + cond_price = close > ma20 + cond_ma = ma5 > ma20 + cond_macd = macd_val > 0 and float(prev.get('macd', 0)) <= 0 + vol_arr = df['volume'].values.astype(np.float64) + vol_avg = float(vol_arr[max(0, n - 11):n - 1].mean()) if n > 10 else float(vol_arr.mean()) + cond_vol = float(vol_arr[-1]) > vol_avg * 1.2 if vol_avg > 0 else False + met = sum([cond_price, cond_ma, cond_macd, cond_vol]) + triggered = bool(met >= 3 and cond_price) + parts = [] + if cond_price: + parts.append(f"价格>{ma20:.2f}(MA20)✓") + else: + parts.append(f"价格{close:.2f}<{ma20:.2f}(MA20)✗") + if cond_ma: + parts.append("MA5>MA20✓") + else: + parts.append(f"MA5({ma5:.2f}) w10_start + 1 else dif_arr[:max(0, n - 1)] + dif_min_10 = float(dif_w10.min()) if len(dif_w10) > 0 else 0.0 + at_low_10 = close <= price_min_10 * 1.01 + is_actual_min_10 = bool(np.argmin(window_10) == len(window_10) - 1) + dif_div_10 = dif > dif_min_10 + triggered = bool(at_low_10 and is_actual_min_10 and dif_div_10) + if triggered: + desc = f"✅ 已触发!价格接近10日新低,DIF({dif:.3f})高于前低({dif_min_10:.3f})" + elif at_low_10 and not is_actual_min_10: + desc = f"价格接近10日低位({price_min_10:.2f}),但非当前最低点" + elif at_low_10: + desc = f"价格在10日低位,但DIF也在低位,暂无背离" + else: + desc = f"价格距10日低点{price_min_10:.2f}尚远,等待回调" + status.append({ + 'type': 'short_bottom_divergence', 'name': '短底背离', 'strength': 65, + 'triggered': triggered, 'description': desc, + 'readiness': _calc_readiness_divergence(close, price_min_10, dif, dif_min_10) + }) + + # 6. 老鼠仓 + if open_p > 0: + drop = (low - open_p) / open_p * 100 + recovery = (close - low) / (high - low) * 100 if high != low else 50 + close_vs_open = (close - open_p) / open_p * 100 + vol_avg_10 = float(vol_arr[max(0, n - 11):n - 1].mean()) if n > 10 else float(vol_arr.mean()) + vol_up = float(vol_arr[-1]) > vol_avg_10 * 1.3 if vol_avg_10 > 0 else False + triggered = bool(drop < -3 and recovery > 60 and close_vs_open > -1 and vol_up) + if triggered: + desc = f"✅ 已触发!盘中跌{drop:.1f}%后回收{recovery:.0f}%,放量吸筹" + else: + parts = [] + if drop >= -3: + parts.append(f"盘中最大跌幅{drop:.1f}%(需<-3%)") + else: + parts.append(f"盘中跌{drop:.1f}%✓") + if recovery <= 60: + parts.append(f"回收{recovery:.0f}%(需>60%)") + else: + parts.append(f"回收{recovery:.0f}%✓") + if not vol_up: + parts.append("未放量") + desc = f"{', '.join(parts)}" + else: + triggered = False + desc = "数据异常" + status.append({ + 'type': 'rat_trading', 'name': '老鼠仓', 'strength': 60, + 'triggered': triggered, 'description': desc, + 'readiness': 0 + }) + + # 7. 反弹 + cross = prev_ema3 <= prev_ema21 and ema3 > ema21 + triggered = bool(cross) + gap = ema3 - ema21 + gap_pct = gap / ema21 * 100 if ema21 > 0 else 0 + if triggered: + desc = f"✅ 已触发!EMA3({ema3:.2f})上穿EMA21({ema21:.2f})" + elif ema3 < ema21: + desc = f"EMA3({ema3:.2f})EMA21({ema21:.2f}),已在上方但非刚穿越" + status.append({ + 'type': 'rebound', 'name': '反弹', 'strength': 55, + 'triggered': triggered, 'description': desc, + 'readiness': _calc_readiness_rebound(ema3, ema21, prev_ema3, prev_ema21) + }) + + return status + + +def _calc_readiness(dif, dea, signal_type): + if dif > 0 and dea > 0 and dif > dea: + return 100 + elif dif > 0 and dea > 0: + return 70 + elif dif > dea: + return 40 + else: + return max(0, int(20 + dif * 100)) + + +def _calc_readiness_divergence(close, price_min, dif, dif_min): + price_near = close <= price_min * 1.03 + dif_higher = dif > dif_min + if price_near and dif_higher: + return 90 + elif price_near: + return 50 + elif dif_higher and dif < 0: + return 30 + return 10 + + +def _calc_readiness_dragon(sk, sd, prev_sk, prev_sd): + if sk < 20 and sk > sd and prev_sk <= prev_sd: + return 100 + elif sk < 20: + return 70 + elif sk < 30: + return 40 + elif sk < 50: + return 20 + return 5 + + +def _calc_readiness_rebound(ema3, ema21, prev_ema3, prev_ema21): + if prev_ema3 <= prev_ema21 and ema3 > ema21: + return 100 + gap_pct = (ema3 - ema21) / ema21 * 100 if ema21 > 0 else 0 + if gap_pct > 0: + return 60 + elif gap_pct > -1: + return 40 + elif gap_pct > -3: + return 20 + return 5 diff --git a/stock-html/services/smart_trade_engine.py b/stock-html/services/smart_trade_engine.py new file mode 100644 index 0000000..73a2d9f --- /dev/null +++ b/stock-html/services/smart_trade_engine.py @@ -0,0 +1,1304 @@ +""" +智能交易引擎 v7.1 — 将回测验证的最优算法应用到实盘模拟交易 + +核心功能: +1. 读取用户的算法配置 (sim_algo_config) +2. 基于全景扫描信号生成买入决策 +3. 基于持仓元数据 + 当前价格生成卖出/部分止盈决策 +4. 动态仓位计算(信号加权) +5. 记录所有决策过程到 sim_trade_signals +6. 手续费模拟(佣金万2.5 + 印花税千1卖出) + +算法来源: docs/algorithm_recommendation.md +回测验证: backtest_v6_analysis_report.md + backtest_v7_timing_comparison.md +""" + +from datetime import date, datetime, time as dt_time +from decimal import Decimal +import traceback + + +# ═══════════════════════════════════════════════════════ +# 0. 手续费计算 +# ═══════════════════════════════════════════════════════ + +# 佣金费率: 万分之2.5 (双向收取, 最低5元) +COMMISSION_RATE = 0.00025 +COMMISSION_MIN = 5.0 + +# 印花税费率: 千分之1 (仅卖出收取) +STAMP_TAX_RATE = 0.001 + +# 滑点费率: 买入+0.3%, 卖出-0.3% +SLIPPAGE_RATE = 0.003 + +# 涨跌停阈值 (实际10%/20%, 留0.2%缓冲) +PRICE_LIMIT_NORMAL = 0.098 # 主板 10% (实际检测9.8%) +PRICE_LIMIT_STAR_GEM = 0.198 # 科创板/创业板 20% (实际检测19.8%) + + +def apply_slippage(price, trade_type): + """ + 应用滑点: 买入时价格上浮, 卖出时价格下浮 + + 参数: + price: 信号价格 + trade_type: 'buy' 或 'sell' + + 返回: + float: 滑点调整后的实际成交价 + """ + if trade_type == 'buy': + return round(price * (1 + SLIPPAGE_RATE), 4) + else: # sell + return round(price * (1 - SLIPPAGE_RATE), 4) + + +def get_price_limit(stock_code): + """ + 获取股票的涨跌停幅度 + + 参数: + stock_code: 股票代码 + + 返回: + float: 涨跌停比例 (0.098 或 0.198) + """ + # 科创板 (688xxx) 和 创业板 (300xxx/301xxx) 涨跌停20% + if stock_code.startswith('688') or stock_code.startswith('300') or stock_code.startswith('301'): + return PRICE_LIMIT_STAR_GEM + # ST股涨跌停5% (简化: 不特别处理, 用主板标准) + return PRICE_LIMIT_NORMAL + + +def check_price_limit(stock_code, current_price, prev_close): + """ + 检查股票是否涨跌停 + + 参数: + stock_code: 股票代码 + current_price: 当前价格 + prev_close: 昨日收盘价 + + 返回: + dict: { + 'at_up_limit': bool, # 是否涨停 + 'at_down_limit': bool, # 是否跌停 + 'change_pct': float, # 涨跌幅% + } + """ + if not prev_close or prev_close <= 0: + return {'at_up_limit': False, 'at_down_limit': False, 'change_pct': 0} + + limit = get_price_limit(stock_code) + change_pct = (current_price - prev_close) / prev_close + + return { + 'at_up_limit': change_pct >= limit, + 'at_down_limit': change_pct <= -limit, + 'change_pct': round(change_pct * 100, 2), + } + + +def calc_trade_fees(price, quantity, trade_type): + """ + 计算交易手续费 + + 参数: + price: 成交价格 + quantity: 成交数量 + trade_type: 'buy' 或 'sell' + + 返回: + dict: { + 'commission': float, # 佣金 + 'stamp_tax': float, # 印花税 + 'total_fee': float, # 总手续费 + } + """ + amount = price * quantity + + # 佣金 (买卖双向, 最低5元) + commission = max(amount * COMMISSION_RATE, COMMISSION_MIN) + + # 印花税 (仅卖出) + stamp_tax = amount * STAMP_TAX_RATE if trade_type == 'sell' else 0.0 + + return { + 'commission': round(commission, 2), + 'stamp_tax': round(stamp_tax, 2), + 'total_fee': round(commission + stamp_tax, 2), + } + + +# ═══════════════════════════════════════════════════════ +# 1. 算法配置管理 +# ═══════════════════════════════════════════════════════ + +DEFAULT_CONFIG = { + 'algo_name': 'PE50G3+BE8', + 'take_profit_pct': 12.0, + 'stop_loss_pct': 8.0, + 'ignore_sell_signal': False, + 'sell_confirm_days': 3, + 'max_hold_days': 60, + 'no_timeout_if_rising': True, + 'total_capital': 200000.0, + 'position_pct': 8.0, + 'signal_weight': True, + 'partial_exit_pct': 50, + 'momentum_trail_gap': 3.0, + 'breakeven_at': 8.0, + 'momentum_tp': False, + 'momentum_days': 3, + 'buy_time': '09:35', + 'sell_time': '13:40', +} + + +def get_user_algo_config(conn, user_id): + """获取用户的算法配置,不存在则返回默认配置""" + from psycopg2.extras import RealDictCursor + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute(""" + SELECT * FROM sim_algo_config + WHERE user_id = %s AND is_active = TRUE + """, (user_id,)) + row = cur.fetchone() + if row: + return dict(row) + return dict(DEFAULT_CONFIG) + + +def save_user_algo_config(conn, user_id, config): + """保存/更新用户的算法配置""" + with conn.cursor() as cur: + cur.execute(""" + INSERT INTO sim_algo_config (user_id, algo_name, + take_profit_pct, stop_loss_pct, ignore_sell_signal, sell_confirm_days, + max_hold_days, no_timeout_if_rising, total_capital, position_pct, + signal_weight, partial_exit_pct, momentum_trail_gap, breakeven_at, + momentum_tp, momentum_days, buy_time, sell_time, is_active) + VALUES (%(user_id)s, %(algo_name)s, + %(take_profit_pct)s, %(stop_loss_pct)s, %(ignore_sell_signal)s, %(sell_confirm_days)s, + %(max_hold_days)s, %(no_timeout_if_rising)s, %(total_capital)s, %(position_pct)s, + %(signal_weight)s, %(partial_exit_pct)s, %(momentum_trail_gap)s, %(breakeven_at)s, + %(momentum_tp)s, %(momentum_days)s, %(buy_time)s, %(sell_time)s, TRUE) + ON CONFLICT (user_id) DO UPDATE SET + algo_name = EXCLUDED.algo_name, + take_profit_pct = EXCLUDED.take_profit_pct, + stop_loss_pct = EXCLUDED.stop_loss_pct, + ignore_sell_signal = EXCLUDED.ignore_sell_signal, + sell_confirm_days = EXCLUDED.sell_confirm_days, + max_hold_days = EXCLUDED.max_hold_days, + no_timeout_if_rising = EXCLUDED.no_timeout_if_rising, + total_capital = EXCLUDED.total_capital, + position_pct = EXCLUDED.position_pct, + signal_weight = EXCLUDED.signal_weight, + partial_exit_pct = EXCLUDED.partial_exit_pct, + momentum_trail_gap = EXCLUDED.momentum_trail_gap, + breakeven_at = EXCLUDED.breakeven_at, + momentum_tp = EXCLUDED.momentum_tp, + momentum_days = EXCLUDED.momentum_days, + buy_time = EXCLUDED.buy_time, + sell_time = EXCLUDED.sell_time, + is_active = TRUE, + updated_at = NOW() + """, {**config, 'user_id': user_id}) + conn.commit() + + +def apply_template(conn, user_id, template_name): + """从算法模板创建用户配置""" + from psycopg2.extras import RealDictCursor + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute("SELECT * FROM algo_templates WHERE name = %s", (template_name,)) + tpl = cur.fetchone() + if not tpl: + return False + + config = { + 'algo_name': tpl['display_name'], + 'take_profit_pct': float(tpl['take_profit_pct']), + 'stop_loss_pct': float(tpl['stop_loss_pct']), + 'ignore_sell_signal': tpl['ignore_sell_signal'], + 'sell_confirm_days': tpl['sell_confirm_days'], + 'max_hold_days': tpl['max_hold_days'], + 'no_timeout_if_rising': tpl['no_timeout_if_rising'], + 'total_capital': 200000.0, # 用户需自行设置 + 'position_pct': float(tpl['position_pct']), + 'signal_weight': tpl['signal_weight'], + 'partial_exit_pct': tpl['partial_exit_pct'], + 'momentum_trail_gap': float(tpl['momentum_trail_gap']), + 'breakeven_at': float(tpl['breakeven_at']), + 'momentum_tp': tpl['momentum_tp'], + 'momentum_days': tpl['momentum_days'], + 'buy_time': tpl.get('buy_time', '09:35'), + 'sell_time': tpl.get('sell_time', '13:40'), + } + save_user_algo_config(conn, user_id, config) + return True + + +# ═══════════════════════════════════════════════════════ +# 2. 仓位计算 +# ═══════════════════════════════════════════════════════ + +def calc_dynamic_shares(available_cash, stock_price, config, recommend_rate=80, triggered_count=1): + """ + 计算动态仓位股数(与回测引擎 backtest_recommend.py 一致的逻辑) + + 参数: + available_cash: 可用资金 + stock_price: 当前股价 + config: 算法配置 + recommend_rate: 信号推荐率 (0-100) + triggered_count: 触发信号数 + + 返回: + int: 建议买入股数(100的整数倍) + """ + total_capital = float(config.get('total_capital', 200000)) + position_pct = float(config.get('position_pct', 8)) + use_signal_weight = config.get('signal_weight', True) + + if stock_price <= 0 or available_cash <= 0: + return 0 + + # 基础仓位金额 = 总资金 * 仓位百分比 + base_amount = total_capital * position_pct / 100.0 + + # 信号加权: 强信号加大仓位 + if use_signal_weight: + weight = 1.0 + if recommend_rate >= 90: + weight = 1.5 # 强信号: 150%仓位 + elif recommend_rate >= 80: + weight = 1.2 # 中强信号: 120%仓位 + elif recommend_rate >= 70: + weight = 1.0 # 标准信号: 100% + else: + weight = 0.7 # 弱信号: 70% + + # 多信号触发加成 + if triggered_count >= 3: + weight *= 1.2 + elif triggered_count >= 2: + weight *= 1.1 + + base_amount *= weight + + # 不超过可用现金 + base_amount = min(base_amount, available_cash * 0.95) # 留5%缓冲 + + # 计算股数 (100的整数倍) + shares = int(base_amount / stock_price / 100) * 100 + return max(shares, 0) + + +# ═══════════════════════════════════════════════════════ +# 3. 持仓元数据管理 +# ═══════════════════════════════════════════════════════ + +def get_position_meta(conn, user_id, stock_code): + """获取单只股票的持仓元数据""" + from psycopg2.extras import RealDictCursor + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute(""" + SELECT * FROM sim_position_meta + WHERE user_id = %s AND stock_code = %s + """, (user_id, stock_code)) + return cur.fetchone() + + +def get_all_position_meta(conn, user_id): + """获取用户所有持仓的元数据""" + from psycopg2.extras import RealDictCursor + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute(""" + SELECT m.*, p.quantity, p.avg_cost::float, p.current_price::float + FROM sim_position_meta m + JOIN sim_positions p ON m.user_id = p.user_id AND m.stock_code = p.stock_code + WHERE m.user_id = %s AND p.quantity > 0 + """, (user_id,)) + return cur.fetchall() + + +def create_position_meta(conn, user_id, stock_code, buy_price, buy_date, + shares, reason='', signal_rate=0, triggered_count=0): + """创建新的持仓元数据""" + with conn.cursor() as cur: + cur.execute(""" + INSERT INTO sim_position_meta + (user_id, stock_code, buy_date, buy_price, buy_reason, + buy_signal_rate, buy_triggered_count, + max_price_since_buy, current_shares, original_shares, last_update_date) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (user_id, stock_code) DO UPDATE SET + buy_date = EXCLUDED.buy_date, + buy_price = EXCLUDED.buy_price, + buy_reason = EXCLUDED.buy_reason, + buy_signal_rate = EXCLUDED.buy_signal_rate, + buy_triggered_count = EXCLUDED.buy_triggered_count, + max_price_since_buy = EXCLUDED.max_price_since_buy, + days_held = 0, + consecutive_up_days = 0, + consecutive_sell_signals = 0, + partial_exit_done = FALSE, + breakeven_active = FALSE, + momentum_trailing_active = FALSE, + momentum_high_price = 0, + current_shares = EXCLUDED.current_shares, + original_shares = EXCLUDED.original_shares, + last_update_date = EXCLUDED.last_update_date, + updated_at = NOW() + """, (user_id, stock_code, buy_date, buy_price, reason, + signal_rate, triggered_count, buy_price, shares, shares, buy_date)) + + +def update_position_meta(conn, user_id, stock_code, updates): + """更新持仓元数据""" + set_clauses = [] + values = [] + for key, val in updates.items(): + set_clauses.append(f"{key} = %s") + values.append(val) + set_clauses.append("updated_at = NOW()") + values.extend([user_id, stock_code]) + + with conn.cursor() as cur: + cur.execute(f""" + UPDATE sim_position_meta SET {', '.join(set_clauses)} + WHERE user_id = %s AND stock_code = %s + """, values) + + +def delete_position_meta(conn, user_id, stock_code): + """删除持仓元数据(清仓时调用)""" + with conn.cursor() as cur: + cur.execute(""" + DELETE FROM sim_position_meta + WHERE user_id = %s AND stock_code = %s + """, (user_id, stock_code)) + + +# ═══════════════════════════════════════════════════════ +# 4. 信号日志 +# ═══════════════════════════════════════════════════════ + +def _fix_all_sequences(conn): + """修复所有模拟交易相关表的序列号,确保不会产生主键冲突""" + seq_table_map = [ + ('sim_trade_signals_id_seq', 'sim_trade_signals'), + ('sim_positions_id_seq', 'sim_positions'), + ('sim_trades_id_seq', 'sim_trades'), + ('sim_position_meta_id_seq', 'sim_position_meta'), + ('sim_algo_config_id_seq', 'sim_algo_config'), + ('sim_daily_stats_id_seq', 'sim_daily_stats'), + ] + try: + with conn.cursor() as cur: + for seq_name, table_name in seq_table_map: + try: + cur.execute(f""" + SELECT setval('{seq_name}', + COALESCE((SELECT MAX(id) FROM {table_name}), 0) + 1, false + ) + """) + except Exception: + pass # 某些表可能不存在,跳过 + except Exception as e: + print(f"⚠️ 修复序列失败: {e}", flush=True) + + +def _fix_trade_signals_sequence(conn): + """修复 sim_trade_signals 序列号(向后兼容)""" + _fix_all_sequences(conn) + + +def log_signal(conn, user_id, signal_date, stock_code, stock_name, + action, reason, algo_rule, signal_price=None, + buy_price=None, profit_pct=None, executed=False, + execute_price=None, execute_shares=None): + """记录交易信号到日志""" + params = (user_id, signal_date, datetime.now().time(), stock_code, stock_name, + action, reason, algo_rule, signal_price, buy_price, profit_pct, + executed, execute_price, execute_shares) + insert_sql = """ + INSERT INTO sim_trade_signals + (user_id, signal_date, signal_time, stock_code, stock_name, + action, reason, algo_rule, signal_price, buy_price, profit_pct, + executed, execute_price, execute_shares) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """ + try: + with conn.cursor() as cur: + cur.execute("SAVEPOINT sp_log_signal") + cur.execute(insert_sql, params) + except Exception as e: + if 'duplicate key' in str(e): + with conn.cursor() as cur: + cur.execute("ROLLBACK TO SAVEPOINT sp_log_signal") + print(f"⚠️ 信号日志主键冲突,正在修复序列...", flush=True) + _fix_trade_signals_sequence(conn) + # 修复后重试一次 + with conn.cursor() as cur: + cur.execute(insert_sql, params) + print(f"✅ 序列修复成功,信号已记录", flush=True) + else: + raise + + +# ═══════════════════════════════════════════════════════ +# 5. 核心交易决策引擎 +# ═══════════════════════════════════════════════════════ + +def generate_sell_decisions(conn, user_id, config, current_prices, scan_map=None): + """ + 生成卖出/部分止盈决策 + + 参数: + conn: 数据库连接 + user_id: 用户ID + config: 算法配置 (from get_user_algo_config) + current_prices: {stock_code: current_price} 当前价格 + scan_map: {stock_code: scan_data} 今日扫描结果 (可选) + + 返回: + list[dict]: 卖出决策列表 + [{'code': str, 'action': 'sell'|'partial_sell', + 'shares': int, 'reason': str, 'rule': str, 'price': float}] + """ + tp_pct = float(config.get('take_profit_pct', 12)) + sl_pct = float(config.get('stop_loss_pct', 8)) + ign_sell = config.get('ignore_sell_signal', False) + confirm_days = int(config.get('sell_confirm_days', 3)) + max_hold = int(config.get('max_hold_days', 60)) + no_timeout_rising = config.get('no_timeout_if_rising', True) + pe_pct = int(config.get('partial_exit_pct', 0)) + mt_gap = float(config.get('momentum_trail_gap', 3)) + be_at = float(config.get('breakeven_at', 0)) + mt_active = config.get('momentum_tp', False) + mt_days = int(config.get('momentum_days', 3)) + + today = date.today() + decisions = [] + + # 获取所有持仓及其元数据 + positions = get_all_position_meta(conn, user_id) + + print(f"[智能引擎] 卖出分析: {len(positions)}只持仓 (TP={tp_pct}%/SL={sl_pct}%/PE={pe_pct}%/BE@{be_at}%/MaxHold={max_hold}天)") + + for pos in positions: + code = pos['stock_code'] + price = current_prices.get(code) + if not price or price <= 0: + print(f" {code} 无价格,跳过") + continue + + buy_price = float(pos['buy_price']) + current_shares = pos.get('current_shares', 0) or pos.get('quantity', 0) + if current_shares <= 0: + continue + + profit_pct_now = (price - buy_price) / buy_price * 100 + days_held = pos.get('days_held', 0) or 0 + print(f" {code} 成本{buy_price:.2f} 现价{price:.2f} 盈亏{profit_pct_now:+.1f}% 持仓{days_held}天") + consec_up = pos.get('consecutive_up_days', 0) or 0 + pe_done = pos.get('partial_exit_done', False) + be_active_now = pos.get('breakeven_active', False) + mt_trailing = pos.get('momentum_trailing_active', False) + mt_high = float(pos.get('momentum_high_price', 0) or 0) + + # ── 规则1: 止损 ── + effective_sl = -sl_pct + if be_active_now: + effective_sl = 0 # 保本止损: 止损线在成本价 + if profit_pct_now <= effective_sl: + rule = 'BE_SL' if be_active_now else 'SL' + reason = f"{'保本止损' if be_active_now else '止损'}: 浮盈{profit_pct_now:.1f}% ≤ {effective_sl:.1f}%" + decisions.append({ + 'code': code, 'action': 'sell', 'shares': current_shares, + 'reason': reason, 'rule': rule, 'price': price + }) + continue + + # ── 规则2: 动量跟踪止盈 ── + if mt_trailing and mt_high > 0: + drop_from_high = (mt_high - price) / mt_high * 100 + if drop_from_high >= mt_gap: + reason = f"动量跟踪止盈: 从最高{mt_high:.2f}回落{drop_from_high:.1f}%≥{mt_gap}%" + decisions.append({ + 'code': code, 'action': 'sell', 'shares': current_shares, + 'reason': reason, 'rule': 'MT_TP', 'price': price + }) + continue + # 更新最高价 + if price > mt_high: + update_position_meta(conn, user_id, code, {'momentum_high_price': price}) + + # ── 规则3: 止盈 / 部分止盈 ── + if profit_pct_now >= tp_pct: + # 检查是否应启动动量跟踪(连涨中不卖) + if mt_active and consec_up >= mt_days: + if not mt_trailing: + update_position_meta(conn, user_id, code, { + 'momentum_trailing_active': True, + 'momentum_high_price': price, + }) + log_signal(conn, user_id, today, code, '', + 'hold', f"连涨{consec_up}天+盈利{profit_pct_now:.1f}%≥TP,启动动量跟踪", + 'MT_START', price, buy_price, profit_pct_now) + continue # 连涨中不触发止盈 + + if pe_pct > 0 and not pe_done: + # 部分止盈 + sell_shares = int(current_shares * pe_pct / 100 / 100) * 100 + sell_shares = max(sell_shares, 100) # 至少100股 + sell_shares = min(sell_shares, current_shares) + reason = f"部分止盈{pe_pct}%: 盈利{profit_pct_now:.1f}%≥TP{tp_pct}%, 卖出{sell_shares}股" + decisions.append({ + 'code': code, 'action': 'partial_sell', 'shares': sell_shares, + 'reason': reason, 'rule': 'PE', 'price': price + }) + # 剩余部分启动跟踪止盈 + update_position_meta(conn, user_id, code, { + 'partial_exit_done': True, + 'momentum_trailing_active': True, + 'momentum_high_price': price, + }) + else: + # 全部止盈 + reason = f"止盈: 盈利{profit_pct_now:.1f}%≥TP{tp_pct}%" + decisions.append({ + 'code': code, 'action': 'sell', 'shares': current_shares, + 'reason': reason, 'rule': 'TP', 'price': price + }) + continue + + # ── 规则4: 激活保本止损 ── + if be_at > 0 and not be_active_now and profit_pct_now >= be_at: + update_position_meta(conn, user_id, code, {'breakeven_active': True}) + log_signal(conn, user_id, today, code, '', + 'hold', f"盈利{profit_pct_now:.1f}%≥{be_at}%,保本止损已激活", + 'BE_ACTIVATE', price, buy_price, profit_pct_now) + + # ── 规则5: 超时平仓 ── + if max_hold > 0 and days_held >= max_hold: + # 连涨且盈利时不超时 + if no_timeout_rising and consec_up >= 2 and profit_pct_now > 0: + log_signal(conn, user_id, today, code, '', + 'hold', f"持仓{days_held}天≥{max_hold}天,但连涨{consec_up}天+盈利中,不平仓", + 'NTO', price, buy_price, profit_pct_now) + else: + reason = f"超时平仓: 持仓{days_held}天≥{max_hold}天" + decisions.append({ + 'code': code, 'action': 'sell', 'shares': current_shares, + 'reason': reason, 'rule': 'TIMEOUT', 'price': price + }) + continue + + # ── 规则6: 扫描卖出信号 ── + if not ign_sell and scan_map: + scan = scan_map.get(code) + if scan: + from services.stock_algorithms import compute_recommend + sig_type, display, reason_txt, rate = compute_recommend( + scan.get('signal_status'), scan.get('indicators'), + scan.get('triggered_count'), is_holding=True + ) + if sig_type == 'sell': + consec_sell = pos.get('consecutive_sell_signals', 0) or 0 + new_consec = consec_sell + 1 + update_position_meta(conn, user_id, code, { + 'consecutive_sell_signals': new_consec + }) + if confirm_days <= 0 or new_consec >= confirm_days: + reason = f"卖出信号确认: {reason_txt} (连续{new_consec}天)" + decisions.append({ + 'code': code, 'action': 'sell', 'shares': current_shares, + 'reason': reason, 'rule': 'SCAN_SELL', 'price': price + }) + else: + log_signal(conn, user_id, today, code, '', + 'hold', f"卖出信号{new_consec}/{confirm_days}天: {reason_txt}", + 'SELL_WAIT', price, buy_price, profit_pct_now) + else: + # 非卖出信号,重置连续卖出计数 + if pos.get('consecutive_sell_signals', 0): + update_position_meta(conn, user_id, code, { + 'consecutive_sell_signals': 0 + }) + + return decisions + + +def generate_buy_decisions(conn, user_id, config, scan_map, current_prices, holding_codes): + """ + 生成买入决策 + + 参数: + conn: 数据库连接 + user_id: 用户ID + config: 算法配置 + scan_map: {stock_code: scan_data} 今日扫描结果 + current_prices: {stock_code: price} 当前价格 + holding_codes: set 当前持仓股票代码 + + 返回: + list[dict]: 买入决策列表 + [{'code': str, 'name': str, 'shares': int, 'reason': str, 'price': float, 'rate': int}] + """ + from services.stock_algorithms import compute_recommend + + total_capital = float(config.get('total_capital', 200000)) + + # 计算可用现金 + from psycopg2.extras import RealDictCursor + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute(""" + SELECT COALESCE(SUM(quantity * avg_cost), 0)::float as total_invested + FROM sim_positions WHERE user_id = %s AND quantity > 0 + """, (user_id,)) + row = cur.fetchone() + total_invested = row['total_invested'] if row else 0.0 + + available_cash = total_capital - total_invested + + if available_cash <= 0: + print(f"[智能引擎] 可用现金不足: ¥{available_cash:,.0f} (总本金¥{total_capital:,.0f} - 已投¥{total_invested:,.0f})") + return [] + + print(f"[智能引擎] 可用现金: ¥{available_cash:,.0f} (总本金¥{total_capital:,.0f} - 已投¥{total_invested:,.0f})") + + # 候选买入列表 + buy_candidates = [] + for code, scan in scan_map.items(): + if code in holding_codes: + continue + + # 过滤退市、ST、*ST股票 — 不参与智能交易 + stock_name = scan.get('name', '') + if any(tag in stock_name for tag in ('退', 'ST', '*ST', '退市')): + continue + + sig_type, display, reason, rate = compute_recommend( + scan.get('signal_status'), scan.get('indicators'), + scan.get('triggered_count'), is_holding=False + ) + + if sig_type != 'buy': + continue + + price = current_prices.get(code) + if not price or price <= 0: + continue + + triggered = scan.get('triggered_count', 0) or 0 + + buy_candidates.append({ + 'code': code, + 'name': scan.get('name', ''), + 'rate': rate, + 'triggered': triggered, + 'reason': reason, + 'price': price, + }) + + # 按推荐率 + 触发信号数排序 + buy_candidates.sort(key=lambda x: (x['rate'], x['triggered']), reverse=True) + print(f"[智能引擎] 买入候选: {len(buy_candidates)}只 (从{len(scan_map)}只扫描结果中筛选)") + for c in buy_candidates[:5]: + print(f" 候选: {c['code']} {c['name']} rate={c['rate']} triggered={c['triggered']} price={c['price']:.2f}") + + # 生成买入决策(按可用资金约束) + decisions = [] + remaining_cash = available_cash + + for cand in buy_candidates: + if remaining_cash <= 0: + break + + shares = calc_dynamic_shares( + remaining_cash, cand['price'], config, + recommend_rate=cand['rate'], + triggered_count=cand['triggered'] + ) + + if shares <= 0: + continue + + cost = shares * cand['price'] + if cost > remaining_cash: + shares = int(remaining_cash / cand['price'] / 100) * 100 + if shares <= 0: + continue + cost = shares * cand['price'] + + decisions.append({ + 'code': cand['code'], + 'name': cand['name'], + 'shares': shares, + 'reason': cand['reason'], + 'price': cand['price'], + 'rate': cand['rate'], + 'triggered': cand['triggered'], + }) + + remaining_cash -= cost + + return decisions + + +# ═══════════════════════════════════════════════════════ +# 6. 每日持仓状态更新 +# ═══════════════════════════════════════════════════════ + +def update_daily_position_status(conn, user_id, current_prices): + """ + 每日更新持仓元数据(在生成卖出决策前调用) + - 更新 days_held (持仓天数) + - 更新 max_price_since_buy (最高价) + - 更新 consecutive_up_days (连涨天数) + """ + today = date.today() + positions = get_all_position_meta(conn, user_id) + + for pos in positions: + code = pos['stock_code'] + price = current_prices.get(code) + if not price or price <= 0: + continue + + buy_date = pos['buy_date'] + if isinstance(buy_date, str): + buy_date = datetime.strptime(buy_date, '%Y-%m-%d').date() + + days_held = (today - buy_date).days + max_price = max(float(pos.get('max_price_since_buy', 0) or 0), price) + + # 判断是否连涨(当前价 > 昨天的最高价估计 - 简化处理) + prev_price = float(pos.get('current_price', 0) or pos.get('buy_price', 0)) + consec_up = pos.get('consecutive_up_days', 0) or 0 + if price > prev_price: + consec_up += 1 + else: + consec_up = 0 + + # 更新部分止盈后的当前股数 + current_shares = pos.get('quantity', 0) or pos.get('current_shares', 0) + + update_position_meta(conn, user_id, code, { + 'days_held': days_held, + 'max_price_since_buy': max_price, + 'consecutive_up_days': consec_up, + 'current_shares': current_shares, + 'last_update_date': today, + }) + + conn.commit() + + +# ═══════════════════════════════════════════════════════ +# 7. 主执行函数 +# ═══════════════════════════════════════════════════════ + +def execute_smart_trade(conn, user_id, scan_date=None): + """ + 智能交易主执行函数 — 替代旧的 execute_auto_trade_for_user + + 流程: + 1. 读取算法配置 + 2. 获取当前持仓和价格 + 3. 更新持仓状态 + 4. 生成卖出决策并执行 + 5. 生成买入决策并执行 + 6. 记录所有信号 + + 返回: + dict: {'success': bool, 'results': list, 'signals': int} + """ + from psycopg2.extras import RealDictCursor + + today = date.today() + now = datetime.now().time() + + print(f"[智能引擎] 开始为用户{user_id}执行智能交易...") + + # 预防性修复序列号,避免主键冲突 + _fix_trade_signals_sequence(conn) + + try: + # 1. 读取算法配置 + config = get_user_algo_config(conn, user_id) + algo_name = config.get('algo_name', 'unknown') + print(f"[智能引擎] 算法: {algo_name}") + + cur = conn.cursor(cursor_factory=RealDictCursor) + + # 2. 获取持仓 + cur.execute(""" + SELECT stock_code, stock_name, quantity, avg_cost::float, current_price::float + FROM sim_positions WHERE user_id = %s AND quantity > 0 + """, (user_id,)) + positions = cur.fetchall() + holding_codes = {p['stock_code'] for p in positions} + + # 3. 读取扫描结果 + if scan_date: + cur.execute(""" + SELECT code, name, triggered_count, signal_status, indicators + FROM stock_signal_scan WHERE scan_date = %s + """, (scan_date,)) + else: + cur.execute(""" + SELECT code, name, triggered_count, signal_status, indicators + FROM stock_signal_scan + WHERE scan_date = ( + SELECT MAX(scan_date) FROM stock_signal_scan + WHERE scan_date <= %s + ) + """, (today,)) + scan_rows = cur.fetchall() + scan_map = {r['code']: r for r in scan_rows} + + if not scan_map: + print(f"[智能引擎] 无可用扫描数据,跳过") + return {'success': True, 'results': [], 'signals': 0} + + print(f"[智能引擎] 扫描数据: {len(scan_map)}只, 持仓: {len(holding_codes)}只") + + # 4. 批量获取所有相关股票的当前价格(单次查询,避免逐个连接) + all_codes = holding_codes | set(scan_map.keys()) + current_prices = {} + if all_codes: + cur.execute(""" + SELECT code, price::float FROM stock_realtime_price + WHERE code = ANY(%s) AND price > 0 + """, (list(all_codes),)) + for row in cur.fetchall(): + current_prices[row['code']] = row['price'] + + # 对于持仓股票,优先使用 sim_positions.current_price(由持仓更新服务刷新,通常更新) + # stock_realtime_price 可能滞后(仅在全景扫描时更新) + for pos in positions: + code = pos['stock_code'] + cp = float(pos.get('current_price', 0) or 0) + if cp > 0: + old_price = current_prices.get(code, 0) + current_prices[code] = cp + if old_price > 0 and abs(cp - old_price) / old_price > 0.001: + print(f" [价格修正] {code} realtime={old_price:.2f} → position={cp:.2f}") + + price_hit = sum(1 for c in holding_codes if c in current_prices) + print(f"[智能引擎] 实时价格: {len(current_prices)}/{len(all_codes)}只, " + f"持仓覆盖: {price_hit}/{len(holding_codes)}只") + + # 4b. 批量获取昨日收盘价 (用于涨跌停检测) + prev_close_prices = {} + if all_codes: + cur.execute(""" + SELECT code, close::float as prev_close + FROM stock_kline_daily + WHERE code = ANY(%s) AND trade_date = ( + SELECT MAX(trade_date) FROM stock_kline_daily + WHERE trade_date < %s + ) + """, (list(all_codes), today)) + for row in cur.fetchall(): + prev_close_prices[row['code']] = row['prev_close'] + print(f"[智能引擎] 昨收价: {len(prev_close_prices)}只 (涨跌停检测)") + + # 4c. 获取今日买入的股票 (T+1规则: 当日买入不可当日卖出) + cur.execute(""" + SELECT DISTINCT stock_code FROM sim_trades + WHERE user_id = %s AND trade_date = %s AND trade_type = 'buy' + """, (user_id, today)) + today_bought_codes = {r['stock_code'] for r in cur.fetchall()} + if today_bought_codes: + print(f"[智能引擎] T+1限制: {len(today_bought_codes)}只今日已买入, 不可卖出") + + # 5. 更新每日持仓状态 + update_daily_position_status(conn, user_id, current_prices) + + results = [] + skipped_limit = [] # 因涨跌停跳过的交易 + skipped_t1 = [] # 因T+1跳过的交易 + + # 6. 生成并执行卖出决策 + sell_decisions = generate_sell_decisions(conn, user_id, config, current_prices, scan_map) + print(f"[智能引擎] 卖出决策: {len(sell_decisions)}笔") + + for dec in sell_decisions: + code = dec['code'] + price = dec['price'] + shares = dec['shares'] + action = dec['action'] + + pos = next((p for p in positions if p['stock_code'] == code), None) + if not pos: + continue + + # T+1规则: 当日买入的股票不可当日卖出 + if code in today_bought_codes: + skipped_t1.append(code) + print(f"[智能引擎] ⏳ T+1限制 {code} 今日买入,不可卖出") + log_signal(conn, user_id, today, code, pos.get('stock_name', ''), + 'hold', f"T+1限制: 今日买入不可卖出 ({dec['reason']})", 'T+1', + price, pos['avg_cost'], + (price - pos['avg_cost']) / pos['avg_cost'] * 100 if pos['avg_cost'] else 0, + False, None, None) + continue + + # 涨跌停检查: 跌停时无法卖出 + prev_close = prev_close_prices.get(code) + if prev_close: + limit_info = check_price_limit(code, price, prev_close) + if limit_info['at_down_limit']: + skipped_limit.append(f"{code}(跌停{limit_info['change_pct']}%)") + print(f"[智能引擎] 🚫 跌停限制 {code} 涨跌幅{limit_info['change_pct']}%,无法卖出") + log_signal(conn, user_id, today, code, pos.get('stock_name', ''), + 'hold', f"跌停无法卖出 ({dec['reason']})", 'LIMIT', + price, pos['avg_cost'], + (price - pos['avg_cost']) / pos['avg_cost'] * 100 if pos['avg_cost'] else 0, + False, None, None) + continue + + # 应用滑点: 卖出价格下浮 + price = apply_slippage(price, 'sell') + + if action == 'partial_sell': + # 部分止盈 + shares = min(shares, pos['quantity']) + if shares <= 0: + continue + remaining = pos['quantity'] - shares + sell_fees = calc_trade_fees(price, shares, 'sell') + realized_pnl = (price - pos['avg_cost']) * shares - sell_fees['total_fee'] + + cur.execute(""" + INSERT INTO sim_trades + (user_id, stock_code, stock_name, trade_type, price, quantity, + trade_date, trade_time, recommend_rate, signal_reason, + commission, stamp_tax, total_fee) + VALUES (%s, %s, %s, 'sell', %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, (user_id, code, pos['stock_name'], price, shares, + today, now, 0, f"[PE] {dec['reason']}", + sell_fees['commission'], sell_fees['stamp_tax'], sell_fees['total_fee'])) + + cur.execute(""" + UPDATE sim_positions SET + quantity = %s, + total_cost = avg_cost * %s, + current_price = %s, + updated_at = NOW() + WHERE user_id = %s AND stock_code = %s + """, (remaining, remaining, price, user_id, code)) + + update_position_meta(conn, user_id, code, { + 'current_shares': remaining, + 'partial_exit_done': True, + }) + + log_signal(conn, user_id, today, code, pos['stock_name'], + 'partial_sell', dec['reason'], dec['rule'], + price, pos['avg_cost'], + (price - pos['avg_cost']) / pos['avg_cost'] * 100, + True, price, shares) + + results.append({ + 'type': 'partial_sell', 'code': code, 'name': pos['stock_name'], + 'price': price, 'quantity': shares, 'pnl': realized_pnl, + 'reason': dec['reason'], 'rule': dec['rule'], + 'fee': sell_fees['total_fee'] + }) + print(f"[智能引擎] 部分止盈 {code} {pos['stock_name']} {shares}股@{price:.2f} " + f"手续费¥{sell_fees['total_fee']:.2f} | {dec['reason']}") + + else: + # 全部卖出 + qty = min(shares, pos['quantity']) + sell_fees = calc_trade_fees(price, qty, 'sell') + realized_pnl = (price - pos['avg_cost']) * qty - sell_fees['total_fee'] + + cur.execute(""" + INSERT INTO sim_trades + (user_id, stock_code, stock_name, trade_type, price, quantity, + trade_date, trade_time, recommend_rate, signal_reason, + commission, stamp_tax, total_fee) + VALUES (%s, %s, %s, 'sell', %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, (user_id, code, pos['stock_name'], price, qty, + today, now, 0, f"[{dec['rule']}] {dec['reason']}", + sell_fees['commission'], sell_fees['stamp_tax'], sell_fees['total_fee'])) + + cur.execute(""" + UPDATE sim_positions SET + quantity = 0, total_cost = 0, current_price = %s, updated_at = NOW() + WHERE user_id = %s AND stock_code = %s + """, (price, user_id, code)) + + # 记录已实现盈亏 + cur.execute(""" + INSERT INTO sim_daily_stats (user_id, stat_date, realized_profit, trade_count) + VALUES (%s, %s, %s, 1) + ON CONFLICT (user_id, stat_date) DO UPDATE SET + realized_profit = sim_daily_stats.realized_profit + %s, + trade_count = sim_daily_stats.trade_count + 1 + """, (user_id, today, realized_pnl, realized_pnl)) + + # 清除持仓元数据 + delete_position_meta(conn, user_id, code) + + log_signal(conn, user_id, today, code, pos['stock_name'], + 'sell', dec['reason'], dec['rule'], + price, pos['avg_cost'], + (price - pos['avg_cost']) / pos['avg_cost'] * 100, + True, price, qty) + + results.append({ + 'type': 'sell', 'code': code, 'name': pos['stock_name'], + 'price': price, 'quantity': qty, 'pnl': realized_pnl, + 'reason': dec['reason'], 'rule': dec['rule'], + 'fee': sell_fees['total_fee'] + }) + print(f"[智能引擎] 卖出 {code} {pos['stock_name']} {qty}股@{price:.2f} " + f"盈亏¥{realized_pnl:+,.0f} 手续费¥{sell_fees['total_fee']:.2f} | [{dec['rule']}] {dec['reason']}") + + # 更新卖出后的持仓列表 + cur.execute(""" + SELECT stock_code FROM sim_positions WHERE user_id = %s AND quantity > 0 + """, (user_id,)) + holding_codes = {r['stock_code'] for r in cur.fetchall()} + + # 7. 生成并执行买入决策 + buy_decisions = generate_buy_decisions(conn, user_id, config, scan_map, + current_prices, holding_codes) + print(f"[智能引擎] 买入决策: {len(buy_decisions)}笔 (持仓{len(holding_codes)}只)") + + for dec in buy_decisions: + code = dec['code'] + price = dec['price'] + shares = dec['shares'] + + # 防重复买入 + cur.execute(""" + SELECT COUNT(*) as cnt FROM sim_trades + WHERE user_id=%s AND stock_code=%s AND trade_date=%s AND trade_type='buy' + """, (user_id, code, today)) + if cur.fetchone()['cnt'] > 0: + continue + + # 涨跌停检查: 涨停时无法买入 + prev_close = prev_close_prices.get(code) + if prev_close: + limit_info = check_price_limit(code, price, prev_close) + if limit_info['at_up_limit']: + skipped_limit.append(f"{code}(涨停{limit_info['change_pct']}%)") + print(f"[智能引擎] 🚫 涨停限制 {code} 涨跌幅{limit_info['change_pct']}%,无法买入") + log_signal(conn, user_id, today, code, dec.get('name', ''), + 'skip', f"涨停无法买入 ({dec['reason']})", 'LIMIT', + price, None, None, False, None, None) + continue + + # 应用滑点: 买入价格上浮 + price = apply_slippage(price, 'buy') + shares = dec['shares'] # 不改变shares + + cost = price * shares + fees = calc_trade_fees(price, shares, 'buy') + + cur.execute(""" + INSERT INTO sim_trades + (user_id, stock_code, stock_name, trade_type, price, quantity, + trade_date, trade_time, recommend_rate, signal_reason, + commission, stamp_tax, total_fee) + VALUES (%s, %s, %s, 'buy', %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, (user_id, code, dec['name'], price, shares, + today, now, dec['rate'], dec['reason'], + fees['commission'], fees['stamp_tax'], fees['total_fee'])) + + # total_cost 包含手续费,更接近真实成本 + actual_cost = cost + fees['total_fee'] + # avg_cost = 含手续费的每股成本,确保 avg_cost * quantity == total_cost + avg_cost_per_share = actual_cost / shares if shares > 0 else price + cur.execute(""" + INSERT INTO sim_positions + (user_id, stock_code, stock_name, quantity, avg_cost, total_cost, current_price) + VALUES (%s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (user_id, stock_code) DO UPDATE SET + quantity = sim_positions.quantity + EXCLUDED.quantity, + total_cost = sim_positions.total_cost + EXCLUDED.total_cost, + avg_cost = (sim_positions.total_cost + EXCLUDED.total_cost) / + (sim_positions.quantity + EXCLUDED.quantity), + current_price = EXCLUDED.current_price, + stock_name = COALESCE(EXCLUDED.stock_name, sim_positions.stock_name), + updated_at = NOW() + """, (user_id, code, dec['name'], shares, avg_cost_per_share, actual_cost, price)) + + # 创建持仓元数据 + create_position_meta(conn, user_id, code, price, today, shares, + reason=dec['reason'], + signal_rate=dec['rate'], + triggered_count=dec.get('triggered', 0)) + + log_signal(conn, user_id, today, code, dec['name'], + 'buy', dec['reason'], 'BUY', + price, price, 0.0, True, price, shares) + + results.append({ + 'type': 'buy', 'code': code, 'name': dec['name'], + 'price': price, 'quantity': shares, + 'reason': dec['reason'], + 'fee': fees['total_fee'] + }) + print(f"[智能引擎] 买入 {code} {dec['name']} {shares}股@{price:.2f} " + f"金额¥{cost:,.0f} 手续费¥{fees['total_fee']:.2f} | {dec['reason']}") + + conn.commit() + + buy_count = len([r for r in results if r['type'] == 'buy']) + sell_count = len([r for r in results if r['type'] in ('sell', 'partial_sell')]) + total_fees = sum(r.get('fee', 0) for r in results) + print(f"[智能引擎] 用户{user_id}完成: 买入{buy_count}笔, 卖出{sell_count}笔, " + f"总手续费¥{total_fees:.2f}, 算法: {algo_name}") + if skipped_limit: + print(f"[智能引擎] 涨跌停跳过: {', '.join(skipped_limit)}") + if skipped_t1: + print(f"[智能引擎] T+1跳过: {', '.join(skipped_t1)}") + + # 构建详细原因摘要 + reasons = [] + if sell_count > 0: + reasons.append(f"卖出{sell_count}笔") + if buy_count > 0: + reasons.append(f"买入{buy_count}笔") + if skipped_t1: + t1_details = [] + for code in skipped_t1: + pos = next((p for p in positions if p['stock_code'] == code), None) + if pos: + cp = current_prices.get(code, 0) + bp = float(pos.get('avg_cost', 0) or 0) + pct = ((cp - bp) / bp * 100) if bp > 0 else 0 + t1_details.append(f"{code}({pct:+.1f}%)") + else: + t1_details.append(code) + reasons.append(f"T+1限制: {', '.join(t1_details)}") + if skipped_limit: + reasons.append(f"涨跌停: {', '.join(skipped_limit)}") + + # 计算可用现金信息 + total_capital = float(config.get('total_capital', 200000)) + cur.execute(""" + SELECT COALESCE(SUM(total_cost), 0)::float as total_invested + FROM sim_positions WHERE user_id = %s AND quantity > 0 + """, (user_id,)) + total_invested = cur.fetchone()['total_invested'] + available_cash = total_capital - total_invested + if buy_count == 0 and available_cash < total_capital * 0.05: + cash_pct = total_invested / total_capital * 100 + reasons.append(f"可用资金¥{available_cash:,.0f}({cash_pct:.0f}%已投)") + + # 卖出决策但被跳过的情况 — 提供详细原因 + if len(sell_decisions) > 0 and sell_count == 0: + for dec in sell_decisions: + code = dec['code'] + if code in skipped_t1: + pass # 已记录 + elif any(code in s for s in skipped_limit): + pass # 已记录 + + return { + 'success': True, + 'results': results, + 'signals': len(results), + 'algo': algo_name, + 'total_fees': total_fees, + 'skipped_limit': skipped_limit, + 'skipped_t1': skipped_t1, + 'detail_reasons': reasons, + 'available_cash': available_cash, + 'total_invested': total_invested, + } + + except Exception as e: + conn.rollback() + traceback.print_exc() + return {'success': False, 'error': str(e)} + + +# ═══════════════════════════════════════════════════════ +# 8. 获取交易引擎状态(供前端展示) +# ═══════════════════════════════════════════════════════ + +def get_engine_status(conn, user_id): + """获取智能交易引擎的当前状态,供前端Dashboard展示""" + from psycopg2.extras import RealDictCursor + + config = get_user_algo_config(conn, user_id) + positions = get_all_position_meta(conn, user_id) + + # 最近信号 + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute(""" + SELECT * FROM sim_trade_signals + WHERE user_id = %s + ORDER BY signal_date DESC, id DESC + LIMIT 20 + """, (user_id,)) + recent_signals = cur.fetchall() + + # 统计 + total_capital = float(config.get('total_capital', 200000)) + total_invested = sum( + float(p.get('buy_price', 0)) * (p.get('current_shares', 0) or p.get('quantity', 0)) + for p in positions + ) + available_cash = total_capital - total_invested + + # 持仓详情 + position_details = [] + for pos in positions: + buy_price = float(pos.get('buy_price', 0)) + current_price = float(pos.get('current_price', 0) or buy_price) + shares = pos.get('current_shares', 0) or pos.get('quantity', 0) + pnl_pct = (current_price - buy_price) / buy_price * 100 if buy_price > 0 else 0 + + # 当前生效的规则 + active_rules = [] + if pos.get('breakeven_active'): + active_rules.append('🛡️ 保本止损') + if pos.get('momentum_trailing_active'): + active_rules.append('📈 动量跟踪') + if pos.get('partial_exit_done'): + active_rules.append('✂️ 已部分止盈') + + tp = float(config.get('take_profit_pct', 12)) + sl = float(config.get('stop_loss_pct', 8)) + be_at_val = float(config.get('breakeven_at', 0)) + + position_details.append({ + 'code': pos['stock_code'], + 'buy_price': buy_price, + 'current_price': current_price, + 'shares': shares, + 'days_held': pos.get('days_held', 0), + 'pnl_pct': round(pnl_pct, 2), + 'pnl_amount': round((current_price - buy_price) * shares, 2), + 'active_rules': active_rules, + 'tp_target': round(buy_price * (1 + tp / 100), 2), + 'sl_target': round(buy_price * (1 - (0 if pos.get('breakeven_active') else sl) / 100), 2), + 'be_trigger': round(buy_price * (1 + be_at_val / 100), 2) if be_at_val > 0 else None, + 'consec_up': pos.get('consecutive_up_days', 0), + 'consec_sell': pos.get('consecutive_sell_signals', 0), + }) + + return { + 'algo_config': config, + 'positions': position_details, + 'total_capital': total_capital, + 'total_invested': round(total_invested, 2), + 'available_cash': round(available_cash, 2), + 'utilization_pct': round(total_invested / total_capital * 100, 1) if total_capital > 0 else 0, + 'recent_signals': [dict(s) for s in recent_signals], + } diff --git a/stock-html/services/stock_algorithms.py b/stock-html/services/stock_algorithms.py new file mode 100644 index 0000000..d960811 --- /dev/null +++ b/stock-html/services/stock_algorithms.py @@ -0,0 +1,907 @@ +""" +统一算法模块 — 全部核心算法的唯一定义处(Single Source of Truth) + +包含: +1. compute_recommend — 统一推荐逻辑(买入/卖出/加仓/观望等,严格遵循suanfa.md) +2. get_kline_data — 获取K线数据并返回 DataFrame(优先本地DB → 阿里云 → 腾讯 → 麦蕊 → AKShare) +3. fetch_kline_rows — 获取K线数据并返回 tuple 行列表(用于写入DB同步) +4. get_latest_price — 获取股票最新价格 +5. code_to_market — 股票代码→市场判断(SH/SZ/BJ) +6. 各 API session 管理 +7. compute_bull_stage — 牛股阶段识别(底部→起爆→确立→加速→补涨) +8. find_bull_stocks — 从扫描结果中找出潜在牛股 + +调用方: +- routes/analysis.py → compute_recommend, get_kline_data +- services/scheduler.py → compute_recommend, get_latest_price +- full_signal_scan.py → get_kline_data, API sessions +- sync_kline.py → fetch_kline_rows, API sessions +- routes/market.py → get_kline_data +""" + +import threading +from datetime import datetime, timedelta + +import requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +from config import Config + + +# ═══════════════════════════════════════════════ +# 1. 股票代码 → 市场 工具函数 +# ═══════════════════════════════════════════════ + +def code_to_market(code): + """6位股票代码 → 市场代码(SH/SZ/BJ)""" + if code.startswith(('0', '3')): + return 'SZ' + elif code.startswith(('8', '9')): + return 'BJ' + else: + return 'SH' + + +def is_bj_stock(code): + """是否是北交所股票""" + return code.startswith(('8', '9')) + + +def code_to_ali_symbol(code): + """6位股票代码 → 阿里云API格式(SH600519 / SZ000001 / BJ920720)""" + return f'{code_to_market(code)}{code}' + + +def code_to_tencent_symbol(code): + """6位股票代码 → 腾讯API格式(sh600519 / sz000001 / bj920720)""" + return f'{code_to_market(code).lower()}{code}' + + +# ═══════════════════════════════════════════════ +# 2. API Session 管理(线程安全,连接池复用) +# ═══════════════════════════════════════════════ + +_ali_session = None +_ali_lock = threading.Lock() + +_tencent_session = None +_tencent_lock = threading.Lock() + + +def get_ali_session(): + """获取阿里云API专用 Session(线程安全,单例)""" + global _ali_session + if _ali_session is None: + with _ali_lock: + if _ali_session is None: + s = requests.Session() + retry = Retry(total=2, backoff_factor=0.3, + status_forcelist=[500, 502, 503, 504]) + adapter = HTTPAdapter(max_retries=retry, + pool_connections=20, pool_maxsize=20) + s.mount('https://', adapter) + s.headers.update({ + 'Authorization': f'APPCODE {Config.ALICLOUD_APPCODE}', + 'Content-Type': 'application/x-www-form-urlencoded', + }) + _ali_session = s + return _ali_session + + +def get_tencent_session(): + """获取腾讯K线API专用 Session(线程安全,单例)""" + global _tencent_session + if _tencent_session is None: + with _tencent_lock: + if _tencent_session is None: + s = requests.Session() + retry = Retry(total=2, backoff_factor=0.3, + status_forcelist=[500, 502, 503, 504]) + adapter = HTTPAdapter(max_retries=retry, + pool_connections=20, pool_maxsize=20) + s.mount('https://', adapter) + s.headers.update({ + 'User-Agent': ('Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' + 'AppleWebKit/537.36'), + }) + _tencent_session = s + return _tencent_session + + +# API URL 常量 +ALICLOUD_KLINE_URL = Config.ALICLOUD_KLINE_URL +TENCENT_KLINE_URL = 'https://proxy.finance.qq.com/ifzqgtimg/appstock/app/newfqkline/get' + + +# ═══════════════════════════════════════════════ +# 3. K线数据获取 — DataFrame 格式(供信号检测/分析用) +# ═══════════════════════════════════════════════ + +def get_kline_data(stock_code, days=120, use_local_db=True): + """ + 获取K线数据,返回 pandas DataFrame (columns: date, open, high, low, close, volume) + + 数据源优先级: 本地DB → 阿里云API → 腾讯API → 麦蕊API → AKShare + + 参数: + stock_code: 6位股票代码 + days: 获取天数 + use_local_db: 是否优先使用本地DB(全景扫描时为True, 实时分析时可为False) + + 返回: + DataFrame 或 None + """ + import pandas as pd + + # 1. 优先从本地数据库读取 + if use_local_db: + df = _get_kline_from_local_db(stock_code, days) + if df is not None: + return df + + # 2. 阿里云K线API(沪深最稳定,北交所可能不支持) + if not is_bj_stock(stock_code): + df = _fetch_ali_kline_df(stock_code, days) + if df is not None and len(df) >= 30: + return df + + # 3. 腾讯K线API(全市场,含北交所) + df = _fetch_tencent_kline_df(stock_code, days) + if df is not None and len(df) >= 30: + return df + + # 4. 麦蕊API + df = _fetch_mairui_kline_df(stock_code, days) + if df is not None and len(df) >= 30: + return df + + # 5. AKShare + df = _fetch_akshare_kline_df(stock_code, days) + if df is not None and len(df) >= 30: + return df + + return None + + +def _get_kline_from_local_db(stock_code, days=120): + """从本地数据库读取K线(最快,毫秒级)""" + import pandas as pd + try: + import psycopg2 + conn = psycopg2.connect( + host=Config.DB_HOST, port=Config.DB_PORT, + dbname=Config.DB_NAME, user=Config.DB_USER, password=Config.DB_PASSWORD, + ) + conn.autocommit = True + start_date = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') + with conn.cursor() as cur: + cur.execute(""" + SELECT trade_date, open, high, low, close, volume + FROM stock_kline_daily + WHERE code = %s AND trade_date >= %s + ORDER BY trade_date + """, (stock_code, start_date)) + rows = cur.fetchall() + conn.close() + + if rows and len(rows) >= 30: + df = pd.DataFrame(rows, columns=['date', 'open', 'high', 'low', 'close', 'volume']) + df['date'] = df['date'].astype(str) + for col in ('open', 'high', 'low', 'close', 'volume'): + df[col] = df[col].astype(float) + return df + except Exception: + pass + return None + + +# ---- 线程本地连接(供多线程扫描时使用,避免频繁建连) ---- +_thread_local = threading.local() + + +def get_kline_from_local_db_threaded(stock_code, days=120): + """多线程扫描专用:使用线程本地连接从本地DB读取K线""" + import pandas as pd + try: + conn = getattr(_thread_local, 'kline_conn', None) + if conn is None or conn.closed: + import psycopg2 + conn = psycopg2.connect( + host=Config.DB_HOST, port=Config.DB_PORT, + dbname=Config.DB_NAME, user=Config.DB_USER, password=Config.DB_PASSWORD, + ) + conn.autocommit = True + _thread_local.kline_conn = conn + + start_date = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') + with conn.cursor() as cur: + cur.execute(""" + SELECT trade_date, open, high, low, close, volume + FROM stock_kline_daily + WHERE code = %s AND trade_date >= %s + ORDER BY trade_date + """, (stock_code, start_date)) + rows = cur.fetchall() + + if rows and len(rows) >= 30: + df = pd.DataFrame(rows, columns=['date', 'open', 'high', 'low', 'close', 'volume']) + df['date'] = df['date'].astype(str) + for col in ('open', 'high', 'low', 'close', 'volume'): + df[col] = df[col].astype(float) + return df + except Exception: + pass + return None + + +def _fetch_ali_kline_df(stock_code, days=120): + """阿里云K线API → DataFrame""" + import pandas as pd + try: + session = get_ali_session() + symbol = code_to_ali_symbol(stock_code) + resp = session.post(ALICLOUD_KLINE_URL, data={ + 'symbol': symbol, + 'type': '240', + 'limit': str(min(days, 300)), + 'ma': '5', + }, timeout=10) + if resp.status_code == 200: + data = resp.json() + if data.get('success') and data.get('data', {}).get('list'): + records = [] + for item in data['data']['list']: + day_str = item.get('day', '') + if not day_str or len(day_str) < 10: + continue + records.append({ + 'date': day_str[:10], + 'open': float(item.get('open', 0)), + 'high': float(item.get('high', 0)), + 'low': float(item.get('low', 0)), + 'close': float(item.get('close', 0)), + 'volume': float(item.get('volume', 0)), + }) + if records: + return pd.DataFrame(records) + except Exception: + pass + return None + + +def _fetch_tencent_kline_df(stock_code, days=120): + """腾讯K线API → DataFrame(全市场含北交所)""" + import pandas as pd + try: + session = get_tencent_session() + symbol = code_to_tencent_symbol(stock_code) + start_date = (datetime.now() - timedelta(days=days + 30)).strftime('%Y-%m-%d') + resp = session.get(TENCENT_KLINE_URL, params={ + 'param': f'{symbol},day,{start_date},,{min(days, 300)},qfq', + }, timeout=15) + if resp.status_code == 200: + data = resp.json() + stock_data = data.get('data', {}).get(symbol, {}) + klines = stock_data.get('qfqday') or stock_data.get('day') or [] + if klines: + records = [] + for item in klines: + if len(item) < 6: + continue + # 腾讯格式: [date, open, close, high, low, volume, ...] + records.append({ + 'date': item[0][:10], + 'open': float(item[1]), + 'high': float(item[3]), # high = position 3 + 'low': float(item[4]), # low = position 4 + 'close': float(item[2]), # close = position 2 + 'volume': float(item[5]), + }) + if records: + return pd.DataFrame(records) + except Exception: + pass + return None + + +def _fetch_mairui_kline_df(stock_code, days=120): + """麦蕊API → DataFrame""" + import pandas as pd + try: + from services.mairui_api import get_kline + result = get_kline(stock_code, period='d', days=days, adjust='f') + if result['success'] and result['data']: + df = pd.DataFrame(result['data']) + df.rename(columns={ + 'date': 'date', 'open': 'open', 'high': 'high', + 'low': 'low', 'close': 'close', 'volume': 'volume', + }, inplace=True) + if len(df) >= 30: + return df + except Exception: + pass + return None + + +def _fetch_akshare_kline_df(stock_code, days=120): + """AKShare → DataFrame (支持自动降级到腾讯数据源)""" + import pandas as pd + try: + from utils.data_fetcher import fetch_stock_hist + end_date = datetime.now().strftime('%Y%m%d') + start_date = (datetime.now() - timedelta(days=days)).strftime('%Y%m%d') + df = fetch_stock_hist( + stock_code=stock_code, period='daily', + start_date=start_date, end_date=end_date, adjust='qfq', + ) + if df is not None and not df.empty: + df = df.rename(columns={ + '日期': 'date', '开盘': 'open', '最高': 'high', + '最低': 'low', '收盘': 'close', '成交量': 'volume', + }) + df = df[['date', 'open', 'high', 'low', 'close', 'volume']] + return df + except Exception: + pass + return None + + +# ═══════════════════════════════════════════════ +# 4. K线数据获取 — tuple行格式(供 sync_kline.py 写入DB用) +# ═══════════════════════════════════════════════ + +def fetch_kline_rows(code, days): + """ + 获取K线数据,返回 list of tuple: (code, date, open, high, low, close, volume, amount) + + 数据源优先级: 阿里云API → 腾讯API → 麦蕊API → AKShare + 北交所(8XX/9XX)直接走腾讯API + + 供 sync_kline.py 同步到本地数据库使用。 + """ + bj = is_bj_stock(code) + + # 北交所:直接用腾讯API(阿里云/Mairui不支持BJ) + if bj: + rows = _fetch_tencent_kline_rows(code, days) + if rows: + return rows + return None + + # 1. 首选:阿里云K线API + rows = _fetch_ali_kline_rows(code, days) + if rows: + return rows + + # 2. 回退:腾讯API + rows = _fetch_tencent_kline_rows(code, days) + if rows: + return rows + + # 3. 回退:Mairui API + rows = _fetch_mairui_kline_rows(code, days) + if rows: + return rows + + # 4. 最后回退:AKShare + rows = _fetch_akshare_kline_rows(code, days) + if rows: + return rows + + return None + + +def _fetch_ali_kline_rows(code, days): + """阿里云K线API → tuple rows""" + try: + session = get_ali_session() + symbol = code_to_ali_symbol(code) + resp = session.post(ALICLOUD_KLINE_URL, data={ + 'symbol': symbol, + 'type': '240', + 'limit': str(min(days, 300)), + 'ma': '5', + }, timeout=10) + if resp.status_code == 200: + data = resp.json() + if data.get('success') and data.get('data', {}).get('list'): + rows = [] + for item in data['data']['list']: + day_str = item.get('day', '') + if not day_str or len(day_str) < 10: + continue + rows.append(( + code, + day_str[:10], + float(item.get('open', 0)), + float(item.get('high', 0)), + float(item.get('low', 0)), + float(item.get('close', 0)), + int(item.get('volume', 0)), + float(item.get('amount', 0)), + )) + if rows: + return rows + except Exception: + pass + return None + + +def _fetch_tencent_kline_rows(code, days): + """腾讯K线API → tuple rows(全市场含北交所)""" + try: + symbol = code_to_tencent_symbol(code) + session = get_tencent_session() + start_date = (datetime.now() - timedelta(days=days + 30)).strftime('%Y-%m-%d') + resp = session.get(TENCENT_KLINE_URL, params={ + 'param': f'{symbol},day,{start_date},,{min(days, 300)},qfq', + }, timeout=15) + if resp.status_code == 200: + data = resp.json() + stock_data = data.get('data', {}).get(symbol, {}) + klines = stock_data.get('qfqday') or stock_data.get('day') or [] + if klines: + rows = [] + for item in klines: + if len(item) < 6: + continue + date_str = item[0] + if not date_str or len(date_str) < 10: + continue + rows.append(( + code, + date_str[:10], + float(item[1]), # open + float(item[3]), # high (position 3) + float(item[4]), # low (position 4) + float(item[2]), # close (position 2) + int(float(item[5])), # volume + float(item[8]) * 10000 if len(item) > 8 and item[8] else 0, + )) + if rows: + return rows + except Exception: + pass + return None + + +def _fetch_mairui_kline_rows(code, days): + """麦蕊API → tuple rows""" + try: + from services.mairui_api import get_kline + result = get_kline(code, period='d', days=days, adjust='f') + if result['success'] and result['data']: + rows = [] + for item in result['data']: + date_str = item.get('date', '') + if not date_str: + continue + rows.append(( + code, + date_str, + item.get('open', 0), + item.get('high', 0), + item.get('low', 0), + item.get('close', 0), + int(item.get('volume', 0)), + item.get('amount', 0), + )) + if rows: + return rows + except Exception: + pass + return None + + +def _fetch_akshare_kline_rows(code, days): + """AKShare → tuple rows (支持自动降级到腾讯数据源)""" + try: + from utils.data_fetcher import fetch_stock_hist + end_date = datetime.now().strftime('%Y%m%d') + start_date = (datetime.now() - timedelta(days=days)).strftime('%Y%m%d') + df = fetch_stock_hist( + stock_code=code, period='daily', + start_date=start_date, end_date=end_date, adjust='qfq', + ) + if df is not None and not df.empty: + rows = [] + for _, r in df.iterrows(): + rows.append(( + code, + str(r['日期']), + float(r['开盘']), + float(r['最高']), + float(r['最低']), + float(r['收盘']), + int(r['成交量']), + float(r.get('成交额', 0)), + )) + if rows: + return rows + except Exception: + pass + return None + + +# ═══════════════════════════════════════════════ +# 5. 统一推荐算法 +# ═══════════════════════════════════════════════ + +def compute_recommend(signal_status, indicators, triggered_count, is_holding): + """ + 统一推荐逻辑 — 全局唯一定义(严格遵循 suanfa.md 体系最强战法)。 + + 体系最强战法流程(suanfa.md): + 1. 日线底背离 → 纳入关注范围 + 2. 龙抬头出现 → 执行买入操作(实操核心买点) + 3. 真龙/主升浪 → 持有仓位+加仓(不是新买入!) + 4. 不见主升浪 → 不出场 + + 参数: + signal_status: list[dict] 信号状态列表(来自 signal_detector._check_all_signal_status) + indicators: dict 最新技术指标(含 macd.dif, macd.dea 等) + triggered_count: int 触发信号数量 + is_holding: bool 当前是否持仓该股票 + + 返回: + tuple: (signal_type, display_text, reason, recommend_rate) + - signal_type: 'buy' | 'sell' | 'watch' + - display_text: '买入' | '卖出' | '加仓' | '持有' | '关注' | '观察' | '观望' + - reason: str 推荐理由 + - recommend_rate: int 推荐评分 0-100 + + 使用场景: + - 全景扫描结果推荐列 + - 策略建议分档 + - 提醒 tab 买卖推荐 + - 模拟交易自动买卖决策 + """ + if not signal_status: + return ('watch', '观望', '暂无信号数据', 0) + + ss = signal_status + sig_map = {} + for s in ss: + sig_map[s.get('type', '')] = s + + has_main_wave = sig_map.get('main_rising_wave', {}).get('triggered', False) + has_divergence = sig_map.get('daily_bottom_divergence', {}).get('triggered', False) + has_dragon = sig_map.get('dragon_head', {}).get('triggered', False) + has_real_dragon = sig_map.get('true_dragon', {}).get('triggered', False) + + macd = (indicators or {}).get('macd', {}) + dif = macd.get('dif', 0) + dea = macd.get('dea', 0) + + triggered_signals = [s.get('name', s.get('type', '')) for s in ss if s.get('triggered')] + + # ════════════════════════════════════════════ + # 持仓逻辑(suanfa.md 步骤3-4) + # ════════════════════════════════════════════ + if is_holding: + # 卖出条件: MACD死叉 + 无主升浪 → 趋势走弱,不见主升浪则出场 + if dif < dea and not has_main_wave: + return ('sell', '卖出', + f"MACD死叉(DIF={dif:.3f}= dea) + if macd_golden: + return ('buy', '买入', '日线底背离+龙抬头 → 最佳买入信号', 95) + return ('watch', '关注', + f'底背离+龙抬头但MACD死叉(DIF={dif:.3f}= dea) # MACD 金叉或无数据 + if has_main_wave and macd_ok: + return ('buy', '买入', '龙抬头+主升浪 → 强势买入信号', 90) + if macd_ok: + return ('buy', '买入', '龙抬头出现 → 短线起爆点,执行买入', 80) + # MACD死叉 + 龙抬头 → 信号冲突,降级为关注 + return ('watch', '关注', + f'龙抬头出现但MACD死叉(DIF={dif:.3f} 0: + sigs = '、'.join(triggered_signals[:3]) + return ('watch', '观察', f"触发{triggered_count}个信号: {sigs}", 40) + + return ('watch', '观望', '无核心信号触发', 0) + + +# ═══════════════════════════════════════════════ +# 6. 获取最新价格 +# ═══════════════════════════════════════════════ + +def get_latest_price(stock_code): + """ + 获取股票最新价格(从本地 stock_realtime_price 表) + + 返回: + float: 最新价格, 失败返回 0 + """ + try: + import psycopg2 + conn = psycopg2.connect( + host=Config.DB_HOST, port=Config.DB_PORT, + dbname=Config.DB_NAME, user=Config.DB_USER, password=Config.DB_PASSWORD, + ) + cur = conn.cursor() + cur.execute(""" + SELECT price FROM stock_realtime_price + WHERE code = %s AND price > 0 + """, (stock_code,)) + row = cur.fetchone() + conn.close() + if row: + return float(row[0]) + except Exception: + pass + return 0 + + +# ═══════════════════════════════════════════════ +# 7. 牛股阶段识别(suanfa.md 标准牛股启动信号先后顺序) +# ═══════════════════════════════════════════════ + +# 标准牛股启动流程(底部→拉升): +# 阶段1 → 日线底背离/短底背离(跌到底部,停止下跌) +# 阶段2 → 龙抬头(资金进场,短线起爆) +# 阶段3 → 真龙(趋势正式确立) +# 阶段4 → ★主升浪(进入加速段,利润兑现最快) +# 阶段5 → 反弹(中途回调后的补涨信号) +# 补充 → 老鼠仓可在底部任意位置提前出现 + +BULL_STAGES = { + 1: {'name': '底部探测', 'icon': '', 'color': '#2196F3', + 'desc': '日线底背离/短底背离 → 跌到底部,停止下跌'}, + 2: {'name': '资金进场', 'icon': '', 'color': '#4CAF50', + 'desc': '龙抬头 → 资金进场,短线起爆点(最佳买入时机)'}, + 3: {'name': '趋势确立', 'icon': '', 'color': '#FF9800', + 'desc': '真龙 → 中期趋势正式确立'}, + 4: {'name': '加速拉升', 'icon': '', 'color': '#F44336', + 'desc': '★主升浪 → 进入加速段,利润兑现最快'}, + 5: {'name': '回调补涨', 'icon': '', 'color': '#9C27B0', + 'desc': '反弹 → 中途回调后的补涨信号'}, +} + + +def compute_bull_stage(signal_status): + """ + 识别股票在标准牛股启动流程中的阶段。 + + 参数: + signal_status: list[dict] 信号状态列表 + + 返回: + dict: { + 'stage': int (0-5, 0=未进入流程), + 'stage_name': str, + 'stage_icon': str, + 'stage_color': str, + 'stage_desc': str, + 'signals_active': list[str], # 当前活跃的信号名称 + 'progress': int (0-100), # 牛股流程进度百分比 + 'next_signal': str, # 下一个期待的信号 + 'investment_advice': str, # 投资建议 + 'has_rat_trading': bool, # 是否有老鼠仓(提前埋伏信号) + } + """ + if not signal_status: + return { + 'stage': 0, 'stage_name': '观望', 'stage_icon': '', + 'stage_color': '#9E9E9E', 'stage_desc': '无信号触发', + 'signals_active': [], 'progress': 0, + 'next_signal': '等待底背离/短底背离', 'investment_advice': '暂无操作机会', + 'has_rat_trading': False, + } + + sig_map = {} + for s in signal_status: + sig_map[s.get('type', '')] = s + + has_divergence = sig_map.get('daily_bottom_divergence', {}).get('triggered', False) + has_short_div = sig_map.get('short_bottom_divergence', {}).get('triggered', False) + has_dragon = sig_map.get('dragon_head', {}).get('triggered', False) + has_true_dragon = sig_map.get('true_dragon', {}).get('triggered', False) + has_main_wave = sig_map.get('main_rising_wave', {}).get('triggered', False) + has_rebound = sig_map.get('rebound', {}).get('triggered', False) + has_rat = sig_map.get('rat_trading', {}).get('triggered', False) + + signals_active = [] + if has_divergence: + signals_active.append('日线底背离') + if has_short_div: + signals_active.append('短底背离') + if has_dragon: + signals_active.append('龙抬头') + if has_true_dragon: + signals_active.append('真龙') + if has_main_wave: + signals_active.append('主升浪') + if has_rebound: + signals_active.append('反弹') + if has_rat: + signals_active.append('老鼠仓') + + # 确定阶段(按最高阶段判定) + stage = 0 + if has_main_wave: + stage = 4 + elif has_true_dragon: + stage = 3 + elif has_dragon: + stage = 2 + elif has_divergence or has_short_div: + stage = 1 + elif has_rebound: + stage = 5 + elif has_rat: + stage = 1 # 老鼠仓归入底部阶段 + + if stage == 0: + return { + 'stage': 0, 'stage_name': '观望', 'stage_icon': '', + 'stage_color': '#9E9E9E', 'stage_desc': '无核心信号触发', + 'signals_active': signals_active, 'progress': 0, + 'next_signal': '等待底背离/短底背离', + 'investment_advice': '暂无操作机会', + 'has_rat_trading': has_rat, + } + + info = BULL_STAGES[stage] + + # 计算流程进度(越靠后越高) + # 加分项:多信号叠加说明流程更完整 + base_progress = {1: 20, 2: 45, 3: 65, 4: 85, 5: 50} + progress = base_progress.get(stage, 0) + if stage <= 2 and has_divergence: + progress += 10 # 有底背离做基础更好 + if stage >= 2 and has_dragon: + progress += 5 + if stage >= 3 and has_true_dragon: + progress += 5 + if has_rat: + progress += 5 # 老鼠仓加分 + progress = min(progress, 100) + + # 下一步信号期待 + next_signals = { + 1: '等待龙抬头(资金进场信号)', + 2: '等待真龙(趋势确认信号)', + 3: '等待主升浪(加速拉升信号)', + 4: '持有!不见主升浪消失不出场', + 5: '等待龙抬头/真龙确认趋势', + } + + # 投资建议 + advices = { + 1: '纳入关注池,等待龙抬头出现后买入', + 2: '最佳买入时机!龙抬头=实操核心买点', + 3: '趋势已确立,可以追入,建议等回调买入', + 4: '已在加速段,持仓者加仓/持有,新入者谨慎追高', + 5: '回调中可关注,但需确认不是假反弹', + } + + return { + 'stage': stage, + 'stage_name': info['name'], + 'stage_icon': info['icon'], + 'stage_color': info['color'], + 'stage_desc': info['desc'], + 'signals_active': signals_active, + 'progress': progress, + 'next_signal': next_signals.get(stage, ''), + 'investment_advice': advices.get(stage, ''), + 'has_rat_trading': has_rat, + } + + +def find_bull_stocks(scan_rows, holding_codes=None): + """ + 从扫描结果中找出潜在牛股,按阶段分组排序。 + + 参数: + scan_rows: list[dict] 扫描结果列表 (含 code, name, signal_status, indicators, triggered_count) + holding_codes: set 持仓代码集合 + + 返回: + dict: { + 'stages': {1: [...], 2: [...], ...}, # 按阶段分组的股票列表 + 'summary': {1: count, 2: count, ...}, # 各阶段数量统计 + 'total': int, # 有信号的总数 + } + """ + if holding_codes is None: + holding_codes = set() + + stages = {1: [], 2: [], 3: [], 4: [], 5: []} + summary = {0: 0, 1: 0, 2: 0, 3: 0, 4: 0, 5: 0} + + for row in scan_rows: + signal_status = row.get('signal_status') or [] + if not signal_status: + summary[0] += 1 + continue + + # 判断牛股阶段 + bull = compute_bull_stage(signal_status) + stage = bull['stage'] + summary[stage] += 1 + + if stage == 0: + continue + + # 计算推荐 + is_holding = row.get('code', '') in holding_codes + st, disp, reason, rate = compute_recommend( + signal_status, row.get('indicators'), + row.get('triggered_count'), is_holding, + ) + + item = { + 'code': row.get('code', ''), + 'name': row.get('name', ''), + 'stage': stage, + 'stage_name': bull['stage_name'], + 'stage_icon': bull['stage_icon'], + 'stage_color': bull['stage_color'], + 'signals_active': bull['signals_active'], + 'progress': bull['progress'], + 'next_signal': bull['next_signal'], + 'investment_advice': bull['investment_advice'], + 'has_rat_trading': bull['has_rat_trading'], + 'recommend_type': st, + 'recommend_text': disp, + 'recommend_reason': reason, + 'recommend_rate': rate, + 'is_holding': is_holding, + 'triggered_count': row.get('triggered_count', 0), + } + + stages[stage].append(item) + + # 每个阶段内按推荐评分降序排序 + for stage_num in stages: + stages[stage_num].sort(key=lambda x: (-x['recommend_rate'], -x['progress'])) + + total = sum(len(v) for v in stages.values()) + + return { + 'stages': stages, + 'summary': summary, + 'total': total, + 'stage_info': BULL_STAGES, + } diff --git a/stock-html/services/stock_service.py b/stock-html/services/stock_service.py new file mode 100644 index 0000000..adf6a68 --- /dev/null +++ b/stock-html/services/stock_service.py @@ -0,0 +1,357 @@ +""" +股票数据服务 - 获取、缓存、分析 +""" +import pandas as pd +import numpy as np +from datetime import datetime, timedelta +import traceback +import json +import os +from config import Config + + +# ========== 股票名称缓存 ========== +_stock_name_cache = {} + + +def _load_stock_name_cache(): + """从本地文件加载股票名称缓存""" + global _stock_name_cache + try: + if os.path.exists(Config.STOCK_NAME_CACHE_FILE): + with open(Config.STOCK_NAME_CACHE_FILE, 'r', encoding='utf-8') as f: + _stock_name_cache = json.load(f) + print(f"加载股票名称缓存:{len(_stock_name_cache)}条") + except Exception as e: + print(f"加载股票名称缓存失败: {e}") + + +def _save_stock_name_cache(): + """保存股票名称缓存到本地""" + try: + with open(Config.STOCK_NAME_CACHE_FILE, 'w', encoding='utf-8') as f: + json.dump(_stock_name_cache, f, ensure_ascii=False, indent=2) + except Exception as e: + print(f"保存股票名称缓存失败: {e}") + + +def get_stock_name(stock_code): + """获取股票名称 — 使用腾讯财经API""" + global _stock_name_cache + + if stock_code in _stock_name_cache: + return _stock_name_cache[stock_code] + + # 腾讯财经API获取股票名称 + try: + import requests as _req + tcode = ('sh' if stock_code.startswith('6') else 'sz') + stock_code + _r = _req.get(f'http://qt.gtimg.cn/q={tcode}', timeout=5, + headers={'Referer': 'https://finance.qq.com'}) + if _r.status_code == 200 and '\"' in _r.text: + _fields = _r.text.split('\"')[1].split('~') + if len(_fields) > 2 and _fields[1]: + _stock_name_cache[stock_code] = _fields[1] + _save_stock_name_cache() + return _fields[1] + except Exception as e: + print(f"获取股票名称失败(腾讯): {e}") + + return None + + +# ========== 股票数据缓存 ========== + +def _get_cache_file_path(stock_code): + """获取缓存文件路径""" + return os.path.join(Config.STOCK_DATA_CACHE_DIR, f'{stock_code}.json') + + +def load_cached_data(stock_code): + """加载缓存的股票数据""" + cache_file = _get_cache_file_path(stock_code) + if os.path.exists(cache_file): + try: + with open(cache_file, 'r', encoding='utf-8') as f: + data = json.load(f) + df = pd.DataFrame(data['records']) + if not df.empty and '日期' in df.columns: + df['日期'] = pd.to_datetime(df['日期']) + return df, data.get('stock_name'), data.get('last_update') + except Exception as e: + print(f"加载缓存数据失败: {e}") + return None, None, None + + +def save_cached_data(stock_code, df, stock_name): + """保存股票数据到缓存""" + cache_file = _get_cache_file_path(stock_code) + try: + df_copy = df.copy() + df_copy['日期'] = df_copy['日期'].dt.strftime('%Y-%m-%d') + records = df_copy.to_dict('records') + + data = { + 'stock_code': stock_code, + 'stock_name': stock_name, + 'last_update': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + 'records': records + } + + with open(cache_file, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + print(f"已保存 {stock_code} 数据,共 {len(records)} 条") + except Exception as e: + print(f"保存缓存数据失败: {e}") + + +# ========== 获取股票资金流向数据 ========== + +def get_stock_fund_flow(stock_code, start_date, end_date, force_refresh=False): + """ + 获取股票资金流向数据(支持缓存,增量获取) + force_refresh: 强制刷新缓存 + 返回: (DataFrame, stock_name, error_msg) + """ + try: + # 判断市场 + if stock_code.startswith('6'): + market = 'sh' + elif stock_code.startswith('0') or stock_code.startswith('3'): + market = 'sz' + else: + return None, None, "无法识别股票代码所属市场" + + stock_name = get_stock_name(stock_code) + start = pd.to_datetime(start_date) + end = pd.to_datetime(end_date) + + # 加载缓存 + cached_df, cached_name, last_update = load_cached_data(stock_code) + need_fetch = force_refresh + new_data_df = None + + if cached_df is not None and not cached_df.empty: + # 如果缓存是今天的,直接使用 + if last_update: + try: + update_date = pd.to_datetime(last_update.split()[0]) + today = pd.to_datetime(datetime.now().strftime('%Y-%m-%d')) + if update_date >= today and not force_refresh: + # 今天已更新,直接使用缓存 + df = cached_df[(cached_df['日期'] >= start) & (cached_df['日期'] <= end)] + df = df.sort_values('日期').reset_index(drop=True) + return df, cached_name or stock_name, None + except: + pass + cached_max_date = cached_df['日期'].max() + today = pd.to_datetime(datetime.now().strftime('%Y-%m-%d')) + + # 如果缓存数据不超过2天,直接使用(优化分析速度) + if cached_max_date >= today - timedelta(days=2) and not force_refresh: + if cached_df['日期'].min() <= start: + need_fetch = False + new_data_df = cached_df + print(f"使用缓存数据: {stock_code}, 最新日期: {cached_max_date.strftime('%Y-%m-%d')}") + + if stock_name is None and cached_name: + stock_name = cached_name + + if need_fetch: + # 东方财富资金流向API已不可用(腾讯云网络限制),使用缓存数据 + print(f"资金流向API不可用,使用缓存: {stock_code}") + if cached_df is not None: + new_data_df = cached_df + else: + return None, None, "资金流向API不可用(东方财富已封锁),且无缓存数据" + + if new_data_df is None or new_data_df.empty: + # 最后尝试使用缓存数据(即使不在日期范围内) + if cached_df is not None and not cached_df.empty: + print(f"使用全部缓存数据: {stock_code}") + df = cached_df.sort_values('日期').reset_index(drop=True) + return df, cached_name or stock_name, None + return None, None, "无法获取数据" + + # 筛选日期范围 + df = new_data_df[(new_data_df['日期'] >= start) & (new_data_df['日期'] <= end)] + + # 如果筛选后为空,使用全部数据 + if df.empty and not new_data_df.empty: + print(f"日期范围无数据,使用全部缓存: {stock_code}") + df = new_data_df + + df = df.sort_values('日期').reset_index(drop=True) + + return df, stock_name, None + except Exception as e: + traceback.print_exc() + return None, None, f"获取数据失败: {str(e)}" + + +# ========== 分析股票数据 ========== + +def analyze_fund_flow_impact(df): + """分析资金流向对股价的影响(含成交量分析)""" + if df is None or df.empty: + return None + + df = df.sort_values('日期').reset_index(drop=True) + threshold = 2.0 + + if '超大单净流入-净占比' not in df.columns: + return None + + df['超大单净流入-净占比'] = df['超大单净流入-净占比'].fillna(0) + df['主力净流入-净占比'] = df['主力净流入-净占比'].fillna(0) + + df['超大单流向'] = df['超大单净流入-净占比'].apply( + lambda x: '大额流入' if x >= threshold else ('大额流出' if x <= -threshold else '普通') + ) + df['主力流向'] = df['主力净流入-净占比'].apply( + lambda x: '大额流入' if x >= threshold else ('大额流出' if x <= -threshold else '普通') + ) + + # 计算价格位置(改为60日) + latest = df.iloc[-1] + lookback = 60 # 从20日改为60日 + try: + actual_lookback = min(len(df), lookback) + if actual_lookback >= 5: # 至少需要5天数据 + recent = df.tail(actual_lookback) + high = float(recent['收盘价'].max() or 0) + low = float(recent['收盘价'].min() or 0) + current_price = float(latest.get('收盘价') or 0) + price_position = (current_price - low) / (high - low) * 100 if high != low else 50 + else: + price_position = 50 + except: + price_position = 50 + + # 成交量分析(基于主力净流入-净额作为成交额指标) + volume_ratio = 1.0 # 默认值 + volume_trend = '普通' + try: + if '主力净流入-净额' in df.columns and len(df) >= 10: + # 使用主力净流入绝对值作为活跃度指标 + df['活跃度'] = df['主力净流入-净额'].abs() + recent_5 = df.tail(5)['活跃度'].mean() + recent_20 = df.tail(min(20, len(df)))['活跃度'].mean() + + if recent_20 > 0: + volume_ratio = recent_5 / recent_20 + if volume_ratio >= 1.5: + volume_trend = '放量' + elif volume_ratio <= 0.5: + volume_trend = '缩量' + else: + volume_trend = '正常' + except: + pass + + # 计算均线MA5和MA20 + ma5 = 0 + ma20 = 0 + try: + if '收盘价' in df.columns and len(df) >= 5: + ma5 = df.tail(5)['收盘价'].mean() + if '收盘价' in df.columns and len(df) >= 20: + ma20 = df.tail(20)['收盘价'].mean() + except: + pass + + # 处理日期格式(可能是datetime或字符串) + def format_date(d): + if hasattr(d, 'strftime'): + return d.strftime('%Y-%m-%d') + return str(d)[:10] if d else '' + + def safe_float(val, default=0): + try: + return float(val) if val is not None else default + except: + return default + + return { + '最新数据': { + '日期': format_date(latest['日期']), + '收盘价': safe_float(latest.get('收盘价')), + '涨跌幅': safe_float(latest.get('涨跌幅')), + '价格位置': safe_float(price_position), + '超大单净流入占比': safe_float(latest.get('超大单净流入-净占比')), + '主力净流入占比': safe_float(latest.get('主力净流入-净占比')), + '超大单流向': latest.get('超大单流向', '普通'), + '主力流向': latest.get('主力流向', '普通'), + '成交量比': safe_float(volume_ratio, 1.0), + '量能趋势': volume_trend, + 'MA5': safe_float(ma5), + 'MA20': safe_float(ma20) + }, + '数据概览': { + '总交易日数': len(df), + '计算周期': min(len(df), lookback), + '日期范围': { + '开始': format_date(df['日期'].min()), + '结束': format_date(df['日期'].max()) + } + } + } + + +# ========== 实时价格 ========== + +def get_realtime_price(stock_code): + """获取实时价格(使用mairuiapi,更稳定)""" + try: + from services.mairui_api import get_realtime_price as mairui_get_price + result = mairui_get_price(stock_code) + if result['success']: + return result + except Exception as e: + print(f"mairuiapi获取实时价格失败({stock_code}): {e}") + + # 备用方案2:使用腾讯财经API(腾讯云可用) + try: + import requests as _req + tcode = ('sh' if stock_code.startswith('6') else 'sz') + stock_code + _r = _req.get(f'http://qt.gtimg.cn/q={tcode}', timeout=5, + headers={'Referer': 'https://finance.qq.com'}) + if _r.status_code == 200 and '\"' in _r.text: + _fields = _r.text.split('\"')[1].split('~') + if len(_fields) > 35 and _fields[3]: + return { + 'success': True, + 'data': { + 'code': stock_code, + 'name': _fields[1], + 'price': float(_fields[3]), + 'change': float(_fields[32]) if _fields[32] else 0, + } + } + except Exception as e: + print(f"腾讯财经备用方案失败({stock_code}): {e}") + + return {'success': False, 'error': '获取失败'} + + +def get_realtime_prices_batch(stock_codes): + """批量获取实时价格""" + try: + from services.mairui_api import get_realtime_prices_batch as mairui_batch + return mairui_batch(stock_codes) + except Exception as e: + print(f"mairuiapi批量获取失败: {e}") + + return {} + + +# ========== 热门股票 ========== + +def get_hot_stocks(limit=100): + """获取热门股票 — 东方财富API已不可用,返回空""" + # stock_hot_rank_em 为东方财富API,已在腾讯云被封锁 + return [] + + +# 初始化时加载缓存 +_load_stock_name_cache() diff --git a/stock-html/services/technical_indicators.py b/stock-html/services/technical_indicators.py new file mode 100644 index 0000000..0697237 --- /dev/null +++ b/stock-html/services/technical_indicators.py @@ -0,0 +1,130 @@ +""" +技术指标计算模块(numpy向量化优化版) +实现 MACD、SKDJ、EMA 等技术指标 + +优化要点: +- calc_sma 使用 numpy 原生数组替代 pandas.iloc,速度提升 5-10x +- calc_all_indicators 智能跳过已是 float 的类型转换 +""" +import pandas as pd +import numpy as np + + +def calc_ema(series, period): + """计算指数移动平均线(EMA) — 使用pandas的C底层ewm实现,已足够快""" + return series.ewm(span=period, adjust=False).mean() + + +def calc_sma(series, period, weight=1): + """ + 计算SMA(通达信公式风格) — numpy优化版 + SMA(X, N, M) = (M * X + (N - M) * prev_SMA) / N + + 优化:使用 numpy 原生数组 arr[i] 替代 pandas series.iloc[i] + numpy 数组元素访问约 50ns,pandas iloc 约 5μs,提升 ~100x + """ + arr = series.values.astype(np.float64) + n = len(arr) + result = np.empty(n, dtype=np.float64) + result[0] = arr[0] + w = np.float64(weight) + carry = np.float64(period - weight) + inv_p = np.float64(1.0 / period) + for i in range(1, n): + result[i] = (w * arr[i] + carry * result[i - 1]) * inv_p + return pd.Series(result, index=series.index) + + +def calc_macd(close, fast=12, slow=26, signal=9): + """ + 计算MACD指标 + 返回: DIF, DEA, MACD柱 + """ + ema_fast = calc_ema(close, fast) + ema_slow = calc_ema(close, slow) + dif = ema_fast - ema_slow + dea = calc_ema(dif, signal) + macd_hist = 2 * (dif - dea) + return dif, dea, macd_hist + + +def calc_kdj(high, low, close, n=9, m1=3, m2=3): + """ + 计算KDJ指标 + 返回: K, D, J + """ + lowest_low = low.rolling(window=n, min_periods=1).min() + highest_high = high.rolling(window=n, min_periods=1).max() + + rsv = pd.Series(np.where( + highest_high == lowest_low, 50, + (close - lowest_low) / (highest_high - lowest_low) * 100 + ), index=close.index, dtype=float) + + k = calc_sma(rsv, m1, 1) + d = calc_sma(k, m2, 1) + j = 3 * k - 2 * d + return k, d, j + + +def calc_skdj(high, low, close, n=9, m=3): + """ + 计算SKDJ(慢速随机指标) + 对RSV先做一次SMA得到K_fast,再对K_fast做两次SMA得到SKDJ的K和D + 返回: K, D + """ + lowest_low = low.rolling(window=n, min_periods=1).min() + highest_high = high.rolling(window=n, min_periods=1).max() + + rsv = pd.Series(np.where( + highest_high == lowest_low, 50, + (close - lowest_low) / (highest_high - lowest_low) * 100 + ), index=close.index, dtype=float) + + k_fast = calc_sma(rsv, m, 1) + k = calc_sma(k_fast, m, 1) + d = calc_sma(k, m, 1) + return k, d + + +def calc_all_indicators(df): + """ + 计算所有技术指标并添加到DataFrame(优化版) + df 需要包含: close, high, low, open, volume 列 + 返回: 添加了指标列的DataFrame + + 优化:智能跳过已是 float64 的列,避免重复 astype + """ + close = df['close'] + high = df['high'] + low = df['low'] + + # 智能类型转换:仅在需要时转换 + if not np.issubdtype(close.dtype, np.floating): + close = close.astype(np.float64) + high = high.astype(np.float64) + low = low.astype(np.float64) + + df['ema3'] = calc_ema(close, 3) + df['ema21'] = calc_ema(close, 21) + + dif, dea, macd_hist = calc_macd(close) + df['dif'] = dif + df['dea'] = dea + df['macd'] = macd_hist + + k, d, j = calc_kdj(high, low, close) + df['kdj_k'] = k + df['kdj_d'] = d + df['kdj_j'] = j + + sk, sd = calc_skdj(high, low, close) + df['skdj_k'] = sk + df['skdj_d'] = sd + + df['ma5'] = close.rolling(5).mean() + df['ma10'] = close.rolling(10).mean() + df['ma20'] = close.rolling(20).mean() + df['ma60'] = close.rolling(60).mean() + + return df diff --git a/stock-html/setup_cron_scan.sh b/stock-html/setup_cron_scan.sh new file mode 100755 index 0000000..a1a39d7 --- /dev/null +++ b/stock-html/setup_cron_scan.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# 在服务器上配置全景扫描定时任务 +# 每个交易日执行两次:11:35(午休)和 16:00(收盘后) +# 用法:在 stock-html 目录下执行 ./setup_cron_scan.sh + +set -e +SERVER="${STOCK_SERVER:-root@8.146.207.22}" +APP_DIR="${STOCK_APP_DIR:-/opt/stock-app}" + +echo "目标服务器: $SERVER" +echo "应用目录: $APP_DIR" +echo "定时规则: 每周一至周五 11:50(午休)+ 16:30(收盘后)执行全景扫描" +echo "" + +# 1. 确保 auto_scan.sh 已同步到服务器 +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +if [ -f "$SCRIPT_DIR/auto_scan.sh" ]; then + echo "[1/3] 同步 auto_scan.sh 到服务器..." + rsync -avz "$SCRIPT_DIR/auto_scan.sh" "$SERVER:${APP_DIR}/" +else + echo "[1/3] 未找到 auto_scan.sh,跳过同步(请确认服务器上已有该文件)" +fi + +# 2. 在服务器上设为可执行并添加 crontab(两条定时任务) +echo "[2/3] 设置可执行并添加定时任务..." +ssh "$SERVER" "chmod +x ${APP_DIR}/auto_scan.sh && (crontab -l 2>/dev/null | grep -v auto_scan.sh || true; echo '50 11 * * 1-5 ${APP_DIR}/auto_scan.sh >> ${APP_DIR}/auto_scan.log 2>&1'; echo '30 16 * * 1-5 ${APP_DIR}/auto_scan.sh >> ${APP_DIR}/auto_scan.log 2>&1') | crontab -" + +echo "[3/3] 当前服务器 crontab:" +ssh "$SERVER" "crontab -l" + +# 3.5. 同步 5分钟K线采集脚本 +if [ -f "$SCRIPT_DIR/auto_sync_kline_5min.sh" ]; then + echo "[3.5/4] 同步 auto_sync_kline_5min.sh 到服务器..." + rsync -avz "$SCRIPT_DIR/auto_sync_kline_5min.sh" "$SCRIPT_DIR/sync_kline_5min.py" "$SERVER:${APP_DIR}/" + ssh "$SERVER" "chmod +x ${APP_DIR}/auto_sync_kline_5min.sh" +fi + +# 4. 添加 5分钟K线每日采集定时任务 +echo "[4/4] 添加5分钟K线采集定时任务..." +ssh "$SERVER" "(crontab -l 2>/dev/null | grep -v auto_sync_kline_5min || true; echo ''; echo '# 5分钟K线数据每日采集(收盘后,约50分钟完成)'; echo '30 17 * * 1-5 ${APP_DIR}/auto_sync_kline_5min.sh >> ${APP_DIR}/sync_kline_5min.log 2>&1') | crontab -" + +echo "" +echo "配置完成。每个交易日将自动执行:" +echo " - 11:50 午休扫描(上午收盘数据,供下午参考)" +echo " - 16:30 收盘扫描(全天完整数据)" +echo " - 17:30 5分钟K线采集(全市场约50分钟)" +echo "" +echo "日志:" +echo " ${APP_DIR}/auto_scan.log" +echo " ${APP_DIR}/sync_kline_5min.log" +echo "手动测试: ssh $SERVER \"${APP_DIR}/auto_scan.sh\"" diff --git a/stock-html/start.sh b/stock-html/start.sh new file mode 100755 index 0000000..40e354f --- /dev/null +++ b/stock-html/start.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +# 激活虚拟环境 +source venv/bin/activate + +# 启动Flask服务 +python app.py diff --git a/stock-html/static/css/auth.css b/stock-html/static/css/auth.css new file mode 100644 index 0000000..2a61f9f --- /dev/null +++ b/stock-html/static/css/auth.css @@ -0,0 +1,266 @@ +/* ═══════════════════════════════════════ + auth.css - Login and registration styles + Lines: 261 + ═══════════════════════════════════════ */ + +/* ========== 登录相关样式 ========== */ + +.title-bar { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 24px; +} + +.title-bar h1 { + margin-bottom: 0; +} + +.user-info { + display: flex; + align-items: center; + gap: 10px; +} + +.username { + color: var(--text-secondary); + font-size: 13px; +} + +.login-btn, .logout-btn { + padding: 6px 14px; + border-radius: 8px; + font-size: 13px; + cursor: pointer; + border: none; + transition: all 0.2s; +} + +.login-btn { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.95), rgba(240, 240, 240, 0.9)); + color: var(--bg-dark); +} + +.logout-btn { + background: rgba(255, 68, 68, 0.2); + color: var(--danger); +} + +.login-btn:hover { + transform: scale(1.02); +} + +.logout-btn:hover { + background: rgba(255, 68, 68, 0.3); +} + +/* 登录弹窗 */ +.login-modal { + background: var(--bg-dark); + border: none; + border-radius: 16px; + width: 90%; + max-width: 360px; + overflow: hidden; +} + +.login-head.title-bar { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: 16px; + border-bottom: 1px solid var(--border-glass); +} + +.title-left { + display: flex; + flex-direction: column; +} + +.current-model { + font-size: 12px; + color: var(--accent); + margin-top: 4px; +} + +.login-header h3 { + font-size: 16px; + font-weight: 600; +} + +.login-body { + padding: 20px; +} + +.login-field { + margin-bottom: 16px; +} + +.login-field label { + display: block; + font-size: 13px; + color: var(--text-secondary); + margin-bottom: 6px; +} + +.login-field input { + width: 100%; + padding: 12px 14px; + background: var(--bg-glass); + border: 1px solid var(--border-glass); + border-radius: 10px; + color: var(--text-primary); + font-size: 14px; + outline: none; + transition: border-color 0.2s; +} + +.login-field input:focus { + border-color: var(--accent); +} + +.login-error { + color: #ff4444; + font-size: 13px; + margin-bottom: 12px; + text-align: center; +} + +.login-success { + color: #00ff88; + font-size: 13px; + margin-bottom: 12px; + text-align: center; +} + +.login-submit { + width: 100%; + padding: 14px; + background: linear-gradient(135deg, rgba(255, 255, 255, 0.95), rgba(240, 240, 240, 0.9)); + color: var(--bg-dark); + border: none; + border-radius: 10px; + font-size: 15px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.login-submit:hover { + transform: scale(1.01); +} + +.login-submit:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.login-switch { + text-align: center; + margin-top: 16px; + font-size: 13px; + color: var(--text-secondary); +} + +.login-switch a { + color: var(--accent); + cursor: pointer; + text-decoration: underline; +} + + +/* 登录选项卡 */ +.login-tabs { + display: flex; + border-bottom: 1px solid var(--border-glass); + position: relative; +} + +.login-tab { + flex: 1; + padding: 16px; + background: transparent; + border: none; + color: var(--text-secondary); + font-size: 15px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.login-tab.active { + color: var(--text-primary); + border-bottom: 2px solid var(--accent); +} + +.login-tab:hover { + color: var(--text-primary); +} + +.modal-close-btn { + position: absolute; + right: 12px; + top: 50%; + transform: translateY(-50%); + background: transparent; + border: none; + color: var(--text-muted); + font-size: 24px; + cursor: pointer; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; +} + +.modal-close-btn:hover { + color: var(--text-primary); +} + + +/* 登录页面(未登录状态) */ +.login-page { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 80vh; + padding: 20px; +} + +.login-page-header { + text-align: center; + margin-bottom: 30px; +} + +.login-icon { + width: 64px; + height: 64px; + margin-bottom: 16px; +} + +.login-page-header h1 { + font-size: 28px; + margin-bottom: 10px; +} + +.login-page-header p { + color: var(--text-secondary); + font-size: 14px; +} + +.login-modal-inline { + width: 100%; + max-width: 360px; +} + +.loading-auth { + display: flex; + align-items: center; + justify-content: center; + min-height: 80vh; + color: var(--text-secondary); +} + + diff --git a/stock-html/static/css/base.css b/stock-html/static/css/base.css new file mode 100644 index 0000000..5df57b1 --- /dev/null +++ b/stock-html/static/css/base.css @@ -0,0 +1,709 @@ +/* ═══════════════════════════════════════ + base.css - Root variables, body, navigation, layout + Lines: 700 + ═══════════════════════════════════════ */ + + :root { + --bg-dark: #0a0a0a; + --bg-glass: rgba(255, 255, 255, 0.05); + --bg-glass-hover: rgba(255, 255, 255, 0.08); + --border-glass: rgba(255, 255, 255, 0.1); + --text-primary: #ffffff; + --text-secondary: rgba(255, 255, 255, 0.7); + --text-muted: rgba(255, 255, 255, 0.4); + --accent: #ffffff; + --success: #00ff88; + --danger: #ff4444; + --warning: #ffaa00; + } + + [v-cloak] { + display: none !important; + } + + * { + margin: 0; + padding: 0; + box-sizing: border-box; + } + + body { + font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'PingFang SC', sans-serif; + background: var(--bg-dark); + min-height: 100vh; + color: var(--text-primary); + padding: 0; + padding-bottom: env(safe-area-inset-bottom); + } + + .container { + max-width: 500px; + margin: 0 auto; + padding: 20px 16px; + padding-top: max(20px, env(safe-area-inset-top)); + } + + h1 { + color: var(--text-primary); + margin-bottom: 24px; + text-align: center; + font-size: 20px; + font-weight: 600; + letter-spacing: -0.5px; + } + + .main-title-date { + color: var(--text-muted); + font-size: 14px; + font-weight: 400; + margin-left: 8px; + } + + .market-status { + font-size: 11px; + font-weight: 500; + padding: 2px 8px; + border-radius: 10px; + margin-left: 8px; + } + + .market-status.open { + color: var(--success); + background: rgba(0, 255, 136, 0.15); + } + + .market-status.closed { + color: var(--text-muted); + background: rgba(255, 255, 255, 0.08); + } + + /* 玻璃卡片基础样式 */ + .glass { + background: var(--bg-glass); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: none; + border-radius: 16px; + } + + /* 导航标签 */ + .nav-tabs { + display: flex; + gap: 6px; + margin-bottom: 20px; + padding: 4px; + background: rgba(255, 255, 255, 0.04); + backdrop-filter: blur(20px); + border-radius: 14px; + border: 1px solid rgba(255, 255, 255, 0.08); + } + + .nav-tab { + flex: 1; + padding: 14px 20px; + background: transparent; + border: none; + border-radius: 10px; + color: var(--text-secondary); + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + overflow: hidden; + } + + /* 紧凑导航样式 */ + .nav-tabs-compact { + margin-bottom: 12px; + } + + .nav-tabs-compact .nav-tab { + padding: 10px 12px; + font-size: 13px; + } + + .nav-tab:hover { + background: rgba(255, 255, 255, 0.06); + color: var(--text-primary); + } + + .nav-tab.active { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.95), rgba(240, 240, 240, 0.9)); + color: var(--bg-dark); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + } + + .nav-tab.active::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent); + } + + /* 子导航标签 */ + .sub-tabs { + display: flex; + gap: 4px; + margin-bottom: 16px; + padding: 3px; + background: rgba(255, 255, 255, 0.03); + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.06); + } + + .sub-tab { + flex: 1; + padding: 10px 14px; + background: transparent; + border: none; + border-radius: 8px; + color: var(--text-secondary); + cursor: pointer; + font-size: 13px; + font-weight: 500; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + } + + .sub-tab:hover { + background: rgba(255, 255, 255, 0.05); + color: var(--text-primary); + } + + .sub-tab.active { + background: rgba(255, 255, 255, 0.1); + color: var(--text-primary); + } + + .sub-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + background: rgba(255, 255, 255, 0.15); + border-radius: 9px; + font-size: 11px; + font-weight: 600; + } + + .sub-tab.active .sub-badge { + background: rgba(255, 255, 255, 0.25); + } + + .add-watch-btn { + width: 36px; + height: 36px; + margin-left: auto; + background: rgba(255, 255, 255, 0.08); + border: 1px dashed rgba(255, 255, 255, 0.2); + border-radius: 8px; + color: var(--text-secondary); + font-size: 18px; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + } + + .add-watch-btn:hover { + background: rgba(255, 255, 255, 0.15); + color: var(--text-primary); + border-color: rgba(255, 255, 255, 0.3); + } + + .add-watch-input { + display: flex; + gap: 8px; + margin-bottom: 12px; + padding: 12px; + background: var(--bg-glass); + border: none; + border-radius: 10px; + } + + .add-watch-input input { + flex: 1; + padding: 8px 12px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + color: var(--text-primary); + font-size: 14px; + } + + .add-watch-input input::placeholder { + color: var(--text-secondary); + } + + .add-watch-input button { + padding: 8px 16px; + background: rgba(255, 255, 255, 0.1); + border: none; + border-radius: 6px; + color: var(--text-primary); + font-size: 13px; + cursor: pointer; + transition: all 0.2s ease; + } + + .add-watch-input button:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.2); + } + + .add-watch-input button:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + /* 输入区域 */ + .input-section { + background: var(--bg-glass); + backdrop-filter: blur(20px); + border: none; + padding: 20px; + border-radius: 16px; + margin-bottom: 16px; + } + + .input-group { + margin-bottom: 16px; + } + + .input-group:last-child { + margin-bottom: 0; + } + + .input-group label { + display: block; + color: var(--text-muted); + margin-bottom: 8px; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .input-group input { + width: 100%; + padding: 14px 16px; + border: none; + border-radius: 10px; + background: rgba(255, 255, 255, 0.03); + font-size: 16px; + color: var(--text-primary); + transition: all 0.2s; + } + + .input-group input:focus { + outline: none; + border-color: var(--text-primary); + background: rgba(255, 255, 255, 0.05); + } + + .input-group input::placeholder { + color: var(--text-muted); + } + + .analyze-btn { + width: 100%; + height: 44px; + padding: 0 16px; + background: var(--text-primary); + color: var(--bg-dark); + border: none; + border-radius: 10px; + cursor: pointer; + font-size: 14px; + font-weight: 600; + transition: all 0.2s; + margin-top: 8px; + } + + .analyze-btn:active { + transform: scale(0.98); + opacity: 0.9; + } + + .analyze-btn:disabled { + background: var(--text-muted); + cursor: not-allowed; + } + + /* 扫描控制区 */ + .scan-control { + margin-bottom: 12px; + display: flex; + gap: 10px; + } + + .scan-source-select { + flex: 0 0 150px; + padding: 14px 16px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 12px; + color: var(--text-primary); + font-size: 14px; + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%23888' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10l-5 5z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + padding-right: 36px; + } + + .scan-source-select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(0, 255, 136, 0.1); + } + + .scan-source-select option { + background: #1a1a2e; + color: var(--text-primary); + padding: 10px; + } + + .scan-btn { + flex: 1; + padding: 14px 28px; + background: rgba(255, 255, 255, 0.1); + color: var(--text-primary); + border: none; + border-radius: 10px; + cursor: pointer; + font-size: 15px; + font-weight: 600; + transition: all 0.3s ease; + } + + .scan-btn:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.15); + } + + .scan-btn:active:not(:disabled) { + transform: scale(0.98); + } + + .scan-btn:disabled { + background: rgba(255, 255, 255, 0.05); + color: var(--text-muted); + cursor: not-allowed; + } + + .scan-status-bar { + text-align: center; + color: var(--text-secondary); + font-size: 13px; + padding: 10px; + margin-bottom: 12px; + background: var(--bg-glass); + border-radius: 8px; + animation: pulse 1.5s infinite; + } + + @keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } + } + + /* 扫描结果 */ + .scan-results { + background: var(--bg-glass); + backdrop-filter: blur(20px); + border: none; + border-radius: 12px; + padding: 12px; + margin-bottom: 16px; + overflow: hidden; + } + + .scan-item { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + overflow: hidden; + } + + .scan-item:last-child { + border-bottom: none; + } + + .scan-item .rank { + width: 22px; + height: 22px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.1); + border-radius: 6px; + font-size: 11px; + font-weight: 600; + color: var(--text-secondary); + flex-shrink: 0; + } + + .scan-item:nth-child(-n+3) .rank { + background: rgba(0, 255, 136, 0.2); + color: var(--success); + } + + .scan-stock-info { + display: flex; + gap: 8px; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + transition: background-color 0.2s; + } + + .scan-stock-info:hover { + background: rgba(0, 255, 136, 0.15); + } + + .scan-code { + font-weight: 600; + color: var(--text-primary); + font-size: 13px; + min-width: 55px; + flex-shrink: 0; + } + + .scan-name { + color: var(--text-secondary); + font-size: 12px; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .scan-price { + color: var(--text-primary); + font-size: 13px; + flex-shrink: 0; + } + + .scan-rate { + padding: 3px 6px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + min-width: 36px; + text-align: center; + flex-shrink: 0; + } + + .scan-rate.buy { + background: rgba(0, 255, 136, 0.15); + color: var(--success); + } + + .scan-add-btn { + width: 24px; + height: 24px; + padding: 0; + background: transparent; + border: none; + color: var(--text-secondary); + font-size: 16px; + font-weight: 300; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + } + + .scan-add-btn:hover { + color: var(--success); + transform: scale(1.2); + } + + .scan-add-btn:active { + transform: scale(0.95); + } + + .scan-add-btn:disabled { + color: var(--text-muted); + cursor: default; + transform: none; + } + + /* 历史记录下拉 */ + .history-dropdown { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + background: #1a1a1a; + border: none; + border-radius: 12px; + z-index: 100; + max-height: 200px; + overflow-y: auto; + } + + .history-item { + padding: 14px 16px; + cursor: pointer; + display: flex; + align-items: center; + gap: 12px; + border-bottom: 1px solid var(--border-glass); + transition: background 0.2s; + } + + .history-item:last-child { + border-bottom: none; + } + + .history-item:active { + background: var(--bg-glass-hover); + } + + .history-item .code { + font-weight: 600; + color: var(--text-primary); + font-size: 14px; + } + + .history-item .name { + flex: 1; + color: var(--text-secondary); + font-size: 13px; + } + + .history-item .remove { + color: var(--text-muted); + font-size: 18px; + padding: 4px 8px; + } + + /* 股票信息条 */ + .stock-info-bar { + background: var(--bg-glass); + backdrop-filter: blur(20px); + padding: 16px; + border-radius: 12px; + margin-bottom: 16px; + display: flex; + align-items: center; + gap: 12px; + border: none; + } + + .stock-info-bar .stock-code { + background: var(--text-primary); + color: var(--bg-dark); + padding: 6px 12px; + border-radius: 8px; + font-weight: 600; + font-size: 14px; + } + + .stock-info-bar .stock-name { + color: var(--text-primary); + font-size: 16px; + font-weight: 500; + } + + .loading { + text-align: center; + padding: 60px 20px; + color: var(--text-secondary); + font-size: 14px; + } + + .error { + background: rgba(255, 68, 68, 0.1); + border: 1px solid rgba(255, 68, 68, 0.2); + color: var(--danger); + padding: 16px; + border-radius: 12px; + margin-bottom: 16px; + font-size: 14px; + } + + /* 信号卡片 */ + .recommendation-card { + background: var(--bg-glass); + backdrop-filter: blur(20px); + border: none; + border-radius: 20px; + padding: 24px; + margin-bottom: 16px; + } + + .recommendation-card.buy { + border-color: rgba(0, 255, 136, 0.3); + background: rgba(0, 255, 136, 0.05); + } + + .recommendation-card.sell { + border-color: rgba(255, 68, 68, 0.3); + background: rgba(255, 68, 68, 0.05); + } + + .signal-icon { + font-size: 40px; + margin-bottom: 12px; + } + + .signal-text { + font-size: 22px; + font-weight: 700; + margin-bottom: 8px; + letter-spacing: -0.5px; + } + + .recommendation-card.buy .signal-text { color: var(--success); } + .recommendation-card.sell .signal-text { color: var(--danger); } + + .signal-desc { + font-size: 14px; + color: var(--text-secondary); + line-height: 1.6; + } + + .signal-details { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; + margin-top: 20px; + } + + .signal-detail-item { + background: rgba(255, 255, 255, 0.03); + padding: 14px; + border-radius: 12px; + border: none; + } + + .signal-detail-item h4 { + font-size: 11px; + color: var(--text-muted); + margin-bottom: 6px; + text-transform: uppercase; + letter-spacing: 0.5px; + font-weight: 500; + } + + .signal-detail-item .value { + font-size: 18px; + font-weight: 600; + } + diff --git a/stock-html/static/css/components.css b/stock-html/static/css/components.css new file mode 100644 index 0000000..1ba4da7 --- /dev/null +++ b/stock-html/static/css/components.css @@ -0,0 +1,2304 @@ +/* ═══════════════════════════════════════ + components.css - Cards, buttons, modals, dialogs, toast, alerts + Lines: 2164 + ═══════════════════════════════════════ */ + + /* 卡片样式 */ + .card { + background: var(--bg-glass); + backdrop-filter: blur(20px); + border: none; + border-radius: 16px; + padding: 20px; + margin-bottom: 16px; + } + + .card-title { + font-size: 14px; + color: var(--text-secondary); + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border-glass); + font-weight: 500; + } + + /* 图表容器 */ + .chart-container { + height: 200px; + position: relative; + } + + /* 数据表格 */ + .data-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; + } + + .data-table th { + background: rgba(255, 255, 255, 0.05); + color: var(--text-muted); + padding: 12px 8px; + text-align: left; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.3px; + } + + .data-table td { + padding: 12px 8px; + border-bottom: 1px solid var(--border-glass); + color: var(--text-secondary); + } + + .positive { color: var(--danger); font-weight: 600; } + .negative { color: var(--success); font-weight: 600; } + + /* 汇总区域 */ + .summary-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; + } + + .summary-item { + background: rgba(255, 255, 255, 0.03); + padding: 16px; + border-radius: 12px; + border: none; + } + + .summary-item h4 { + color: var(--text-muted); + font-size: 11px; + margin-bottom: 8px; + text-transform: uppercase; + letter-spacing: 0.5px; + font-weight: 500; + } + + .summary-item .value { + font-size: 20px; + font-weight: 600; + color: var(--text-primary); + } + + .summary-item .sub { + font-size: 11px; + color: var(--text-muted); + margin-top: 4px; + } + + /* 折叠面板 */ + .collapsible-section { + margin-bottom: 12px; + } + + .collapsible-header { + background: var(--bg-glass); + backdrop-filter: blur(20px); + border: none; + border-radius: 12px; + padding: 16px 20px; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + } + + .collapsible-header h3 { + margin: 0; + font-size: 14px; + color: var(--text-primary); + font-weight: 500; + } + + .collapsible-header .toggle-icon { + font-size: 14px; + color: var(--text-muted); + transition: transform 0.3s; + } + + .collapsible-header .toggle-icon.expanded { + transform: rotate(180deg); + } + + .collapsible-content { + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease-out; + } + + .collapsible-content.expanded { + max-height: 2000px; + } + + .collapsible-content .card { + margin-top: 8px; + border-radius: 12px; + } + + /* 交易统计 */ + .trade-stats-card { + background: var(--bg-glass); + backdrop-filter: blur(20px); + border: none; + border-radius: 12px; + padding: 14px; + margin-bottom: 12px; + } + + .trade-stats-card h4 { + color: var(--text-secondary); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 12px; + font-weight: 500; + } + + .today-date { + color: var(--text-muted); + font-size: 11px; + margin-left: 8px; + } + + .stats-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; + } + + .stats-detail { + display: flex; + justify-content: center; + gap: 20px; + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid rgba(255,255,255,0.1); + font-size: 12px; + } + + .stats-detail .profit { + color: var(--success); + } + + .stats-detail .loss { + color: var(--danger); + } + + .stat-item { + text-align: center; + padding: 10px 6px; + background: rgba(255, 255, 255, 0.03); + border-radius: 8px; + } + + .stat-value { + font-size: clamp(12px, 4vw, 18px); + font-weight: 700; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--text-primary); + } + + .stat-label { + color: var(--text-muted); + font-size: 9px; + margin-top: 2px; + text-transform: uppercase; + letter-spacing: 0.3px; + } + + .stat-item.profit .stat-value { color: var(--success); } + .stat-item.loss .stat-value { color: var(--danger); } + + /* 可用资金和总资产 */ + .available-cash-row, .total-assets-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 0; + border-top: 1px solid rgba(255,255,255,0.1); + margin-top: 8px; + } + .total-assets-row { + margin-top: 0; + } + .cash-label, .assets-label { + color: var(--text-secondary); + font-size: 12px; + } + .cash-value, .assets-value { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + } + .assets-value { + color: var(--primary); + } + .cash-edit-btn { + background: none; + border: none; + cursor: pointer; + font-size: 12px; + padding: 2px 6px; + opacity: 0.6; + transition: opacity 0.2s; + } + .cash-edit-btn:hover { + opacity: 1; + } + .cash-input { + width: 100px; + padding: 4px 8px; + border: 1px solid var(--primary); + border-radius: 4px; + background: var(--bg-secondary); + color: var(--text-primary); + font-size: 13px; + text-align: right; + } + .cash-input:focus { + outline: none; + border-color: var(--primary); + } + .cash-save-btn, .cash-cancel-btn { + background: none; + border: none; + cursor: pointer; + font-size: 14px; + padding: 2px 6px; + margin-left: 4px; + } + .cash-save-btn { + color: var(--success); + } + .cash-cancel-btn { + color: var(--danger); + } + + /* 模型建议 */ + .model-suggestions { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--border-glass); + } + + .model-suggestions h4 { + color: var(--text-secondary); + font-size: 12px; + margin-bottom: 12px; + font-weight: 500; + } + + .suggestion-item { + padding: 12px 14px; + border-radius: 10px; + margin-bottom: 8px; + font-size: 13px; + line-height: 1.5; + } + + .suggestion-item.warning { + background: rgba(255, 170, 0, 0.1); + color: var(--warning); + border: 1px solid rgba(255, 170, 0, 0.2); + } + + .suggestion-item.success { + background: rgba(0, 255, 136, 0.1); + color: var(--success); + border: 1px solid rgba(0, 255, 136, 0.2); + } + + .suggestion-item.danger { + background: rgba(255, 68, 68, 0.1); + color: var(--danger); + border: 1px solid rgba(255, 68, 68, 0.2); + } + + /* 止损预警样式 */ + .stoploss-alerts-card { + background: linear-gradient(145deg, rgba(255, 68, 68, 0.15), rgba(255, 68, 68, 0.05)); + backdrop-filter: blur(20px); + border-radius: 12px; + padding: 14px; + margin-bottom: 12px; + border: 1px solid rgba(255, 68, 68, 0.3); + } + + .stoploss-alerts-card.stoploss-collapsible .stoploss-alerts-header { + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; + padding: 2px 0; + margin: -2px 0 0 0; + user-select: none; + } + + .stoploss-alerts-card.stoploss-collapsible .stoploss-alerts-header:hover { + opacity: 0.9; + } + + .stoploss-alerts-card.stoploss-collapsible .stoploss-alerts-header h4 { + margin: 0; + } + + .stoploss-alerts-card.stoploss-collapsible .stoploss-alerts-header .toggle-icon { + font-size: 10px; + color: var(--danger); + transition: transform 0.2s; + } + + .stoploss-alerts-card.stoploss-collapsible .stoploss-alerts-header .toggle-icon.expanded { + transform: rotate(180deg); + } + + .stoploss-badge { + display: inline-block; + font-size: 11px; + padding: 2px 8px; + border-radius: 10px; + background: rgba(255, 68, 68, 0.3); + margin-left: 6px; + } + + .stoploss-alerts-body { + margin-top: 12px; + } + + .stoploss-alerts-card h4 { + color: var(--danger); + font-size: 13px; + margin-bottom: 12px; + font-weight: 600; + } + + .stoploss-alert-item { + background: rgba(0, 0, 0, 0.2); + border-radius: 8px; + padding: 12px; + margin-bottom: 8px; + } + + .stoploss-alert-item:last-child { + margin-bottom: 0; + } + + .stoploss-alert-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + } + + .stoploss-alert-header .alert-stock { + color: var(--text-primary); + font-weight: 600; + font-size: 14px; + } + + .stoploss-alert-header .alert-loss { + color: var(--danger); + font-weight: 700; + font-size: 16px; + } + + .stoploss-alert-detail { + display: flex; + gap: 12px; + font-size: 12px; + color: var(--text-secondary); + margin-bottom: 8px; + } + + .stoploss-alert-message { + color: var(--danger); + font-size: 12px; + margin-bottom: 10px; + padding: 6px 10px; + background: rgba(255, 68, 68, 0.1); + border-radius: 6px; + } + + .stoploss-sell-btn { + width: 100%; + height: 36px; + background: var(--danger); + color: white; + border: none; + border-radius: 8px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + } + + .stoploss-sell-btn:hover { + background: #ff6666; + } + + /* 基本面弹窗样式 */ + .fundamental-modal { + max-width: 380px; + width: 90%; + } + + .fundamental-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border-glass); + } + + .fundamental-header h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + } + + .fundamental-code { + color: var(--text-muted); + font-size: 12px; + } + + .fundamental-actions { + display: flex; + gap: 8px; + margin-bottom: 16px; + } + + .watch-btn { + flex: 1; + padding: 10px 16px; + font-size: 13px; + border: none; + border-radius: 6px; + cursor: pointer; + font-weight: 600; + transition: all 0.2s; + } + + .fundamental-actions .ai-analyze-btn { + flex: 1; + padding: 10px 16px; + font-size: 13px; + border: none; + border-radius: 6px; + background: rgba(255, 255, 255, 0.15); + color: var(--text-primary); + cursor: pointer; + } + + .watch-btn.add { + background: var(--success); + color: #000; + } + + .watch-btn.add:hover { + background: #00cc6a; + } + + .watch-btn.remove { + background: rgba(255,255,255,0.1); + color: var(--text-muted); + } + + .watch-btn.remove:hover { + background: rgba(255,82,82,0.2); + color: var(--danger); + } + + /* 已关注标识 */ + .watched-badge { + color: var(--warning); + font-size: 12px; + } + + .scan-item.watched { + background: rgba(255,170,0,0.08); + } + + .modal-close { + margin-left: auto; + background: none; + border: none; + color: var(--text-muted); + font-size: 24px; + cursor: pointer; + padding: 0 8px; + } + + .modal-close:hover { + color: var(--text-primary); + } + + .fundamental-loading { + text-align: center; + padding: 40px; + color: var(--text-muted); + } + + .fundamental-error { + text-align: center; + padding: 40px; + color: var(--danger); + } + + .fundamental-section { + margin-bottom: 16px; + } + + .fundamental-section h4 { + font-size: 12px; + color: var(--text-secondary); + margin: 0 0 10px 0; + font-weight: 500; + } + + .signal-section h4 { + display: flex; + align-items: center; + gap: 8px; + } + .signal-count { + font-size: 11px; + color: var(--primary); + background: rgba(59,130,246,0.1); + padding: 2px 8px; + border-radius: 10px; + font-weight: 600; + } + .signal-grid { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 8px; + } + .signal-chip { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + border-radius: 14px; + font-size: 12px; + font-weight: 500; + } + .signal-chip.triggered { + background: rgba(239,68,68,0.12); + color: #ef4444; + border: 1px solid rgba(239,68,68,0.25); + } + .signal-chip.inactive { + background: var(--bg-secondary); + color: var(--text-muted); + border: 1px solid transparent; + } + .signal-dot { + width: 6px; + height: 6px; + border-radius: 50%; + display: inline-block; + } + .signal-dot.on { background: #ef4444; } + .signal-dot.off { background: var(--text-muted); opacity: 0.4; } + .signal-summary { + font-size: 12px; + padding: 6px 10px; + border-radius: 6px; + background: var(--bg-secondary); + } + .signal-strong { color: #ef4444; font-weight: 600; } + .signal-normal { color: var(--text-secondary); } + .signal-none { color: var(--text-muted); } + + .fundamental-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; + } + + .fundamental-item { + background: rgba(255, 255, 255, 0.03); + border-radius: 8px; + padding: 10px 8px; + text-align: center; + } + + .fundamental-item .label { + display: block; + font-size: 10px; + color: var(--text-muted); + margin-bottom: 4px; + } + + .fundamental-item .value { + display: block; + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .alert-card { + cursor: pointer; + } + + .alert-card:active { + transform: scale(0.98); + } + + /* 资金流向表格样式 */ + .fundflow-table { + font-size: 12px; + } + + .fundflow-header { + display: grid; + grid-template-columns: 1fr 1fr 1fr 1fr; + gap: 8px; + padding: 8px; + background: rgba(255, 255, 255, 0.05); + border-radius: 6px 6px 0 0; + color: var(--text-muted); + } + + .fundflow-row { + display: grid; + grid-template-columns: 1fr 1fr 1fr 1fr; + gap: 8px; + padding: 8px; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + } + + .fundflow-row:last-child { + border-bottom: none; + } + + .ff-col { + text-align: center; + font-weight: 500; + } + + .ff-col.date { + color: var(--text-secondary); + } + + .ff-col.up { + color: var(--success); + } + + .ff-col.down { + color: var(--danger); + } + + /* K线图样式 */ + .kline-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + } + + .kline-header h4 { + margin: 0; + } + + .kline-period-tabs { + display: flex; + gap: 4px; + } + + .kline-tab { + padding: 6px 12px; + background: rgba(255, 255, 255, 0.05); + border: none; + border-radius: 6px; + color: var(--text-secondary); + font-size: 12px; + cursor: pointer; + transition: all 0.2s ease; + } + + .kline-tab:hover { + background: rgba(255, 255, 255, 0.1); + color: var(--text-primary); + } + + .kline-tab.active { + background: rgba(255, 255, 255, 0.15); + color: var(--text-primary); + } + + .kline-chart-container { + height: 180px; + position: relative; + background: rgba(255, 255, 255, 0.02); + border-radius: 8px; + padding: 8px; + } + + .kline-chart-container.loading { + opacity: 0.5; + } + + .kline-loading { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: var(--text-muted); + font-size: 12px; + } + + /* 通用按钮样式 */ + .btn { + width: 100%; + height: 44px; + padding: 0 16px; + border: none; + border-radius: 10px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + } + + .btn:active { + transform: scale(0.98); + opacity: 0.9; + } + + .btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .btn-primary { + background: var(--text-primary); + color: var(--bg-dark); + } + + .btn-success { + background: var(--success); + color: var(--bg-dark); + } + + .btn-danger { + background: var(--danger); + color: white; + } + + .btn-outline { + background: var(--bg-glass); + color: var(--text-primary); + border: none; + } + + /* 交易操作按钮栏 */ + .trade-actions-bar { + display: flex; + gap: 10px; + margin-bottom: 12px; + } + + /* 持有/清仓 子Tab */ + .trade-sub-tabs { + display: flex; + gap: 8px; + margin-bottom: 12px; + padding: 4px; + background: rgba(255, 255, 255, 0.04); + border-radius: 10px; + } + + .trade-sub-tab { + flex: 1; + padding: 10px 16px; + background: transparent; + border: none; + border-radius: 8px; + color: var(--text-secondary); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + } + + .trade-sub-tab:hover { + background: rgba(255, 255, 255, 0.06); + color: var(--text-primary); + } + + .trade-sub-tab.active { + background: rgba(255, 255, 255, 0.1); + color: var(--text-primary); + } + + .trade-sub-tab .tab-count { + display: inline-block; + min-width: 18px; + padding: 2px 6px; + margin-left: 6px; + background: rgba(255, 255, 255, 0.1); + border-radius: 10px; + font-size: 11px; + } + + .trade-sub-tab.active .tab-count { + background: rgba(255, 255, 255, 0.15); + } + + .add-trade-btn { + flex: 1; + height: 44px; + padding: 0 16px; + background: rgba(255, 255, 255, 0.1); + border: none; + border-radius: 10px; + color: var(--text-primary); + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + } + + .add-trade-btn:hover { + background: rgba(255, 255, 255, 0.15); + } + + .add-trade-btn:active { + transform: scale(0.98); + opacity: 0.9; + } + + .add-trade-btn:disabled { + background: rgba(255, 255, 255, 0.05); + color: var(--text-muted); + cursor: not-allowed; + } + + .refresh-price-btn { + flex: 1; + height: 44px; + padding: 0 16px; + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 10px; + color: var(--text-secondary); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + } + + .refresh-price-btn:hover { + background: rgba(255, 255, 255, 0.05); + border-color: rgba(255, 255, 255, 0.3); + color: var(--text-primary); + } + + .refresh-price-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + /* 弹窗 */ + .modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(10px); + display: flex; + align-items: flex-start; + justify-content: center; + z-index: 1000; + padding-top: max(20px, env(safe-area-inset-top)); + overflow-y: auto; + } + + .modal-content { + background: #111; + padding: 24px; + border-radius: 20px; + width: 90%; + max-width: 450px; + margin-bottom: 20px; + border: none; + } + + .modal-content h3 { + margin-bottom: 24px; + text-align: center; + color: var(--text-primary); + font-size: 18px; + font-weight: 600; + } + + .form-row { + margin-bottom: 16px; + } + + .form-row label { + display: block; + margin-bottom: 8px; + color: var(--text-muted); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .form-row input, .form-row select, .form-row textarea { + width: 100%; + padding: 14px 16px; + border: none; + border-radius: 10px; + background: rgba(255, 255, 255, 0.03); + color: var(--text-primary); + font-size: 16px; + -webkit-appearance: none; + appearance: none; + } + + .form-row select { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='white' viewBox='0 0 24 24'%3E%3Cpath d='M7 10l5 5 5-5z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + background-size: 20px; + padding-right: 40px; + } + + .form-row textarea { + resize: vertical; + min-height: 80px; + } + + .form-actions { + display: flex; + gap: 12px; + margin-top: 20px; + } + + .form-actions button { + flex: 1; + height: 44px; + padding: 0 16px; + border: none; + border-radius: 10px; + cursor: pointer; + font-size: 14px; + font-weight: 600; + } + + .form-actions button[type="button"] { + background: var(--bg-glass); + color: var(--text-primary); + border: none; + } + + .form-actions button[type="submit"] { + background: var(--text-primary); + color: var(--bg-dark); + } + + /* 交易记录列表 */ + .trades-list { + display: flex; + flex-direction: column; + gap: 8px; + } + + .empty-trades { + text-align: center; + padding: 32px 16px; + color: var(--text-muted); + font-size: 13px; + } + .trade-item { + display: flex; + background: var(--bg-glass); + backdrop-filter: blur(20px); + border: none; + border-radius: 10px; + padding: 12px; + } + + .trade-content { + flex: 1; + } + + .trade-actions-vertical { + display: flex; + flex-direction: column; + gap: 4px; + margin-left: 12px; + align-self: flex-end; + } + + .trade-actions-vertical button { + padding: 8px 4px; + border: none; + background: transparent; + color: var(--text-secondary); + border-radius: 4px; + cursor: pointer; + font-size: 11px; + transition: all 0.2s ease; + writing-mode: vertical-rl; + text-orientation: mixed; + } + + .trade-actions-vertical button:hover { + background: rgba(255, 255, 255, 0.1); + color: var(--text-primary); + } + + .trade-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; + } + + .trade-code { + font-weight: 600; + color: var(--text-primary); + font-size: 14px; + } + + .trade-name { + color: var(--text-secondary); + font-size: 12px; + } + + .trade-type { + padding: 4px 10px; + border-radius: 6px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.3px; + } + + .trade-type.buy { + background: rgba(0, 255, 136, 0.15); + color: var(--success); + border: 1px solid rgba(0, 255, 136, 0.3); + } + + .trade-type.sell { + background: rgba(255, 68, 68, 0.15); + color: var(--danger); + border: 1px solid rgba(255, 68, 68, 0.3); + } + + .trade-result { + padding: 4px 10px; + border-radius: 6px; + font-size: 11px; + font-weight: 500; + margin-left: auto; + } + + .trade-result.profit { + background: rgba(0, 255, 136, 0.1); + color: var(--success); + } + + .trade-result.loss { + background: rgba(255, 68, 68, 0.1); + color: var(--danger); + } + + .trade-result.pending { + background: rgba(255, 170, 0, 0.1); + color: var(--warning); + } + + .trade-amount-row { + display: flex; + align-items: baseline; + gap: 12px; + margin-bottom: 6px; + } + + .trade-amount { + font-size: 16px; + font-weight: 600; + color: var(--text-secondary); + } + + .trade-profit { + font-size: 18px; + font-weight: 700; + } + + .trade-profit.profit { + color: var(--success); + } + + .trade-profit.loss { + color: var(--danger); + } + + .trade-details { + display: flex; + gap: 12px; + flex-wrap: wrap; + font-size: 11px; + color: var(--text-muted); + } + + .profit-text { color: var(--success); } + .loss-text { color: var(--danger); } + + .trade-reason, .trade-notes { + font-size: 11px; + color: var(--text-muted); + margin-top: 4px; + } + + .trade-actions { + display: flex; + gap: 6px; + margin-top: 8px; + } + + .trade-actions button { + padding: 6px 12px; + border: none; + background: transparent; + color: var(--text-secondary); + border-radius: 6px; + cursor: pointer; + font-size: 11px; + transition: all 0.2s; + } + + .trade-actions button:active { + background: var(--bg-glass-hover); + } + + /* 信号显示 */ + .signal-display { + padding: 14px 16px; + border-radius: 10px; + font-size: 13px; + background: var(--bg-glass); + color: var(--text-secondary); + border: none; + line-height: 1.5; + } + + .signal-display.buy { + background: rgba(0, 255, 136, 0.1); + color: var(--success); + border-color: rgba(0, 255, 136, 0.2); + } + + .signal-display.sell { + background: rgba(255, 68, 68, 0.1); + color: var(--danger); + border-color: rgba(255, 68, 68, 0.2); + } + + /* 今日信号区域 */ + .today-signal-section { + margin-bottom: 20px; + } + + .today-signal-section h3 { + margin-bottom: 16px; + color: var(--text-primary); + font-size: 16px; + font-weight: 600; + } + + .signal-query { + margin-bottom: 16px; + } + + .stock-select { + width: 100%; + padding: 14px 16px; + border: none; + border-radius: 12px; + background: var(--bg-glass); + font-size: 15px; + color: var(--text-primary); + -webkit-appearance: none; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='white' viewBox='0 0 24 24'%3E%3Cpath d='M7 10l5 5 5-5z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + background-size: 20px; + } + + .today-signal-card { + background: var(--bg-glass); + backdrop-filter: blur(20px); + border: none; + border-radius: 20px; + padding: 20px; + } + + .today-signal-card.buy { + border-color: rgba(0, 255, 136, 0.3); + background: linear-gradient(135deg, rgba(0, 255, 136, 0.08) 0%, var(--bg-glass) 100%); + } + + .today-signal-card.sell { + border-color: rgba(255, 68, 68, 0.3); + background: linear-gradient(135deg, rgba(255, 68, 68, 0.08) 0%, var(--bg-glass) 100%); + } + + .signal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + } + + .stock-info { + font-weight: 600; + color: var(--text-primary); + font-size: 15px; + } + + .signal-date { + color: var(--text-muted); + font-size: 12px; + } + + .signal-main { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 20px; + } + + .signal-icon { + font-size: 48px; + } + + .signal-content { + flex: 1; + } + + .signal-title { + font-size: 22px; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 6px; + letter-spacing: -0.5px; + } + + .today-signal-card.buy .signal-title { color: var(--success); } + .today-signal-card.sell .signal-title { color: var(--danger); } + + .signal-desc { + color: var(--text-secondary); + font-size: 13px; + line-height: 1.5; + } + + .signal-details { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 10px; + padding: 16px 0; + border-top: 1px solid var(--border-glass); + border-bottom: 1px solid var(--border-glass); + margin-bottom: 16px; + } + + .detail-item { + text-align: center; + padding: 12px; + background: rgba(255, 255, 255, 0.03); + border-radius: 10px; + } + + .detail-item .label { + display: block; + font-size: 10px; + color: var(--text-muted); + margin-bottom: 6px; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .detail-item .value { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + } + + .detail-item .value.positive { color: var(--danger); } + .detail-item .value.negative { color: var(--success); } + + .quick-trade-btns { + display: flex; + gap: 10px; + } + + .quick-btn { + flex: 1; + padding: 14px; + border: none; + border-radius: 12px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + } + + .quick-btn:active { + transform: scale(0.98); + opacity: 0.9; + } + + .quick-btn.buy { + background: var(--success); + color: var(--bg-dark); + } + + .quick-btn.sell { + background: var(--danger); + color: white; + } + + .quick-btn.record { + background: var(--text-primary); + color: var(--bg-dark); + } + + .no-signal-tip { + padding: 40px 20px; + text-align: center; + color: var(--text-muted); + background: var(--bg-glass); + border: none; + border-radius: 16px; + font-size: 14px; + line-height: 1.6; + } + + .section-divider { + border: none; + border-top: 1px solid var(--border-glass); + margin: 24px 0; + } + + /* 分析理论说明卡片 - 美化版 */ + .theory-card { + background: linear-gradient(135deg, rgba(255,255,255,0.08) 0%, rgba(255,255,255,0.02) 100%); + backdrop-filter: blur(24px); + border: 1px solid rgba(255,255,255,0.12); + border-radius: 20px; + padding: 24px; + margin-bottom: 20px; + position: relative; + overflow: hidden; + } + + .theory-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 1px; + background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent); + } + + .theory-card-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; + } + + .theory-card-icon { + width: 40px; + height: 40px; + border-radius: 12px; + background: linear-gradient(135deg, rgba(255,255,255,0.15), rgba(255,255,255,0.05)); + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + } + + .theory-card h4 { + color: var(--text-primary); + font-size: 16px; + font-weight: 600; + margin: 0; + letter-spacing: 0.5px; + } + + .theory-card-subtitle { + color: var(--text-muted); + font-size: 12px; + margin-top: 2px; + } + + .theory-card-desc { + color: var(--text-secondary); + font-size: 13px; + line-height: 1.8; + margin-bottom: 16px; + padding: 12px 16px; + background: rgba(255,255,255,0.03); + border-radius: 12px; + } + + .theory-card .highlight { + color: var(--text-primary); + font-weight: 600; + background: linear-gradient(135deg, rgba(255,255,255,0.15), rgba(255,255,255,0.05)); + padding: 2px 8px; + border-radius: 6px; + } + + .theory-signals { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-bottom: 16px; + } + + .signal-box { + padding: 14px 16px; + border-radius: 14px; + background: rgba(255,255,255,0.03); + border: 1px solid rgba(255,255,255,0.08); + transition: all 0.3s ease; + } + + .signal-box:hover { + background: rgba(255,255,255,0.06); + transform: translateY(-2px); + } + + .signal-box-title { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 8px; + } + + .signal-box-title .dot { + width: 8px; + height: 8px; + border-radius: 50%; + } + + .signal-box.buy .dot { + background: var(--success); + box-shadow: 0 0 8px var(--success); + } + + .signal-box.sell .dot { + background: var(--danger); + box-shadow: 0 0 8px var(--danger); + } + + .signal-box-title span { + font-size: 13px; + font-weight: 600; + } + + .signal-box.buy .signal-box-title span { + color: var(--success); + } + + .signal-box.sell .signal-box-title span { + color: var(--danger); + } + + .signal-box-content { + font-size: 11px; + color: var(--text-muted); + line-height: 1.6; + } + + .theory-footer { + display: flex; + align-items: center; + gap: 8px; + padding-top: 12px; + border-top: 1px solid rgba(255,255,255,0.06); + font-size: 11px; + color: var(--text-muted); + } + + .theory-footer-icon { + opacity: 0.5; + } + + .theory-card .buy-signal { + color: var(--success); + } + + .theory-card .sell-signal { + color: var(--danger); + } + + .theory-toggle { + color: var(--text-muted); + font-size: 12px; + transition: transform 0.3s ease; + } + + .theory-card-body { + margin-top: 16px; + } + + /* 提醒页面样式 */ + .alert-badge { + background: var(--danger); + color: white; + font-size: 10px; + padding: 2px 6px; + border-radius: 10px; + margin-left: 4px; + } + + .alerts-summary.top-summary { + display: grid; + grid-template-columns: repeat(4, 1fr); + grid-template-rows: auto auto; + gap: 8px 12px; + margin-bottom: 12px; + padding: 12px 16px; + } + + .alerts-summary.top-summary .summary-row { + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + } + + .alerts-summary.top-summary .summary-label { + font-size: 11px; + color: var(--text-muted); + } + + .alerts-summary.top-summary .summary-value { + font-size: 16px; + font-weight: 600; + } + + .refresh-btn-inline { + grid-column: 1 / -1; + padding: 8px 14px; + font-size: 12px; + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 6px; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.2s ease; + } + + .refresh-btn-inline:hover { + background: rgba(255, 255, 255, 0.1); + color: var(--text-primary); + } + + .refresh-btn-inline:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .alerts-summary { + background: var(--bg-glass); + backdrop-filter: blur(20px); + border: none; + border-radius: 10px; + padding: 12px; + margin-bottom: 14px; + display: flex; + justify-content: space-around; + } + + .summary-row { + text-align: center; + } + + .summary-label { + display: block; + font-size: 10px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 2px; + } + + .summary-value { + font-size: 20px; + font-weight: 700; + color: var(--text-primary); + } + + .summary-row.buy .summary-value { color: var(--success); } + .summary-row.sell .summary-value { color: var(--danger); } + + .alert-section { + margin-bottom: 20px; + } + + .section-title { + font-size: 13px; + font-weight: 600; + margin-bottom: 12px; + padding-left: 4px; + color: var(--text-secondary); + } + + .section-title.buy { color: var(--success); } + .section-title.sell { color: var(--danger); } + .section-title.watch { color: var(--text-muted); } + + .alert-card { + background: var(--bg-glass); + backdrop-filter: blur(20px); + border: none; + border-radius: 12px; + padding: 12px; + margin-bottom: 8px; + } + + .alert-card.watch { + opacity: 0.7; + } + + .alert-header { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 6px; + } + + .alert-code { + font-weight: 600; + color: var(--text-primary); + font-size: 15px; + } + + .alert-name { + color: var(--text-secondary); + font-size: 13px; + flex: 1; + } + + .alert-remove { + color: var(--text-muted); + font-size: 18px; + padding: 4px 8px; + cursor: pointer; + margin-right: 4px; + } + + .alert-remove:active { + color: var(--danger); + } + + .recommend-rate { + font-size: 12px; + font-weight: 600; + padding: 2px 8px; + border-radius: 4px; + } + + .recommend-rate.buy { + background: rgba(0, 255, 136, 0.15); + color: var(--success); + } + + .recommend-rate.sell { + background: rgba(255, 68, 68, 0.15); + color: var(--danger); + } + + .alert-icon { + width: 24px; + height: 24px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: bold; + } + + .alert-card.buy .alert-icon { + background: var(--success); + color: var(--bg-dark); + } + + .alert-card.sell .alert-icon { + background: var(--danger); + color: white; + } + + .alert-position { + font-size: 11px; + color: var(--text-muted); + font-weight: 400; + margin-left: 6px; + } + + .alert-flow { + display: flex; + gap: 12px; + font-size: 11px; + color: var(--text-secondary); + margin-bottom: 4px; + } + + .alert-desc { + font-size: 11px; + color: var(--text-muted); + flex: 1; + min-width: 0; + } + .alert-desc.holding-note { + font-size: 10px; + color: var(--warning, #f59e0b); + margin-top: 4px; + } + .alert-bottom { + display: flex; + align-items: center; + gap: 8px; + margin-top: 4px; + } + .alert-signals { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin: 4px 0; + } + .alert-prices-dual { + display: flex; + gap: 8px; + margin-bottom: 4px; + padding: 4px 0; + } + .alert-prices-dual .price-col { + flex: 1; + display: flex; + align-items: center; + gap: 4px; + min-width: 0; + } + .alert-prices-dual .price-label { + font-size: 10px; + color: var(--text-muted); + white-space: nowrap; + flex-shrink: 0; + } + .alert-prices-dual .price-col.scan { + opacity: 0.75; + } + .alert-prices-dual .price-col.scan .price-label { + color: var(--text-muted); + } + .alert-price { + font-size: 12px; + color: #ffd700; + margin-left: auto; + font-weight: 500; + } + .alert-prices-dual .alert-price { + margin-left: 0; + font-size: 12px; + } + .alert-change { + font-size: 11px; + font-weight: 500; + margin-right: 4px; + } + .alert-change.up { color: #ff4444; } + .alert-change.down { color: #00ce9e; } + .alert-sig-tag { + font-size: 10px; + padding: 1px 6px; + border-radius: 3px; + background: rgba(0, 206, 158, 0.15); + color: #00ce9e; + } + + .refresh-btn { + width: 100%; + height: 44px; + padding: 0 16px; + background: var(--bg-glass); + border: none; + border-radius: 10px; + color: var(--text-primary); + font-size: 14px; + font-weight: 600; + cursor: pointer; + margin-top: 8px; + } + + .refresh-btn:active { + background: var(--bg-glass-hover); + } + + .refresh-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .alert-action-btn { + flex-shrink: 0; + height: 30px; + padding: 0 14px; + border: none; + border-radius: 6px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + } + + .alert-action-btn.buy, + .alert-action-btn.sell { + background: rgba(255, 255, 255, 0.1); + color: var(--text-primary); + } + + .alert-action-btn.buy:hover, + .alert-action-btn.sell:hover { + background: rgba(255, 255, 255, 0.15); + } + + .alert-action-btn:active { + transform: scale(0.98); + opacity: 0.9; + } + + /* Toast 提示样式 */ + .toast-container { + position: fixed; + top: 20px; + left: 50%; + transform: translateX(-50%); + padding: 12px 24px; + border-radius: 12px; + background: rgba(30, 30, 40, 0.95); + backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.1); + display: flex; + align-items: center; + gap: 10px; + z-index: 10000; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); + max-width: 90%; + } + + .toast-icon { + width: 24px; + height: 24px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + font-weight: bold; + } + + .toast-container.info .toast-icon { + background: rgba(100, 150, 255, 0.2); + color: #6496ff; + } + + .toast-container.success .toast-icon { + background: rgba(0, 255, 136, 0.2); + color: var(--success); + } + + .toast-container.error .toast-icon { + background: rgba(255, 68, 68, 0.2); + color: var(--danger); + } + + .toast-container.warning .toast-icon { + background: rgba(255, 170, 0, 0.2); + color: var(--warning); + } + + .toast-message { + font-size: 14px; + color: var(--text-primary); + } + + /* Toast 动画 */ + .toast-fade-enter-active, + .toast-fade-leave-active { + transition: all 0.3s ease; + } + + .toast-fade-enter-from, + .toast-fade-leave-to { + opacity: 0; + transform: translateX(-50%) translateY(-20px); + } + + /* 确认对话框样式 */ + .confirm-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 10001; + backdrop-filter: blur(4px); + } + + .confirm-dialog { + background: rgba(30, 30, 40, 0.98); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 16px; + padding: 24px; + min-width: 280px; + max-width: 90%; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5); + } + + .confirm-message { + font-size: 15px; + color: var(--text-primary); + text-align: center; + margin-bottom: 20px; + line-height: 1.5; + } + + .confirm-buttons { + display: flex; + gap: 12px; + } + + .confirm-btn { + flex: 1; + padding: 12px 20px; + border-radius: 10px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + border: none; + } + + .confirm-btn.cancel { + background: rgba(255, 255, 255, 0.1); + color: var(--text-secondary); + } + + .confirm-btn.cancel:hover { + background: rgba(255, 255, 255, 0.15); + } + + .confirm-btn.ok { + background: var(--danger); + color: white; + } + + .confirm-btn.ok:hover { + opacity: 0.9; + } + + /* Modal 动画 */ + .modal-fade-enter-active, + .modal-fade-leave-active { + transition: all 0.25s ease; + } + + .modal-fade-enter-from, + .modal-fade-leave-to { + opacity: 0; + } + + .modal-fade-enter-from .confirm-dialog, + .modal-fade-leave-to .confirm-dialog { + transform: scale(0.95); + } + + /* 响应式优化 */ + @media (max-width: 400px) { + .container { + padding: 16px 12px; + } + + h1 { + font-size: 18px; + } + + .signal-details { + grid-template-columns: repeat(2, 1fr); + } + + .summary-grid { + grid-template-columns: 1fr; + } + } + + /* 滚动条美化 */ + ::-webkit-scrollbar { + width: 4px; + } + + ::-webkit-scrollbar-track { + background: transparent; + } + + ::-webkit-scrollbar-thumb { + background: var(--border-glass); + border-radius: 2px; + } + + /* 交易记录分组样式 */ + .trade-group { + margin-bottom: 16px; + } + + .trade-group-header { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.03)); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + padding: 14px 16px; + margin-bottom: 8px; + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 8px; + transition: all 0.2s ease; + } + + .trade-group-header:hover { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.05)); + } + + .toggle-btn { + background: none; + border: none; + color: var(--text-muted); + font-size: 12px; + cursor: pointer; + padding: 0; + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: color 0.2s ease; + } + + .toggle-btn:hover { + color: var(--text-primary); + } + + .trade-group-content { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; + cursor: pointer; + } + + .trade-group-info { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + } + + .trade-group-code { + font-size: 16px; + font-weight: 700; + color: var(--text-primary); + } + + .trade-group-name { + font-size: 14px; + color: var(--text-secondary); + } + + .trade-group-count { + font-size: 11px; + color: var(--text-muted); + background: rgba(255, 255, 255, 0.08); + padding: 3px 8px; + border-radius: 10px; + } + + .trade-group-summary { + display: flex; + align-items: center; + gap: 10px; + } + + /* 新的统计格式:数字在上,文字在下 */ + .trade-summary-stats { + display: flex; + gap: 8px; + margin-left: 5px; + } + + .summary-stat-item { + text-align: center; + min-width: 45px; + } + + .summary-stat-value { + font-size: 12px; + font-weight: 600; + white-space: nowrap; + } + + .summary-stat-label { + color: var(--text-muted); + font-size: 9px; + margin-top: 1px; + } + + .summary-stat-item.profit .summary-stat-value { color: var(--success); } + .summary-stat-item.loss .summary-stat-value { color: var(--danger); } + + .break-even-price { + color: var(--text-secondary); + } + + .holding-badge { + font-size: 11px; + color: var(--text-secondary); + background: rgba(255, 255, 255, 0.08); + padding: 4px 10px; + border-radius: 12px; + } + + .holding-badge.cleared { + color: var(--text-muted); + background: rgba(255, 255, 255, 0.05); + } + + .holding-price { + font-size: 12px; + color: var(--text-secondary); + } + + /* 价格涨跌颜色 */ + .price-up { + color: var(--success) !important; + } + + .price-down { + color: var(--danger) !important; + } + + .holding-value { + font-size: 12px; + color: var(--text-secondary); + } + + .holding-profit { + font-size: 14px; + font-weight: 700; + } + + .holding-profit.profit { + color: var(--success); + } + + .holding-profit.loss { + color: var(--danger); + } + + /* 交易条目样式 - 区分买入卖出 */ + .trade-item { + display: flex; + background: var(--bg-glass); + backdrop-filter: blur(20px); + border: none; + border-radius: 10px; + padding: 12px; + margin-bottom: 6px; + margin-left: 8px; + position: relative; + transition: all 0.2s ease; + } + + .trade-type-indicator { + width: 32px; + height: 32px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 700; + margin-right: 12px; + flex-shrink: 0; + } + + .trade-type-indicator.buy { + background: rgba(0, 255, 136, 0.15); + color: var(--success); + } + + .trade-type-indicator.sell { + background: rgba(255, 68, 68, 0.15); + color: var(--danger); + } + + .trade-type-badge { + font-size: 11px; + font-weight: 600; + padding: 3px 8px; + border-radius: 4px; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .trade-type-badge.buy { + background: rgba(0, 255, 136, 0.15); + color: var(--success); + } + + .trade-type-badge.sell { + background: rgba(255, 68, 68, 0.15); + color: var(--danger); + } + + .trade-date { + font-size: 11px; + color: var(--text-muted); + margin-top: 4px; + } diff --git a/stock-html/static/css/pages.css b/stock-html/static/css/pages.css new file mode 100644 index 0000000..af637ba --- /dev/null +++ b/stock-html/static/css/pages.css @@ -0,0 +1,1532 @@ +/* ═══════════════════════════════════════ + pages.css - Tech signals, trading, AI analysis + Lines: 1199 + ═══════════════════════════════════════ */ + +/* ========== 技术信号页面样式 ========== */ + +.tech-signal-page { padding-bottom: 20px; } + +.tech-signal-header { margin-bottom: 16px; } + +.tech-input-row { + display: flex; gap: 6px; margin-bottom: 8px; flex-wrap: wrap; +} +.tech-action-row { + display: flex; gap: 8px; margin-bottom: 10px; flex-wrap: wrap; +} +.tech-input { + flex: 1; min-width: 100px; padding: 8px 12px; border-radius: 8px; + border: 1px solid var(--border-color); background: var(--bg-glass); + color: var(--text-primary); font-size: 13px; +} +.tech-btn { + padding: 8px 10px; border-radius: 8px; border: none; + background: var(--primary); color: white; font-size: 12px; + white-space: nowrap; flex-shrink: 0; + font-weight: 600; cursor: pointer; white-space: nowrap; +} +.tech-btn.batch { background: var(--warning); color: #333; } +.tech-btn.fullscan { background: #6c5ce7; color: #fff; } +.tech-btn.strategy { background: linear-gradient(135deg, #ff6b6b, #ffd93d); color: #1a1a2e; font-weight: 700; } +.tech-btn.rescan { + background: transparent; border: 1px solid rgba(255,255,255,0.15); + color: var(--text-muted); font-size: 11px; +} +.tech-btn.rescan:hover { border-color: #ff6b6b; color: #ff6b6b; } +.tech-btn:disabled { opacity: 0.5; cursor: not-allowed; } + +.tech-legend { + display: flex; flex-wrap: wrap; gap: 8px; font-size: 11px; color: var(--text-secondary); +} +.legend-item { display: flex; align-items: center; gap: 3px; } +.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; } +.dot.s85 { background: #e74c3c; } +.dot.s80 { background: #e67e22; } +.dot.s75 { background: #f39c12; } +.dot.s70 { background: #27ae60; } +.dot.s65 { background: #2980b9; } +.dot.s60 { background: #8e44ad; } +.dot.s55 { background: #95a5a6; } + +.tech-result-card { + background: var(--bg-glass); border-radius: 16px; + padding: 16px; margin-bottom: 16px; + border: 1px solid var(--border-color); +} +.tech-result-header { + display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; +} +.tech-result-header h4 { font-size: 15px; font-weight: 600; color: var(--text-primary); margin: 0; } +.tech-result-actions { + display: flex; gap: 8px; margin-bottom: 12px; +} +/* 方案C:扫描 vs 实时 双数据显示 */ +.signal-source-section { margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--border-glass); } +.signal-source-section:first-of-type { margin-top: 0; padding-top: 0; border-top: none; } +.signal-source-title { font-size: 13px; font-weight: 600; color: var(--text-secondary); margin: 0 0 8px 0; } +.realtime-loading { font-size: 12px; color: var(--text-muted); } +.realtime-error { font-size: 12px; color: var(--danger); } +.tech-recommend-bar.realtime-bar { margin-top: 8px; } +.tech-result-actions .watch-btn { + padding: 6px 14px; font-size: 12px; +} +.signal-count { font-size: 12px; padding: 3px 10px; border-radius: 12px; background: var(--primary); color: white; font-weight: 600; } +.signal-count.none { background: var(--bg-secondary); color: var(--text-secondary); } + +.tech-recommend-bar { + margin-bottom: 14px; + padding: 10px 12px; + background: rgba(255,255,255,0.06); + border-radius: 10px; +} +.tech-recommend-bar.rec-buy { background: rgba(0, 206, 158, 0.08); } +.tech-recommend-bar.rec-sell { background: rgba(255, 68, 68, 0.08); } +.tech-recommend-bar.rec-watch { background: rgba(100, 149, 237, 0.08); } +.tech-recommend-label { font-size: 11px; color: var(--text-muted); margin-right: 8px; } +.tech-recommend-text { font-weight: 600; font-size: 14px; } +.tech-recommend-rate { margin-left: 8px; font-size: 12px; opacity: 0.9; } +.tech-recommend-reason { font-size: 12px; color: var(--text-secondary); margin-top: 6px; } +.tech-holding-note { + margin-top: 8px; padding: 6px 10px; + background: rgba(255,152,0,0.08); border-radius: 6px; + font-size: 12px; color: #e67e00; line-height: 1.5; +} + +.tech-indicators { + display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 14px; + padding: 10px; background: var(--bg-secondary); border-radius: 10px; +} +.indicator-group { + display: flex; align-items: center; gap: 6px; font-size: 12px; +} +.ind-label { font-weight: 600; color: var(--text-secondary); min-width: 40px; } +.ind-val { padding: 2px 6px; border-radius: 4px; background: var(--bg-glass); font-family: monospace; font-size: 11px; } +.ind-val.pos { color: var(--danger); } +.ind-val.neg { color: var(--success); } +.ind-val.oversold { color: var(--success); font-weight: 600; } +.ind-val.overbought { color: var(--danger); font-weight: 600; } + +.tech-signal-list { display: flex; flex-direction: column; gap: 8px; } + +.tech-signal-item { + padding: 12px; border-radius: 10px; + background: var(--bg-secondary); +} + +.sig-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; } +.sig-name { font-weight: 700; font-size: 14px; color: var(--text-primary); } +.sig-strength { font-size: 12px; font-weight: 600; padding: 2px 8px; border-radius: 10px; background: rgba(255,255,255,0.1); } +.sig-date { font-size: 11px; color: var(--text-secondary); } +.sig-desc { font-size: 12px; color: var(--text-secondary); line-height: 1.5; margin-bottom: 4px; } +.sig-price { font-size: 12px; color: var(--text-secondary); font-family: monospace; } + +.tech-batch-results { margin-top: 16px; } +.tech-batch-item { + display: flex; justify-content: space-between; align-items: center; + padding: 12px 14px; border-radius: 10px; margin-bottom: 6px; + background: var(--bg-glass); border: 1px solid var(--border-color); + cursor: pointer; transition: background 0.2s; +} +.tech-batch-item:active { background: var(--bg-secondary); } +.batch-stock-info { display: flex; gap: 8px; align-items: center; } +.batch-code { font-weight: 600; font-size: 13px; color: var(--text-primary); } +.batch-name { font-size: 12px; color: var(--text-secondary); } +.batch-signals { display: flex; gap: 4px; flex-wrap: wrap; } +.batch-signal-tag { + font-size: 11px; padding: 2px 8px; border-radius: 8px; + font-weight: 600; color: white; +} +.batch-signal-tag.strength-85 { background: #e74c3c; } +.batch-signal-tag.strength-80 { background: #e67e22; } +.batch-signal-tag.strength-75 { background: #f39c12; } +.batch-signal-tag.strength-70 { background: #27ae60; } +.batch-signal-tag.strength-65 { background: #2980b9; } +.batch-signal-tag.strength-60 { background: #8e44ad; } +.batch-signal-tag.strength-55 { background: #95a5a6; } + + +/* ========== 模拟交易页面样式 ========== */ + +.sim-trade-page { + padding-bottom: 20px; +} + +.sim-stats-card { + background: var(--bg-glass); + border-radius: 16px; + padding: 16px; + margin-bottom: 16px; + border: 1px solid var(--border-glass); +} + +.sim-stats-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} + +.sim-stats-header h4 { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); +} + +.sim-actions { + display: flex; + gap: 8px; +} + +.sim-btn { + padding: 8px 16px; + border-radius: 8px; + border: none; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.sim-btn.auto { + background: linear-gradient(135deg, #00ff88, #00ccff); + color: #000; +} + +.sim-btn.auto:hover { + opacity: 0.9; + transform: scale(1.02); +} + +.sim-btn.auto:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.sim-btn.reset { + background: rgba(255, 68, 68, 0.2); + color: var(--danger); +} + +.sim-btn.reset:hover { + background: rgba(255, 68, 68, 0.3); +} + +.sim-stats-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 6px; + margin-bottom: 10px; +} + +.sim-stat-item { + text-align: center; + padding: 8px 4px; + background: rgba(255, 255, 255, 0.03); + border-radius: 8px; +} + +.sim-stat-value { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.sim-stat-item.profit .sim-stat-value { + color: var(--success); +} + +.sim-stat-item.loss .sim-stat-value { + color: var(--danger); +} + +.sim-stat-label { + font-size: 10px; + color: var(--text-muted); +} + +.sim-stats-detail { + display: flex; + justify-content: center; + gap: 16px; + padding-top: 10px; + border-top: 1px solid var(--border-glass); + font-size: 12px; +} + +.sim-stats-detail .profit { + color: var(--success); +} + +.sim-stats-detail .loss { + color: var(--danger); +} + +/* 交易规则卡片(可折叠) */ +.sim-rule-card { + background: rgba(255, 170, 0, 0.08); + border: 1px solid rgba(255, 170, 0, 0.2); + border-radius: 10px; + padding: 10px 12px; + margin-bottom: 12px; +} + +.sim-rule-card.collapsible { + cursor: pointer; +} + +.sim-rule-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.sim-rule-card h4 { + font-size: 13px; + font-weight: 600; + color: var(--warning); + margin: 0; +} + +.sim-rule-header .toggle-icon { + font-size: 10px; + color: var(--warning); + transition: transform 0.2s; +} + +.sim-rule-header .toggle-icon.expanded { + transform: rotate(180deg); +} + +.sim-rule-card ul { + list-style: none; + padding: 0; + margin: 8px 0 0 0; +} + +.sim-rule-card li { + font-size: 11px; + color: var(--text-secondary); + padding: 3px 0; + padding-left: 14px; + position: relative; +} + +.sim-rule-card li::before { + content: '•'; + position: absolute; + left: 0; + color: var(--warning); +} + +.sim-rule-card li strong { + color: var(--text-primary); +} + +/* 模拟交易区块 */ +.sim-section { + margin-bottom: 16px; +} +.sim-section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} +.sim-section-header .section-title { + margin: 0; +} +.sim-section-header .toggle-icon { + font-size: 10px; + color: #888; + transition: transform 0.2s; + flex-shrink: 0; +} +.sim-section-header .toggle-icon.expanded { + transform: rotate(180deg); +} +.collapsible-section { + border-bottom: 1px solid rgba(255,255,255,0.05); + padding-bottom: 8px; +} + +/* 模拟 vs 实盘 对比面板 */ +.sim-compare-panel { + margin-top: 8px; + padding: 8px 10px; + background: rgba(255, 159, 67, 0.06); + border: 1px solid rgba(255, 159, 67, 0.15); + border-radius: 8px; +} +.sim-compare-header { + display: flex; + justify-content: space-between; + align-items: center; +} +.sim-compare-body { + margin-top: 8px; +} +.sim-compare-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; +} +.sim-compare-col { + flex: 1; + text-align: center; + padding: 6px 8px; + background: rgba(255,255,255,0.03); + border-radius: 6px; +} +.sim-compare-label { + font-size: 11px; + color: #888; + margin-bottom: 2px; +} +.sim-compare-value { + font-size: 14px; + font-weight: 700; +} +.sim-compare-value small { + font-size: 11px; + font-weight: 400; + opacity: 0.7; +} +.sim-compare-arrow { + color: #666; + font-size: 16px; + flex-shrink: 0; +} +.sim-compare-fees { + display: flex; + justify-content: space-between; + align-items: center; + padding: 4px 0; + font-size: 12px; + border-top: 1px dashed rgba(255,255,255,0.06); + margin-top: 4px; +} +.sim-compare-tip { + text-align: center; + padding-top: 4px; +} +.sim-btn.refresh { + background: rgba(108, 92, 231, 0.25); + color: var(--primary); + font-size: 12px; +} +.sim-btn.refresh:hover:not(:disabled) { + background: rgba(108, 92, 231, 0.4); +} +.sim-btn.refresh:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.sim-section .section-title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 12px; + display: flex; + align-items: center; + gap: 8px; +} + +.sim-section .badge { + background: rgba(255, 255, 255, 0.1); + color: var(--text-secondary); + font-size: 11px; + padding: 2px 8px; + border-radius: 10px; + font-weight: 500; +} + +.empty-tip { + text-align: center; + color: var(--text-muted); + font-size: 13px; + padding: 30px 20px; + background: var(--bg-glass); + border-radius: 12px; +} + +/* 模拟持仓列表 */ +.sim-positions-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.sim-position-item { + background: var(--bg-glass); + border-radius: 12px; + padding: 14px; + cursor: pointer; + transition: all 0.2s; + border: 1px solid var(--border-glass); +} + +.sim-position-item:hover { + background: var(--bg-glass-hover); +} + +.sim-pos-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.sim-pos-code { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); +} + +.sim-pos-name { + font-size: 13px; + color: var(--text-secondary); + flex: 1; +} + +.sim-pos-qty { + font-size: 12px; + color: var(--text-muted); + background: rgba(255, 255, 255, 0.08); + padding: 2px 8px; + border-radius: 6px; +} + +.sim-pos-detail { + display: flex; + gap: 16px; + font-size: 12px; + color: var(--text-secondary); +} + +.sim-pos-detail .profit { + color: var(--success); + font-weight: 500; +} + +.sim-pos-detail .loss { + color: var(--danger); + font-weight: 500; +} + +/* 模拟交易记录列表 */ +.sim-trades-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.sim-trade-item { + display: flex; + gap: 12px; + background: var(--bg-glass); + border-radius: 12px; + padding: 12px; + border: 1px solid var(--border-glass); +} + +.sim-trade-type { + width: 28px; + height: 28px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 600; + flex-shrink: 0; +} + +.sim-trade-type.buy { + background: rgba(0, 255, 136, 0.15); + color: var(--success); +} + +.sim-trade-type.sell { + background: rgba(255, 68, 68, 0.15); + color: var(--danger); +} + +.sim-trade-content { + flex: 1; + min-width: 0; +} + +.sim-trade-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 4px; +} + +.sim-trade-stock { + font-size: 13px; + font-weight: 500; + color: var(--text-primary); +} + +.sim-trade-rate { + font-size: 11px; + color: var(--success); + background: rgba(0, 255, 136, 0.1); + padding: 2px 6px; + border-radius: 4px; +} + +.sim-trade-detail { + display: flex; + justify-content: space-between; + font-size: 12px; + color: var(--text-secondary); + margin-bottom: 4px; +} + +.sim-trade-amount { + font-weight: 500; + color: var(--text-primary); +} + +.sim-trade-reason { + font-size: 11px; + color: var(--text-muted); + margin-bottom: 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.sim-trade-date { + font-size: 11px; + color: var(--text-muted); +} + +/* AI分析页面样式 */ +.ai-analyze-page { + padding: 16px 0; +} + +.ai-input-section { + background: var(--bg-glass); + border: 1px solid var(--border-glass); + border-radius: 12px; + padding: 16px; + margin-bottom: 16px; +} + +.ai-input-row { + display: flex; + gap: 8px; + margin-bottom: 10px; +} + +.scan-source-select, .model-select { + flex: 1; + padding: 10px 12px; + border: 1px solid var(--border-glass); + border-radius: 8px; + background: rgba(255, 255, 255, 0.05); + color: var(--text-primary); + font-size: 14px; +} + +.model-select { + max-width: 140px; +} + +.model-desc { + font-size: 12px; + color: var(--text-muted); + margin-top: 6px; + margin-bottom: 10px; +} + +/* 模型页面 */ +.model-page { + padding: 12px; +} + +/* 信号体系各区块 */ +.signal-system-section { + background: var(--bg-glass); + border: 1px solid var(--border-glass); + border-radius: 12px; + padding: 14px; + margin-bottom: 12px; +} +.signal-system-section.highlight-section { + border-color: rgba(255, 215, 0, 0.3); + background: rgba(255, 215, 0, 0.05); +} +.signal-system-title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + margin: 0 0 10px 0; + padding-bottom: 6px; + border-bottom: 1px solid var(--border-glass); +} + +/* 信号胜率排行 */ +.signal-rank-list { display: flex; flex-direction: column; gap: 6px; } +.signal-rank-item { + display: grid; + grid-template-columns: 24px 72px 42px 1fr; + align-items: center; + gap: 6px; + padding: 6px 8px; + border-radius: 8px; + background: rgba(255,255,255,0.03); + font-size: 12px; +} +.signal-rank-item .rank-param { + display: none; +} +.rank-badge { + width: 20px; height: 20px; + border-radius: 50%; + display: flex; align-items: center; justify-content: center; + font-size: 11px; font-weight: 700; + color: #fff; + background: #555; +} +.rank-1 .rank-badge { background: linear-gradient(135deg, #ff6b6b, #ffd93d); } +.rank-2 .rank-badge { background: linear-gradient(135deg, #00ce9e, #00b4d8); } +.rank-3 .rank-badge { background: linear-gradient(135deg, #748ffc, #9775fa); } +.rank-name { font-weight: 600; color: var(--text-primary); } +.rank-1 .rank-name { color: #ffd93d; } +.rank-2 .rank-name { color: #00ce9e; } +.rank-3 .rank-name { color: #748ffc; } +.rank-rate { + font-weight: 700; + color: #00ce9e; + text-align: right; +} +.rank-desc { color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +/* 标准牛股启动顺序 */ +.bull-flow { + display: flex; + align-items: center; + gap: 3px; + padding: 6px 0; + flex-wrap: nowrap; + justify-content: space-between; +} +.bull-step { + display: flex; + flex-direction: column; + align-items: center; + gap: 1px; + flex: 1; + min-width: 0; + padding: 5px 2px; + border-radius: 6px; + background: rgba(255,255,255,0.05); + border: 1px solid var(--border-glass); +} +.bull-num { + width: 16px; height: 16px; + border-radius: 50%; + background: var(--accent); + color: #000; + display: flex; align-items: center; justify-content: center; + font-size: 9px; font-weight: 700; + flex-shrink: 0; +} +.bull-label { + font-size: 10px; + font-weight: 600; + color: var(--text-primary); + text-align: center; + line-height: 1.2; +} +.bull-hint { + font-size: 8px; + color: var(--text-muted); + text-align: center; + line-height: 1.1; +} +.bull-arrow { + color: var(--text-muted); + font-size: 10px; + flex-shrink: 0; +} +.bull-note { + font-size: 10px; + color: var(--text-muted); + margin-top: 4px; + text-align: center; + font-style: italic; +} + +/* 手机端:标准牛股启动顺序防溢出 */ +@media (max-width: 520px) { + /* 父级裁剪,防止子元素撑开页面 */ + .signal-system-section.bull-flow-section { + overflow-x: hidden; + overflow-y: visible; + } + .signal-system-section.bull-flow-section .bull-flow { + overflow-x: auto; + overflow-y: hidden; + -webkit-overflow-scrolling: touch; + flex-wrap: nowrap; + justify-content: flex-start; + padding: 8px 4px 8px 0; + margin: 0 -4px; + min-width: 0; + width: 100%; + max-width: 100%; + box-sizing: border-box; + } + .signal-system-section.bull-flow-section .bull-flow .bull-step { + flex: 0 0 auto; + min-width: 58px; + padding: 6px 4px; + } + .signal-system-section.bull-flow-section .bull-flow .bull-arrow { + flex-shrink: 0; + } + .signal-system-section.bull-flow-section .bull-flow .bull-label { + font-size: 9px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 54px; + display: block; + margin: 0 auto; + } + .signal-system-section.bull-flow-section .bull-flow .bull-hint { + font-size: 7px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 54px; + display: block; + margin: 0 auto; + } +} + +/* 体系最强战法 */ +.strategy-steps { + display: flex; + flex-direction: column; + gap: 8px; +} +.strategy-step { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-radius: 8px; + background: rgba(255,255,255,0.03); +} +.strategy-step .step-icon { font-size: 16px; } +.strategy-step .step-action { + font-size: 12px; + font-weight: 700; + min-width: 32px; +} +.strategy-step .step-rule { + font-size: 12px; + color: var(--text-secondary); +} +.step-watch .step-action { color: #748ffc; } +.step-buy .step-action { color: #ff6b6b; } +.step-add .step-action { color: #00ce9e; } +.step-hold .step-action { color: #ffd93d; } + +/* 核心信号一句话总结 */ +.signal-summary-list { + display: flex; + flex-direction: column; + gap: 6px; +} +.signal-summary-item { + font-size: 12px; + color: var(--text-secondary); + display: flex; + align-items: center; + gap: 8px; +} +.summary-tag { + font-size: 11px; + font-weight: 600; + padding: 2px 8px; + border-radius: 4px; + white-space: nowrap; +} +.summary-tag.core { + background: rgba(255, 107, 107, 0.15); + color: #ff6b6b; +} +.summary-tag.normal { + background: rgba(0, 206, 158, 0.15); + color: #00ce9e; +} +.summary-tag.aux { + background: rgba(255,255,255,0.08); + color: var(--text-muted); +} + +/* 密码弹窗按钮 */ +.password-buttons { + display: flex; + gap: 12px; + margin-top: 16px; +} + +.password-buttons .btn-cancel { + flex: 1; + padding: 12px; + border: 1px solid var(--border-glass); + border-radius: 8px; + background: transparent; + color: var(--text-secondary); + font-size: 14px; + cursor: pointer; +} + +.password-buttons .btn-cancel:hover { + background: rgba(255, 255, 255, 0.1); +} + +.password-buttons .login-submit { + flex: 1; + margin-top: 0; +} + +/* 股票组选择列表 */ +.source-list { + display: flex; + gap: 8px; + margin-bottom: 12px; +} + +.source-item { + flex: 1; + text-align: center; + background: var(--bg-glass); + border: 1px solid var(--border-glass); + border-radius: 8px; + padding: 10px 12px; + cursor: pointer; + transition: all 0.2s; + font-size: 13px; + color: var(--text-secondary); +} + +.source-item:hover { + border-color: rgba(255, 255, 255, 0.3); +} + +.source-item.active { + border-color: var(--accent); + background: rgba(0, 255, 136, 0.1); + color: var(--text-primary); +} + +.source-check { + color: var(--accent); + font-size: 14px; +} + +.scan-action { + margin-bottom: 12px; +} + +.scan-btn-full { + width: 100%; + padding: 12px; + background: rgba(255, 255, 255, 0.1); + border: 1px solid var(--border-glass); + border-radius: 10px; + color: var(--text-primary); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.scan-btn-full:hover { + background: rgba(255, 255, 255, 0.15); +} + +.scan-btn-full:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; +} + +.ai-stock-input { + flex: 1; + max-width: 180px; + padding: 10px 12px; + border: 1px solid var(--border-glass); + border-radius: 8px; + background: rgba(255, 255, 255, 0.05); + color: var(--text-primary); + font-size: 14px; +} + +.ai-stock-input:focus { + outline: none; + border-color: rgba(255, 255, 255, 0.3); +} + +.ai-analyze-btn { + padding: 10px 16px; + border: none; + border-radius: 8px; + background: rgba(255, 255, 255, 0.1); + color: var(--text-primary); + font-size: 13px; + cursor: pointer; + white-space: nowrap; +} + +.ai-analyze-btn.primary { + background: rgba(255, 255, 255, 0.15); + color: var(--text-primary); +} + +.ai-analyze-btn.primary:hover { + background: rgba(255, 255, 255, 0.2); +} + +.ai-analyze-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.ai-quick-stocks { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.quick-label { + font-size: 12px; + color: var(--text-muted); +} + +.quick-stock-btn { + padding: 6px 12px; + border: 1px solid var(--border-glass); + border-radius: 16px; + background: transparent; + color: var(--text-secondary); + font-size: 12px; + cursor: pointer; +} + +.quick-stock-btn:hover { + background: var(--bg-glass-hover); + color: var(--text-primary); +} + +.ai-result-section { + background: var(--bg-glass); + border: 1px solid var(--border-glass); + border-radius: 12px; + padding: 16px; + margin-bottom: 16px; +} + +.ai-result-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border-glass); +} + +.ai-result-header h3 { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); +} + +.ai-result-time { + font-size: 11px; + color: var(--text-muted); +} + +.ai-result-content { + font-size: 14px; + line-height: 1.8; + color: var(--text-secondary); +} + +.ai-result-content strong { + color: var(--text-primary); +} + +/* AI思考过程样式 */ +.ai-reasoning-section { + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--border-glass); + border-radius: 8px; + margin-bottom: 16px; + overflow: hidden; +} + +.ai-reasoning-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + cursor: pointer; + font-size: 13px; + color: var(--text-secondary); +} + +.ai-reasoning-header:hover { + background: rgba(255, 255, 255, 0.05); +} + +.ai-reasoning-header .expand-icon { + font-size: 10px; + color: var(--text-muted); +} + +.ai-reasoning-content { + padding: 0 16px 16px; + font-size: 13px; + line-height: 1.7; + color: var(--text-muted); + white-space: pre-wrap; + max-height: 300px; + overflow-y: auto; +} + +/* AI加载指示器 */ +.ai-loading-indicator { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 0; + font-size: 13px; + color: var(--text-muted); +} + +.loading-dot { + display: inline-block; + width: 8px; + height: 8px; + background: var(--success); + border-radius: 50%; + animation: pulse 1s infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} + +.ai-result-content .ai-section-title { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); + margin: 16px 0 8px 0; + padding-bottom: 6px; + border-bottom: 1px solid var(--border-glass); +} + +.ai-result-content .ai-section-title:first-child { + margin-top: 0; +} + +.ai-result-content .ai-subsection-title { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + margin: 12px 0 6px 0; +} + +.ai-result-content li { + margin-left: 16px; + list-style: disc; +} + +.ai-history-section { + background: var(--bg-glass); + border: 1px solid var(--border-glass); + border-radius: 12px; + padding: 16px; + margin-bottom: 16px; +} + +.ai-history-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.ai-history-item { + display: flex; + justify-content: space-between; + padding: 10px 12px; + background: rgba(255, 255, 255, 0.03); + border-radius: 8px; + cursor: pointer; +} + +.ai-history-item:hover { + background: var(--bg-glass-hover); +} + +.history-stock { + font-size: 13px; + color: var(--text-primary); +} + +.history-time { + font-size: 11px; + color: var(--text-muted); +} + +.ai-empty-tip { + text-align: center; + padding: 60px 20px; + color: var(--text-muted); +} + +.ai-empty-tip .empty-icon { + font-size: 48px; + margin-bottom: 16px; +} + +.ai-empty-tip p { + font-size: 14px; + margin-bottom: 8px; +} + +.ai-empty-tip .sub-tip { + font-size: 12px; + color: var(--text-muted); +} + +/* 基本面弹窗中的AI分析 */ +.ai-analysis-section { + background: rgba(102, 126, 234, 0.1); + border: 1px solid rgba(102, 126, 234, 0.3); + border-radius: 12px; + padding: 16px; + margin: 12px 0; +} + +.ai-analysis-content { + font-size: 13px; + line-height: 1.7; + color: var(--text-secondary); + max-height: 300px; + overflow-y: auto; +} + +.ai-close-btn { + margin-top: 12px; + padding: 8px 16px; + border: 1px solid var(--border-glass); + border-radius: 6px; + background: transparent; + color: var(--text-secondary); + font-size: 12px; + cursor: pointer; +} + +/* 7个信号状态列表 */ +.signal-status-list { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 10px; +} + +.signal-status-item { + background: rgba(255,255,255,0.04); + border: 1px solid rgba(255,255,255,0.08); + border-radius: 8px; + padding: 10px 12px; + transition: all 0.2s; +} + +.signal-status-item.triggered { + background: rgba(0, 200, 83, 0.08); + border-color: rgba(0, 200, 83, 0.3); +} + +.signal-status-item.inactive { + opacity: 0.85; +} + +.ss-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 4px; +} + +.ss-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.ss-dot.s85 { background: #ff4444; } +.ss-dot.s80 { background: #ff6b35; } +.ss-dot.s75 { background: #4CAF50; } +.ss-dot.s70 { background: #2196F3; } +.ss-dot.s65 { background: #9C27B0; } +.ss-dot.s60 { background: #FF9800; } +.ss-dot.s55 { background: #607D8B; } + +.ss-name { + font-weight: 600; + font-size: 13px; + color: #fff; +} + +.ss-strength { + font-size: 11px; + color: var(--text-muted); +} + +.ss-badge { + font-size: 11px; + padding: 1px 8px; + border-radius: 10px; + margin-left: auto; +} + +.ss-badge.active { + background: rgba(0, 200, 83, 0.2); + color: #00c853; + border: 1px solid rgba(0, 200, 83, 0.4); +} + +.ss-badge.wait { + background: rgba(255,255,255,0.06); + color: var(--text-muted); + border: 1px solid rgba(255,255,255,0.1); +} + +.ss-desc { + font-size: 12px; + color: var(--text-secondary); + line-height: 1.5; + padding-left: 16px; +} + +/* ========== 智能执行结果模态框 ========== */ +.smart-result-modal { + background: var(--card-bg, #1a1a2e); + border-radius: 16px; + width: 90%; + max-width: 480px; + max-height: 80vh; + overflow: hidden; + display: flex; + flex-direction: column; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.smart-result-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.smart-result-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 16px; + font-weight: 600; + color: var(--text-primary, #fff); +} + +.smart-result-icon.success { filter: none; } +.smart-result-icon.warning { filter: none; } + +.smart-result-body { + padding: 16px 20px; + overflow-y: auto; + flex: 1; +} + +.smart-result-algo { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 16px; + padding: 10px 14px; + background: rgba(255, 255, 255, 0.04); + border-radius: 10px; +} + +.algo-badge { + background: linear-gradient(135deg, #667eea, #764ba2); + color: #fff; + padding: 3px 10px; + border-radius: 6px; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.5px; +} + +.result-summary { + color: var(--text-secondary, #aaa); + font-size: 14px; +} + +.result-section-title { + font-size: 13px; + font-weight: 600; + color: var(--text-secondary, #aaa); + margin-bottom: 8px; + padding-bottom: 6px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +/* 交易结果列表 */ +.smart-result-trades { + margin-bottom: 16px; +} + +.result-trade-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + margin-bottom: 6px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.03); + font-size: 13px; +} + +.trade-type-tag { + padding: 2px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; +} + +.trade-type-tag.buy { + background: rgba(76, 175, 80, 0.2); + color: #4caf50; +} + +.trade-type-tag.sell, .trade-type-tag.partial_sell { + background: rgba(244, 67, 54, 0.2); + color: #f44336; +} + +.trade-stock { + color: var(--text-primary, #fff); + font-weight: 500; + flex: 1; +} + +.trade-detail { + color: var(--text-secondary, #aaa); + font-size: 12px; +} + +.trade-fee { + color: #ff9800; + font-size: 11px; +} + +/* 详细原因列表 */ +.smart-result-reasons { + margin-bottom: 16px; +} + +.result-reason-item { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 10px 14px; + margin-bottom: 6px; + border-radius: 10px; + font-size: 13px; + line-height: 1.6; + color: var(--text-primary, #fff); +} + +.result-reason-item.reason-limit { + background: rgba(255, 152, 0, 0.1); + border-left: 3px solid #ff9800; +} + +.result-reason-item.reason-cash { + background: rgba(33, 150, 243, 0.1); + border-left: 3px solid #2196f3; +} + +.result-reason-item.reason-sell { + background: rgba(244, 67, 54, 0.08); + border-left: 3px solid #f44336; +} + +.result-reason-item.reason-buy { + background: rgba(76, 175, 80, 0.08); + border-left: 3px solid #4caf50; +} + +.result-reason-item.reason-info { + background: rgba(255, 255, 255, 0.04); + border-left: 3px solid rgba(255, 255, 255, 0.2); +} + +.reason-icon { + font-size: 16px; + flex-shrink: 0; + margin-top: 1px; +} + +.reason-text { + flex: 1; +} + +/* 手续费和资金 */ +.smart-result-fees, .smart-result-cash { + padding: 8px 14px; + margin-bottom: 6px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.03); + font-size: 13px; + color: var(--text-secondary, #aaa); +} + +.smart-result-footer { + padding: 12px 20px; + border-top: 1px solid rgba(255, 255, 255, 0.08); + display: flex; + justify-content: center; +} + +.smart-result-footer .confirm-btn.ok { + min-width: 120px; + padding: 8px 24px; + border-radius: 8px; + font-size: 14px; +} diff --git a/stock-html/static/css/responsive.css b/stock-html/static/css/responsive.css new file mode 100644 index 0000000..bb4b9cb --- /dev/null +++ b/stock-html/static/css/responsive.css @@ -0,0 +1,237 @@ +/* ═══════════════════════════════════════ + responsive.css - PC / 桌面端适配 + 手机端保持现有样式不变 + 断点: 768px (PC 及以上应用以下样式) + ═══════════════════════════════════════ */ + +@media (min-width: 768px) { + /* ===== 主容器 ===== */ + .container { + max-width: 880px; + padding: 28px 32px; + padding-top: max(28px, env(safe-area-inset-top)); + } + + /* ===== 标题栏 ===== */ + .title-bar { + flex-direction: row; + align-items: center; + margin-bottom: 24px; + } + + .title-bar h1 { + font-size: 22px; + } + + .main-title-date { + font-size: 15px; + } + + .current-model { + margin-top: 6px; + font-size: 13px; + } + + /* ===== 导航标签 ===== */ + .nav-tabs { + margin-bottom: 24px; + padding: 6px; + } + + .nav-tabs-compact .nav-tab { + padding: 12px 20px; + font-size: 14px; + } + + .sub-tabs { + margin-bottom: 20px; + padding: 4px; + } + + .sub-tab { + padding: 12px 18px; + font-size: 14px; + } + + /* ===== 提醒汇总区 ===== */ + .alerts-summary.top-summary { + padding: 16px 24px; + gap: 12px 16px; + } + + .alerts-summary.top-summary .summary-label { + font-size: 12px; + } + + .alerts-summary.top-summary .summary-value { + font-size: 20px; + } + + /* ===== 卡片与区块 ===== */ + .card, + .input-section, + .scan-results { + padding: 24px; + border-radius: 18px; + } + + .recommendation-card { + padding: 28px; + border-radius: 20px; + } + + .signal-details { + grid-template-columns: repeat(4, 1fr); + gap: 16px; + } + + .signal-detail-item .value { + font-size: 20px; + } + + /* ===== 图表 ===== */ + .chart-container { + height: 240px; + } + + /* ===== 数据表格 ===== */ + .data-table { + font-size: 13px; + } + + .data-table th, + .data-table td { + padding: 14px 12px; + } + + /* ===== 汇总网格 ===== */ + .summary-grid { + grid-template-columns: repeat(4, 1fr); + gap: 16px; + } + + .summary-item { + padding: 20px; + } + + .summary-item .value { + font-size: 22px; + } + + /* ===== 弹窗 / 模态框 ===== */ + .login-modal { + max-width: 420px; + } + + .confirm-dialog { + min-width: 360px; + max-width: 480px; + } + + .smart-result-modal { + max-width: 540px; + } + + /* ===== 扫描控制 ===== */ + .scan-control { + gap: 14px; + } + + .scan-source-select { + flex: 0 0 180px; + } + + .scan-btn { + padding: 16px 32px; + font-size: 15px; + } + + /* ===== 扫描结果项 ===== */ + .scan-item { + padding: 12px 0; + } + + .scan-code { + font-size: 14px; + min-width: 60px; + } + + .scan-name { + font-size: 13px; + } + + .scan-price, + .scan-rate { + font-size: 14px; + } + + /* ===== 按钮 ===== */ + .analyze-btn { + height: 48px; + font-size: 15px; + } + + /* ===== 技术信号 ===== */ + .tech-result-card { + padding: 20px; + } + + .tech-result-header h4 { + font-size: 16px; + } + + /* ===== 模拟交易 ===== */ + .sim-stats-card { + padding: 20px; + } + + .sim-stats-header h4 { + font-size: 18px; + } + + .sim-stats-grid { + gap: 12px; + } + + .sim-stat-value { + font-size: 15px; + } + + .sim-stat-label { + font-size: 11px; + } + + /* ===== 信号排名 ===== */ + .signal-rank-item { + grid-template-columns: 28px 80px 48px 1fr; + padding: 8px 12px; + font-size: 13px; + } + + /* ===== 输入组 ===== */ + .input-group label { + font-size: 13px; + } + + .input-group input { + padding: 16px 18px; + font-size: 15px; + } + + /* ===== Toast ===== */ + .toast-container { + max-width: 420px; + } +} + +/* 更大屏幕 (1200px+) 进一步放宽 */ +@media (min-width: 1200px) { + .container { + max-width: 1000px; + padding: 32px 40px; + } + + .signal-details { + grid-template-columns: repeat(4, 1fr); + } +} diff --git a/stock-html/static/css/scan.css b/stock-html/static/css/scan.css new file mode 100644 index 0000000..a1766c8 --- /dev/null +++ b/stock-html/static/css/scan.css @@ -0,0 +1,1018 @@ +/* ═══════════════════════════════════════ + scan.css - Scan cards, full scan, bull stocks + Lines: 940 + ═══════════════════════════════════════ */ + +/* 批量扫描结果卡片 */ +/* ── 批量扫描卡片 ── */ +.batch-results-header { + display: flex; + align-items: center; + justify-content: space-between; +} +.tech-batch-card { + position: relative; + background: rgba(255,255,255,0.03); + border: 1px solid rgba(255,255,255,0.07); + border-radius: 14px; + margin-bottom: 12px; + overflow: hidden; + transition: transform 0.18s, box-shadow 0.18s, border-color 0.18s; +} +.tech-batch-card:hover { + border-color: rgba(255,255,255,0.14); + transform: translateY(-1px); + box-shadow: 0 4px 20px rgba(0,0,0,0.25); +} +.tech-batch-card.batch-has-signal { + background: rgba(255,255,255,0.045); +} + +/* 顶部渐变色条 */ +.batch-accent { + height: 3px; + width: 100%; + opacity: 0.85; +} + +/* 卡片头部 */ +.batch-card-header { + display: flex; + align-items: center; + padding: 12px 16px 6px; + cursor: pointer; + gap: 10px; + transition: background 0.15s; +} +.batch-card-header:hover { + background: rgba(255,255,255,0.03); +} +.batch-header-left { + display: flex; + align-items: baseline; + gap: 8px; + flex: 1; + min-width: 0; +} +.batch-card-header .batch-code { + font-weight: 700; + color: #fff; + font-size: 15px; + letter-spacing: 0.5px; + font-variant-numeric: tabular-nums; +} +.batch-card-header .batch-name { + color: var(--text-secondary); + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* 价格涨幅 */ +.batch-price { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + font-variant-numeric: tabular-nums; +} +.batch-change { + font-size: 12px; + font-weight: 600; + font-variant-numeric: tabular-nums; +} +.batch-change.up { color: #ff4444; } +.batch-change.down { color: #00c853; } + +/* 信号计数徽章 */ +.batch-count-badge { + flex-shrink: 0; + font-size: 13px; + font-weight: 700; + min-width: 36px; + text-align: center; + padding: 2px 10px; + border-radius: 10px; + color: #4fc3f7; + background: rgba(79,195,247,0.10); +} +.batch-count-badge small { font-size: 10px; font-weight: 400; opacity: 0.6; } +.batch-count-badge.warm { + color: #00c853; + background: rgba(0,200,83,0.12); +} +.batch-count-badge.hot { + color: #ff6b35; + background: rgba(255,107,53,0.14); +} +.batch-count-badge.empty { + color: var(--text-muted); + background: rgba(255,255,255,0.04); + font-weight: 400; +} + +/* 已触发信号标签行 */ +.batch-tags-row { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 4px 16px 2px; +} +.batch-sig-pill { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + font-weight: 600; + padding: 3px 10px; + border-radius: 14px; + letter-spacing: 0.2px; + transition: opacity 0.15s; +} +.batch-sig-pill .pill-icon { + font-style: normal; + font-size: 10px; + opacity: 0.9; +} +.pill-s85 { color: #ff6666; background: rgba(255,68,68,0.12); } +.pill-s80 { color: #ff8a50; background: rgba(255,107,53,0.12); } +.pill-s75 { color: #66bb6a; background: rgba(76,175,80,0.12); } +.pill-s70 { color: #64b5f6; background: rgba(33,150,243,0.12); } +.pill-s65 { color: #ce93d8; background: rgba(156,39,176,0.10); } +.pill-s60 { color: #ffb74d; background: rgba(255,152,0,0.12); } +.pill-s55 { color: #90a4ae; background: rgba(96,125,139,0.12); } + +/* 描述区域 */ +.batch-desc-area { + padding: 6px 16px 4px; +} +.batch-desc-row { + display: flex; + align-items: flex-start; + gap: 6px; + padding: 2px 0; + line-height: 1.45; +} +.desc-dot { + width: 5px; + height: 5px; + border-radius: 50%; + flex-shrink: 0; + margin-top: 6px; +} +.dot-s85 { background: #ff4444; } +.dot-s80 { background: #ff6b35; } +.dot-s75 { background: #4CAF50; } +.dot-s70 { background: #2196F3; } +.dot-s65 { background: #9C27B0; } +.dot-s60 { background: #FF9800; } +.dot-s55 { background: #607D8B; } +.desc-text { + font-size: 11.5px; + color: var(--text-secondary); + line-height: 1.45; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; +} + +/* 批量扫描 - 综合推荐 */ +.batch-rec-bar { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + margin-top: 2px; + border-top: 1px solid rgba(255,255,255,0.04); +} +.batch-rec-tag { + font-size: 12px; + font-weight: 700; + padding: 2px 10px; + border-radius: 10px; + flex-shrink: 0; + letter-spacing: 0.5px; +} +.batch-rec-tag.rec-buy, +.batch-rec-tag.rec-add { + background: rgba(0,206,158,0.18); + color: #00ce9e; +} +.batch-rec-tag.rec-sell { + background: rgba(255,68,68,0.18); + color: #ff4444; +} +.batch-rec-tag.rec-watch, +.batch-rec-tag.rec-hold { + background: rgba(100,149,237,0.18); + color: #6495ed; +} +.batch-rec-reason { + font-size: 12px; + color: rgba(255,255,255,0.55); + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.batch-rec-rate { + font-size: 11px; + font-weight: 600; + color: rgba(255,255,255,0.4); + flex-shrink: 0; +} + +/* 持仓说明 */ +.batch-holding-note, +.scan-holding-note, +.strategy-holding-note { + font-size: 11.5px; + color: #ff9800; + background: rgba(255,152,0,0.08); + padding: 4px 12px; + margin: 4px 16px 0; + border-radius: 6px; + line-height: 1.4; +} +.scan-holding-note { + margin: 4px 10px 6px; +} +.strategy-holding-note { + margin: 2px 0 4px 0; + padding: 3px 10px; + font-size: 11px; +} + +/* 未触发行 */ +.batch-muted-row { + display: flex; + flex-wrap: wrap; + gap: 2px 10px; + padding: 6px 16px 12px; + border-top: 1px solid rgba(255,255,255,0.04); + margin-top: 6px; +} +.batch-muted-name { + font-size: 11px; + color: rgba(255,255,255,0.22); + letter-spacing: 0.2px; +} +.batch-muted-name + .batch-muted-name::before { + content: '·'; + margin-right: 10px; + color: rgba(255,255,255,0.12); +} + +/* 兼容旧类名 */ +.ss-dot-sm { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} +.ss-dot-sm.s85 { background: #ff4444; } +.ss-dot-sm.s80 { background: #ff6b35; } +.ss-dot-sm.s75 { background: #4CAF50; } +.ss-dot-sm.s70 { background: #2196F3; } +.ss-dot-sm.s65 { background: #9C27B0; } +.ss-dot-sm.s60 { background: #FF9800; } +.ss-dot-sm.s55 { background: #607D8B; } + +/* 全量扫描触发区域 */ +.full-scan-trigger-section { + margin-top: 16px; + padding: 14px; + background: var(--card-bg, #1e1e2e); + border-radius: 12px; + border: 1px solid rgba(108, 92, 231, 0.2); +} +.scan-loading-hint { + text-align: center; + padding: 20px 0; + color: var(--text-muted); + font-size: 13px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} +.loading-spinner { + display: inline-block; + width: 16px; + height: 16px; + border: 2px solid rgba(108, 92, 231, 0.3); + border-top-color: #6c5ce7; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} +@keyframes spin { + to { transform: rotate(360deg); } +} +.scan-trigger-header { + display: flex; + align-items: center; + margin-bottom: 12px; + gap: 8px; +} +.scan-action-btn { + flex: 1; + padding: 8px 0; + border: 1px solid rgba(108, 92, 231, 0.4); + border-radius: 8px; + background: rgba(108, 92, 231, 0.15); + color: #fff; + font-size: 13px; + cursor: pointer; + transition: all 0.2s; + text-align: center; +} +.scan-action-btn:hover { + background: rgba(108, 92, 231, 0.35); +} +.scan-action-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.scan-progress-bar { margin-bottom: 12px; } +.progress-info { + display: flex; + justify-content: space-between; + font-size: 12px; + color: var(--text-secondary); + margin-bottom: 6px; +} +.progress-track { + height: 6px; + background: rgba(255,255,255,0.06); + border-radius: 3px; + overflow: hidden; +} +.progress-fill { + height: 100%; + background: linear-gradient(90deg, #6c5ce7, #00ce9e); + border-radius: 3px; + transition: width 0.5s ease; +} +.progress-done { + margin-top: 8px; + font-size: 13px; + color: #00ce9e; + font-weight: 600; +} + +.scan-quick-actions { + display: flex; + gap: 8px; + margin-bottom: 12px; +} +.quick-btn { + padding: 6px 14px; + border-radius: 8px; + border: 1px solid rgba(255,255,255,0.1); + background: rgba(255,255,255,0.04); + color: var(--text-secondary); + font-size: 12px; + cursor: pointer; + transition: all 0.2s; +} +.quick-btn:hover { + background: rgba(255,255,255,0.08); + color: var(--text-primary); +} + +/* 策略建议CSS已移除(功能合并到全景扫描) */ + +.section-close { + width: 24px; height: 24px; + border-radius: 50%; + border: none; + background: rgba(255,255,255,0.08); + color: var(--text-muted); + font-size: 12px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + flex-shrink: 0; +} +.section-close:hover { + background: rgba(255, 107, 107, 0.2); + color: #ff6b6b; +} +.section-close.inline { + display: inline-flex; +} + +/* 全量扫描样式 */ +.full-scan-section { + margin-top: 16px; + padding: 16px; + background: var(--card-bg, #1e1e2e); + border-radius: 12px; + border: 1px solid rgba(108, 92, 231, 0.3); +} +.full-scan-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 4px; +} +.full-scan-trigger-section > .scan-summary-section { + margin-top: 12px; +} +.scan-summary-section { + margin-bottom: 8px; +} +.scan-summary-header { + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; + padding: 6px 0; + user-select: none; +} +.scan-summary-actions { + display: flex; + align-items: center; + gap: 6px; +} +.section-close.inline { + padding: 2px 8px; + font-size: 12px; + min-width: auto; +} +.scan-summary-text { + font-size: 12px; + color: var(--text-secondary); +} +.scan-summary-text .highlight { color: #ffd93d; } +.scan-summary-toggle { + font-size: 10px; + color: var(--text-muted); + transition: transform 0.2s; +} +.scan-summary-body { + padding-top: 4px; +} +.scan-time-info { + font-size: 11px; + color: var(--text-muted); + opacity: 0.7; + margin-bottom: 8px; +} + +.signal-dist-bar { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 10px; +} +.dist-tag { + padding: 4px 10px; + border-radius: 12px; + font-size: 12px; + background: rgba(255,255,255,0.05); + color: var(--text-secondary); + cursor: pointer; + transition: all 0.2s; +} +.dist-tag:hover, .dist-tag.active { + background: rgba(108, 92, 231, 0.3); + color: #fff; +} +.dist-tag b { margin-left: 4px; color: #ffd93d; } + +.scan-filters { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 12px; +} +.scan-filter-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} +.scan-filter-label { + font-size: 11px; + color: var(--text-muted); + min-width: 28px; + margin-right: 2px; +} +.filter-tag { + padding: 6px 12px; + border-radius: 10px; + font-size: 12px; + background: rgba(255,255,255,0.05); + color: var(--text-muted); + cursor: pointer; + transition: background 0.2s, color 0.2s; +} +.filter-tag:hover { + background: rgba(255,255,255,0.1); + color: var(--text-secondary); +} +.filter-tag.active { + background: var(--primary, #6c5ce7); + color: #fff; +} +.filter-tag.rec-all.active { background: var(--primary, #6c5ce7); color: #fff; } +.filter-tag.rec-buy.active { background: rgba(0, 206, 158, 0.35); color: #00ce9e; } +.filter-tag.rec-sell.active { background: rgba(255, 68, 68, 0.35); color: #ff4444; } +.filter-tag.rec-hold.active { background: rgba(255, 217, 61, 0.35); color: #ffd93d; } +.filter-tag.rec-watch.active { background: rgba(100, 149, 237, 0.35); color: #6495ed; } + +.full-scan-list { + display: flex; + flex-direction: column; + gap: 6px; + max-height: 500px; + overflow-y: auto; +} +.scan-empty-hint { + text-align: center; + color: var(--text-muted); + padding: 24px 16px; + font-size: 13px; +} +.scan-result-card { + padding: 10px 12px; + background: rgba(255,255,255,0.03); + border-radius: 8px; + transition: all 0.2s; +} +.scan-result-card.has-signal { + background: rgba(255, 217, 61, 0.05); +} +.scan-result-card.is-watched { + /* 不再使用左边线颜色,仅保留星标等标识 */ +} +.scan-result-card:hover { background: rgba(255,255,255,0.06); } +.scan-watched-icon { + color: #f59e0b; + font-size: 16px; + flex-shrink: 0; +} +.scan-card-header { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + margin-bottom: 4px; +} +.scan-code { + font-family: 'Courier New', monospace; + font-size: 13px; + color: var(--primary, #6c5ce7); + font-weight: 600; +} +.scan-name { font-size: 13px; color: var(--text-primary); } +.scan-price { + font-size: 12px; + color: #ffd700; +} +.scan-change { + font-size: 11px; + margin-left: 4px; + font-weight: 500; +} +.scan-change.up { color: #ff4757; } +.scan-change.down { color: #00ce9e; } +.scan-recommend { + font-size: 11px; + font-weight: 600; + padding: 1px 6px; + border-radius: 3px; + margin-left: 4px; +} +.scan-recommend.buy { background: rgba(0, 206, 158, 0.2); color: #00ce9e; } +.scan-recommend.sell { background: rgba(255, 68, 68, 0.2); color: #ff4444; } +.scan-recommend.hold { background: rgba(255, 217, 61, 0.2); color: #ffd93d; } +.scan-recommend.watch-active { background: rgba(100, 149, 237, 0.2); color: #6495ed; } +.scan-recommend.watch { background: rgba(255, 255, 255, 0.08); color: var(--text-muted); } +.scan-card-info { + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; + margin-bottom: 4px; +} +.scan-info-left { display: flex; align-items: center; gap: 6px; } +.scan-triggered { + font-size: 12px; + color: #ffd93d; + font-weight: 600; +} +.scan-no-signal { + font-size: 12px; + color: var(--text-muted); +} +.scan-signals { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 4px; +} +.scan-signal-tag { + padding: 2px 8px; + border-radius: 8px; + font-size: 11px; +} +.scan-signal-tag.active { + background: rgba(0, 206, 158, 0.15); + color: #00ce9e; +} +.scan-signal-tag.inactive { + background: rgba(255,255,255,0.03); + color: var(--text-muted); +} + +.scan-pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 16px; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid rgba(255,255,255,0.05); +} +.scan-pagination button { + padding: 6px 16px; + border-radius: 8px; + border: 1px solid rgba(255,255,255,0.1); + background: rgba(255,255,255,0.05); + color: var(--text-primary); + cursor: pointer; + font-size: 12px; +} +.scan-pagination button:disabled { opacity: 0.3; cursor: not-allowed; } +.scan-pagination span { font-size: 13px; color: var(--text-secondary); } + +.admin-link-btn { + display: block; + text-align: center; + margin-top: 12px; + padding: 12px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + color: rgba(255, 255, 255, 0.7); + text-decoration: none; + font-size: 14px; + font-weight: 500; + transition: all 0.2s; +} +.admin-link-btn:hover { + background: rgba(255, 255, 255, 0.1); + color: #fff; +} + + +/* ══════════ 找牛股页面 ══════════ */ +.bull-stocks-page { padding: 12px 0; } +.bull-flow-card { + background: var(--bg-glass); + border: 1px solid var(--border-glass); + border-radius: 14px; + padding: 16px; + margin-bottom: 16px; +} +.bull-flow-title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + text-align: center; + margin-bottom: 14px; +} +.bull-flow-steps { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + flex-wrap: wrap; +} +.bull-step { + display: flex; + flex-direction: column; + align-items: center; + padding: 10px 12px; + border-radius: 12px; + cursor: pointer; + transition: all 0.25s; + background: rgba(255,255,255,0.04); + border: 1px solid transparent; + min-width: 64px; +} +.bull-step:hover { background: rgba(255,255,255,0.08); } +.bull-step.active { + border-color: rgba(255,255,255,0.2); + background: rgba(255,255,255,0.1); + transform: translateY(-2px); + box-shadow: 0 4px 16px rgba(0,0,0,0.3); +} +.bull-step .step-icon { font-size: 22px; margin-bottom: 4px; } +.bull-step .step-label { + font-size: 12px; + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; +} +.bull-step .step-action { + font-size: 10px; + padding: 1px 6px; + border-radius: 8px; + margin-top: 3px; +} +.bull-step .step-count { + font-size: 11px; + font-weight: 700; + margin-top: 4px; + color: var(--text-primary); + background: rgba(255,255,255,0.1); + padding: 1px 8px; + border-radius: 10px; +} +.step-divergence .step-action { background: rgba(100, 149, 237, 0.25); color: #6495ed; } +.step-divergence.active { border-color: rgba(100, 149, 237, 0.4); } +.step-dragon .step-action { background: rgba(0, 206, 158, 0.25); color: #00ce9e; } +.step-dragon.active { border-color: rgba(0, 206, 158, 0.4); } +.step-true-dragon .step-action { background: rgba(0, 200, 83, 0.25); color: #00c853; } +.step-true-dragon.active { border-color: rgba(0, 200, 83, 0.4); } +.step-main-wave .step-action { background: rgba(255, 68, 68, 0.25); color: #ff6b6b; } +.step-main-wave.active { border-color: rgba(255, 68, 68, 0.4); } +.bull-flow-arrow { + color: var(--text-muted); + font-size: 16px; + margin: 0 2px; +} +.bull-flow-hint { + text-align: center; + font-size: 11px; + color: var(--text-muted); + margin-top: 12px; +} + +/* 找牛股流程 - 手机端防溢出 */ +@media (max-width: 520px) { + .bull-flow-card { overflow-x: hidden; } + .bull-flow-steps { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + flex-wrap: nowrap; + justify-content: flex-start; + padding: 4px 0; + margin: 0 -4px; + } + .bull-flow-steps .bull-step { + flex: 0 0 auto; + min-width: 56px; + padding: 8px 6px; + } + .bull-flow-steps .bull-step .step-label { + font-size: 10px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 48px; + } + .bull-flow-steps .bull-flow-arrow { + flex-shrink: 0; + } +} +.bull-loading { + text-align: center; + padding: 40px 16px; + color: var(--text-secondary); + font-size: 14px; +} +.bull-loading .loading-dot { + display: inline-block; + width: 8px; height: 8px; + border-radius: 50%; + background: var(--text-secondary); + animation: pulse 1.5s infinite; + margin-right: 6px; +} +.bull-stage-section { margin-bottom: 16px; } +.bull-stage-header { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 14px; + background: var(--bg-glass); + border: 1px solid var(--border-glass); + border-radius: 12px 12px 0 0; +} +.bull-stage-icon { font-size: 20px; } +.bull-stage-name { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + flex: 1; +} +.bull-stage-count { + font-size: 12px; + color: var(--text-muted); + background: rgba(255,255,255,0.08); + padding: 3px 10px; + border-radius: 10px; +} +.bull-stock-list { + display: flex; + flex-direction: column; + max-height: 500px; + overflow-y: auto; + background: var(--bg-glass); + border: 1px solid var(--border-glass); + border-top: none; + border-radius: 0 0 12px 12px; +} +.bull-stock-card { + display: flex; + align-items: center; + padding: 12px 14px; + cursor: pointer; + transition: all 0.2s; + border-bottom: 1px solid rgba(255,255,255,0.04); +} +.bull-stock-card:last-child { border-bottom: none; } +.bull-stock-card:hover { background: rgba(255,255,255,0.06); } +.bull-stock-card.stage-bottom_divergence { border-left: 3px solid rgba(100, 149, 237, 0.5); } +.bull-stock-card.stage-dragon_head { border-left: 3px solid rgba(0, 206, 158, 0.5); } +.bull-stock-card.stage-true_dragon { border-left: 3px solid rgba(0, 200, 83, 0.5); } +.bull-stock-card.stage-main_rising_wave { border-left: 3px solid rgba(255, 68, 68, 0.5); } +.bull-stock-card.stage-other_signals { border-left: 3px solid rgba(255,255,255,0.15); } +.bull-card-left { min-width: 90px; } +.bull-card-code { + font-size: 13px; + font-weight: 700; + color: var(--text-primary); +} +.bull-card-name { + font-size: 11px; + color: var(--text-muted); + margin-top: 2px; +} +.bull-card-center { + flex: 1; + min-width: 0; + padding: 0 12px; +} +.bull-card-desc { + font-size: 12px; + color: var(--text-secondary); + line-height: 1.4; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.bull-card-right { text-align: right; flex-shrink: 0; } +.bull-card-rate { display: flex; align-items: baseline; gap: 1px; } +.rate-value { + font-size: 18px; + font-weight: 700; + color: var(--success); +} +.rate-unit { font-size: 11px; color: var(--text-muted); } +.bull-other-section { margin-top: 12px; } +.bull-other-header { + padding: 10px 14px; + background: var(--bg-glass); + border: 1px solid var(--border-glass); + border-radius: 10px; + cursor: pointer; + font-size: 12px; + color: var(--text-muted); + transition: all 0.2s; +} +.bull-other-header:hover { + background: rgba(255,255,255,0.06); + color: var(--text-secondary); +} +.bull-empty { + text-align: center; + padding: 30px 16px; + color: var(--text-muted); + font-size: 13px; + background: var(--bg-glass); + border: 1px solid var(--border-glass); + border-top: none; + border-radius: 0 0 12px 12px; +} +.bull-empty-tip { + text-align: center; + padding: 40px 16px; +} +.bull-empty-tip .empty-icon { font-size: 48px; margin-bottom: 12px; } +.bull-empty-tip p { + color: var(--text-secondary); + font-size: 14px; + margin: 4px 0; +} +.bull-empty-tip .sub-tip { + font-size: 12px; + color: var(--text-muted); +} +.bull-empty-tip .scan-action-btn { margin-top: 16px; } + +/* 阶段左侧装饰(数字阶段) */ +.bull-stock-card.stage-s1 { border-left: 3px solid rgba(33, 150, 243, 0.5); } +.bull-stock-card.stage-s2 { border-left: 3px solid rgba(76, 175, 80, 0.5); } +.bull-stock-card.stage-s3 { border-left: 3px solid rgba(255, 152, 0, 0.5); } +.bull-stock-card.stage-s4 { border-left: 3px solid rgba(244, 67, 54, 0.5); } +.bull-stock-card.stage-s5 { border-left: 3px solid rgba(156, 39, 176, 0.5); } + +/* 回调补涨阶段样式 */ +.step-rebound .step-action { background: rgba(156, 39, 176, 0.25); color: #ce93d8; } +.step-rebound.active { border-color: rgba(156, 39, 176, 0.4); } + +/* 信号标签列表 */ +.bull-card-signals { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-bottom: 4px; +} +.bull-signal-tag { + font-size: 10px; + padding: 1px 6px; + border-radius: 6px; + background: rgba(0, 206, 158, 0.12); + color: #00ce9e; + white-space: nowrap; +} +/* 推荐标签 + 下一信号 */ +.bull-card-recommend { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} +.rec-tag { + font-size: 10px; + padding: 1px 6px; + border-radius: 6px; + font-weight: 600; + white-space: nowrap; +} +.rec-buy { background: rgba(244, 67, 54, 0.15); color: #f44336; } +.rec-sell { background: rgba(33, 150, 243, 0.15); color: #2196F3; } +.rec-add { background: rgba(255, 152, 0, 0.15); color: #FF9800; } +.rec-hold { background: rgba(76, 175, 80, 0.15); color: #4CAF50; } +.rec-watch { background: rgba(156, 39, 176, 0.15); color: #ce93d8; } +.rec-observe { background: rgba(158, 158, 158, 0.15); color: #9E9E9E; } +.rec-reason { + font-size: 10px; + color: var(--text-muted); + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +/* 价格和涨跌幅 */ +.bull-card-price { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + text-align: right; +} +.bull-card-change { + font-size: 11px; + text-align: right; + margin-top: 2px; +} +.bull-card-change.up { color: #ff4444; } +.bull-card-change.down { color: #00c853; } +/* 迷你进度条 */ +.bull-card-progress { + display: flex; + align-items: center; + gap: 4px; + margin-top: 4px; + justify-content: flex-end; +} +.mini-progress-bar { + width: 40px; + height: 3px; + background: rgba(255,255,255,0.1); + border-radius: 2px; + overflow: hidden; +} +.mini-progress-fill { + height: 100%; + border-radius: 2px; + background: linear-gradient(90deg, #2196F3, #4CAF50); + transition: width 0.3s; +} +.bull-card-progress .progress-label { + font-size: 10px; + color: var(--text-muted); + min-width: 28px; + text-align: right; +} diff --git a/stock-html/static/icon.svg b/stock-html/static/icon.svg new file mode 100644 index 0000000..7d438ff --- /dev/null +++ b/stock-html/static/icon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/stock-html/static/js/app.js b/stock-html/static/js/app.js new file mode 100644 index 0000000..b4da947 --- /dev/null +++ b/stock-html/static/js/app.js @@ -0,0 +1,2835 @@ + const { createApp } = Vue; + + const ALERT_CACHE_VERSION = 5; + + // 配置axios携带credentials(支持跨域session) + axios.defaults.withCredentials = true; + + createApp({ + data() { + // 动态计算上一年1月1日 + const lastYear = new Date().getFullYear() - 1; + const defaultStartDate = `${lastYear}-01-01`; + + return { + // 分析页面 + stockCode: '', + startDate: defaultStartDate, // 动态设置为上一年1月1日 + endDate: '', + loading: false, + error: null, + result: null, + + theoryExpanded: false, + periods: ['当日', '未来1日', '未来2日', '未来3日', '未来4日', '未来5日'], + superChart: null, + mainChart: null, + latestData: {}, + expandedSections: { + charts: false, + table: false, + summary: false + }, + searchHistory: [], + showHistory: false, + clockTick: Date.now(), + + // 全局提示模态框 + toastMessage: '', + toastType: 'info', // info, success, error, warning + toastVisible: false, + + // 确认对话框 + confirmVisible: false, + confirmMessage: '', + confirmCallback: null, + + // 用户登录 + currentUser: null, + checkingAuth: true, // 正在检查登录状态 + showLoginModal: false, + isRegister: false, + loginForm: { email: '', password: '' }, + loginError: '', + loginLoading: false, + + // 修改密码 + showPasswordModal: false, + passwordForm: { oldPassword: '', newPassword: '', confirmPassword: '' }, + passwordLoading: false, + passwordError: '', + passwordSuccess: '', + + // 导航 + activeTab: 'alerts', + + // 提醒页面 + alertsLoading: false, + alertsProgress: { current: 0, total: 0 }, // 分析进度 + stockAlerts: [], // 所有股票的分析结果 + alertSubTab: 'holding', // 'holding' 或 'watching' + showAddWatch: false, // 显示添加关注输入框 + newWatchCode: '', // 新关注股票代码 + + // 今日信号 + todaySignalStock: '', + todaySignalData: null, + + // 技术信号 + techSignalCode: '', + techSignalResult: null, + techBatchResults: [], + techLoading: false, + // 全量扫描 + fullScanResults: [], + fullScanStatus: null, + fullScanLoading: false, + fullScanPage: 1, + fullScanTotalPages: 1, + fullScanFilter: 'all', + fullScanFilterTypes: [], + fullScanFilterRecommend: '买入', // 扫描结果打开时缺省显示买入列表;all|买入|加仓|卖出|持有|关注|观察|观望 + scanSummaryCollapsed: false, + fullScanSignalDist: [], + + // 找牛股 + bullStocksData: null, // { stages: {1:[...], 2:[...]}, summary: {}, stage_info: [...] } + bullStocksLoading: false, + bullActiveStage: 2, // 默认显示阶段2=龙抬头(最佳买点) + + // 交易记录页面 + trades: [], + tradeStats: null, + availableCash: 0, // 可用资金 + editingCash: false, // 是否在编辑可用资金 + cashInputValue: '', // 编辑时的输入值 + holdingPositions: {}, // 持仓信息:{ 股票代码: { quantity, cost, currentPrice, name } } + priceRefreshing: false, // 刷新价格状态 + stopLossAlerts: [], // 止损预警列表 + stopLossAlertsExpanded: true, // 止损预警区域是否展开 + expandedTradeGroups: {}, // 展开的交易组:{ 股票代码: true/false } + tradeViewType: 'holding', // 交易视图类型:'holding' 或 'cleared' + showFundamentalModal: false, // 基本面弹窗 + fundamentalLoading: false, // 加载状态 + fundamentalData: null, // 基本面数据 + aiAnalyzing: false, // AI分析中 + aiAnalysisResult: '', // AI分析结果(基本面弹窗用) + aiReasoningResult: '', // AI思考过程(基本面弹窗用) + aiReasoningExpanded: false, // AI思考过程是否展开 + klineChart: null, // K线图实例 + klinePeriod: 'monthly', // K线周期:weekly, monthly, quarterly, yearly + klineLoading: false, // K线加载状态 + showTradeForm: false, + editingTradeId: null, + selectedStockFromHistory: '', + selectedStockSignal: { type: '', icon: '', title: '', description: '' }, + analysisCache: {}, // 缓存每个股票的分析结果 + + // 分析页子导航 + analysisSubTab: 'scan', // 'scan' 或 'sim' + + // 模拟交易相关 + simStats: {}, // 模拟交易统计 + simPositions: [], // 模拟持仓 + simTrades: [], // 模拟交易记录 + simAutoTrading: false, // 自动交易执行中 + simPriceRefreshing: false, // 模拟持仓刷新现价中 + simRuleExpanded: false, // 交易规则是否展开 + + // 智能交易引擎 + smartAlgoConfig: null, // 当前算法配置 + smartAlgoIsDefault: true, // 是否默认配置 + smartTemplates: [], // 算法模板列表 + smartEngineStatus: null, // 引擎状态 + smartSignals: [], // 信号日志 + smartPositionMeta: [], // 持仓元数据 + smartConfigExpanded: false, // 算法配置展开 + smartSignalExpanded: false, // 信号日志展开 + smartConfigEditing: false, // 正在编辑配置 + todayTradesExpanded: false, // 今日交易展开 + simPositionsExpanded: false, // 当前持仓展开 + simTradesExpanded: false, // 交易记录展开 + + // 智能执行结果模态框 + smartResultVisible: false, + smartResultData: null, // { algo, type, title, reasons, results, fees, summary } + compareExpanded: false, // 模拟vs实盘对比展开 + + newTrade: { + stock_code: '', + stock_name: '', + trade_type: 'buy', + price: '', + quantity: '', + trade_date: '', + reason: '', + result: 'pending', + profit_amount: '', + stop_loss_price: '', + notes: '' + } + }; + }, + computed: { + // 今日日期 + todayDate() { + void this.clockTick; + const now = new Date(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + return `${month}-${day}`; + }, + // 市场状态:开市/闭市(clockTick驱动自动刷新) + marketStatus() { + void this.clockTick; + const now = new Date(); + const day = now.getDay(); // 0=周日, 1-5=周一到周五, 6=周六 + const hour = now.getHours(); + const minute = now.getMinutes(); + const time = hour * 100 + minute; // 如 1430 = 14:30 + + // 周末闭市 + if (day === 0 || day === 6) { + return { isOpen: false, text: '闭市' }; + } + + // 交易时间:9:30-11:30, 13:00-15:00 + if ((time >= 930 && time <= 1130) || (time >= 1300 && time <= 1500)) { + return { isOpen: true, text: '开市' }; + } + + return { isOpen: false, text: '闭市' }; + }, + // 数据最新日期(从API返回的stocks中获取) + dataLatestDate() { + if (this.stocks && this.stocks.length > 0) { + const dates = this.stocks.map(s => s.lastUpdateTime || s.date).filter(d => d); + if (dates.length > 0) { + const latestDate = dates.sort().reverse()[0]; + // 取前10个字符作为日期,格式化为 MM-DD + const dateStr = latestDate.substring(0, 10); + return dateStr.substring(5); + } + } + return ''; + }, + // 买入信号列表(仅关注股票,按推荐率排序) + buyAlerts() { + const watchCodes = this.searchHistory.map(h => h.code); + return this.stockAlerts + .filter(a => a.signalType === 'buy' && !this.holdingStocks.includes(a.code) && watchCodes.includes(a.code)) + .sort((a, b) => (b.recommendRate || 0) - (a.recommendRate || 0)); + }, + // 卖出信号列表(仅关注股票,按推荐率排序) + sellAlerts() { + const watchCodes = this.searchHistory.map(h => h.code); + return this.stockAlerts + .filter(a => a.signalType === 'sell' && !this.holdingStocks.includes(a.code) && watchCodes.includes(a.code)) + .sort((a, b) => (b.recommendRate || 0) - (a.recommendRate || 0)); + }, + // 观望列表(仅关注股票) + watchAlerts() { + const watchCodes = this.searchHistory.map(h => h.code); + return this.stockAlerts.filter(a => a.signalType === 'watch' && !this.holdingStocks.includes(a.code) && watchCodes.includes(a.code)); + }, + // 有效提醒数量(买入+卖出) + alertCount() { + return this.buyAlerts.length + this.sellAlerts.length; + }, + // 今日交易记录 + todayTrades() { + const today = new Date().toISOString().substring(0, 10); + return (this.simTrades || []).filter(t => t.trade_date === today); + }, + // 全景扫描结果按「推荐」筛选(按具体内容:全部/买入/加仓/卖出/持有/关注/观察/观望,并显示数量) + fullScanResultsFiltered() { + const r = this.fullScanFilterRecommend; + if (r === 'all') return this.fullScanResults; + return this.fullScanResults.filter(item => { + const text = (item.recommend_text != null) ? item.recommend_text : this.getScanRecommend(item).text; + return text === r; + }); + }, + // 推荐类型列表(含数量),顺序固定 + fullScanRecommendOptions() { + const counts = this.fullScanStatus?.recommend_counts || {}; + const hints = { '持有': '仅持仓且触达真龙时显示,建议继续持有' }; + const order = ['买入', '加仓', '卖出', '持有', '关注', '观察', '观望']; + return order.map(text => ({ text, count: counts[text] || 0, hint: hints[text] })); + }, + + // 持有的股票代码(从交易记录中获取买入但未卖出的) + holdingStocks() { + const buyStocks = {}; + const sellStocks = {}; + + // 统计每个股票的买入和卖出数量 + this.trades.forEach(trade => { + if (trade.trade_type === 'buy') { + buyStocks[trade.stock_code] = (buyStocks[trade.stock_code] || 0) + parseInt(trade.quantity || 0); + } else if (trade.trade_type === 'sell') { + sellStocks[trade.stock_code] = (sellStocks[trade.stock_code] || 0) + parseInt(trade.quantity || 0); + } + }); + + // 持有 = 买入数量 > 卖出数量 + return Object.keys(buyStocks).filter(code => + (buyStocks[code] || 0) > (sellStocks[code] || 0) + ); + }, + + // 关注的股票(在历史记录中但不在持有列表中) + watchingStocks() { + return this.searchHistory.filter(item => + !this.holdingStocks.includes(item.code) + ); + }, + + // 所有可选股票(关注列表 + 持有股票,用于交易记录表单) + allStocksForTrade() { + const allStocks = [...this.searchHistory]; + + // 添加持有的股票(如果不在关注列表中) + this.holdingStocks.forEach(code => { + if (!allStocks.some(item => item.code === code)) { + const trade = this.trades.find(t => t.stock_code === code); + allStocks.push({ + code: code, + name: trade?.stock_name || `股票${code}`, + isHolding: true // 标记为持有股票 + }); + } + }); + + // 给关注列表中已持有的股票也加上标记 + return allStocks.map(item => ({ + ...item, + isHolding: this.holdingStocks.includes(item.code) + })); + }, + + // 持有股票的提醒 + holdingAlerts() { + return this.stockAlerts + .filter(a => this.holdingStocks.includes(a.code)) + .sort((a, b) => (b.recommendRate || 0) - (a.recommendRate || 0)); + }, + + // 按股票分组、按时间倒序排序的交易记录 + groupedTrades() { + if (!this.trades || this.trades.length === 0) return []; + + // 按股票代码分组 + const groups = {}; + this.trades.forEach(trade => { + const code = trade.stock_code; + if (!groups[code]) { + groups[code] = { + code: code, + name: trade.stock_name || code, + trades: [], + latestDate: trade.trade_date, + realizedProfit: 0 // 已实现盈亏 + }; + } + groups[code].trades.push(trade); + // 记录该组最新的交易日期 + if (trade.trade_date > groups[code].latestDate) { + groups[code].latestDate = trade.trade_date; + } + }); + + // 计算每个组的已实现盈亏(使用平均成本法) + Object.values(groups).forEach(group => { + // 按交易日期和创建时间排序(从早到晚) + const sortedTrades = [...group.trades].sort((a, b) => { + if (a.trade_date !== b.trade_date) { + return a.trade_date.localeCompare(b.trade_date); + } + return (a.created_at || '').localeCompare(b.created_at || ''); + }); + + let holdingQty = 0; + let holdingCost = 0; + let realizedProfit = 0; + + sortedTrades.forEach(trade => { + const qty = parseInt(trade.quantity) || 0; + const price = parseFloat(trade.price) || 0; + + if (trade.trade_type === 'buy') { + holdingCost += qty * price; + holdingQty += qty; + } else if (trade.trade_type === 'sell' && holdingQty > 0) { + // 计算平均成本 + const avgCost = holdingCost / holdingQty; + // 已实现盈亏 = 卖出价 - 平均成本) × 卖出数量 + realizedProfit += (price - avgCost) * qty; + // 更新持仓 + holdingCost -= avgCost * qty; + holdingQty -= qty; + } + }); + + group.realizedProfit = realizedProfit; + }); + + // 每组内按时间倒序排序(显示用) + Object.values(groups).forEach(group => { + group.trades.sort((a, b) => { + // 先按日期倒序 + if (b.trade_date !== a.trade_date) { + return b.trade_date.localeCompare(a.trade_date); + } + // 同日期按创建时间倒序 + return (b.created_at || '').localeCompare(a.created_at || ''); + }); + }); + + // 组与组之间排序:先按是否持有(持有在前),再按最新交易日期倒序 + const holdingPositions = this.holdingPositions || {}; + return Object.values(groups).sort((a, b) => { + const aHolding = (holdingPositions[a.code]?.quantity || 0) > 0; + const bHolding = (holdingPositions[b.code]?.quantity || 0) > 0; + // 持有的排在前面 + if (aHolding !== bHolding) { + return bHolding ? 1 : -1; + } + // 同类型按日期倒序 + return b.latestDate.localeCompare(a.latestDate); + }); + }, + + // 根据视图类型过滤交易分组 + filteredGroupedTrades() { + if (!this.groupedTrades) return []; + const holdingPositions = this.holdingPositions || {}; + + if (this.tradeViewType === 'holding') { + // 持有中:显示当前持有股票数量 > 0 的 + return this.groupedTrades.filter(group => + (holdingPositions[group.code]?.quantity || 0) > 0 + ); + } else { + // 已清仓:显示当前持有股票数量 = 0 的 + return this.groupedTrades.filter(group => + (holdingPositions[group.code]?.quantity || 0) === 0 + ); + } + }, + + // 持有股票数量 + holdingGroupCount() { + if (!this.groupedTrades) return 0; + const holdingPositions = this.holdingPositions || {}; + return this.groupedTrades.filter(group => + (holdingPositions[group.code]?.quantity || 0) > 0 + ).length; + }, + + // 清仓股票数量 + clearedGroupCount() { + if (!this.groupedTrades) return 0; + const holdingPositions = this.holdingPositions || {}; + return this.groupedTrades.filter(group => + (holdingPositions[group.code]?.quantity || 0) === 0 + ).length; + }, + + tradingSignal() { + if (!this.result) return { type: '', icon: '', title: '', description: '' }; + + const pricePos = this.latestData.pricePosition || 50; + const superRatio = this.latestData.superRatio || 0; + const mainRatio = this.latestData.mainRatio || 0; + + // 判断买卖信号 + const isLow = pricePos <= 50; + const isHigh = pricePos > 50; + const isSuperInflow = superRatio >= 2; + const isSuperOutflow = superRatio <= -2; + const isMainInflow = mainRatio >= 2; + const isMainOutflow = mainRatio <= -2; + + // 买入信号:低位 + 资金流入 + if (isLow && (isSuperInflow || isMainInflow)) { + return { + type: 'buy', + icon: '✅', + title: '买入信号', + description: `价格处于${pricePos <= 30 ? '低位' : '中低位'}(${pricePos.toFixed(1)}%),${isSuperInflow ? '超大单' : '主力'}大额流入,建议买入,仓位30-50%,止损-5%` + }; + } + + // 卖出信号:高位 + 资金流出 + if (isHigh && (isSuperOutflow || isMainOutflow)) { + return { + type: 'sell', + icon: '🔴', + title: '卖出信号', + description: `价格处于${pricePos >= 70 ? '高位' : '中高位'}(${pricePos.toFixed(1)}%),${isSuperOutflow ? '超大单' : '主力'}大额流出,建议卖出或减仓,及时锁定利润` + }; + } + + // 观望信号 + let hint = ''; + if (isLow) { + hint = '价格在低位,可关注资金流入信号后买入'; + } else if (isHigh) { + hint = '价格在高位,可关注资金流出信号后卖出'; + } else { + hint = '当前无明确买卖信号,建议继续观察'; + } + + return { + type: '', + icon: '⏸️', + title: '观望信号', + description: hint + }; + } + }, + async mounted() { + setInterval(() => { this.clockTick = Date.now(); }, 30000); + + const today = new Date(); + this.endDate = today.toISOString().split('T')[0]; + + // 检查当前用户登录状态 + await this.checkCurrentUser(); + + // 未登录时不加载数据 + if (!this.currentUser) { + return; + } + + // 已登录,加载数据 + this.alertsLoading = true; + this.alertsProgress = { current: 0, total: 0 }; + + // 加载关注列表(从服务器) + await this.loadHistory(); + + // 先加载交易记录(用于计算持有股票) + await this.loadTrades(); + + // 自动分析关注的股票 + this.$nextTick(() => { + this.refreshAlerts(); + }); + }, + watch: {}, + methods: { + // ========== 用户认证 ========== + async checkCurrentUser() { + this.checkingAuth = true; + try { + const response = await axios.get('/api/me'); + if (response.data.success && response.data.user) { + this.currentUser = response.data.user; + console.log('已登录:', this.currentUser.username); + } + } catch (e) { + console.log('未登录'); + } finally { + this.checkingAuth = false; + } + }, + + async handleLogin() { + if (this.loginLoading) return; + + const { email, password } = this.loginForm; + if (!email || !password) { + this.loginError = '请输入邮箱和密码'; + return; + } + + // 验证邮箱格式 + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + this.loginError = '请输入有效的邮箱地址'; + return; + } + + if (this.isRegister && password.length < 6) { + this.loginError = '密码至少6位'; + return; + } + + this.loginError = ''; + this.loginLoading = true; + + try { + const url = this.isRegister ? '/api/register' : '/api/login'; + const response = await axios.post(url, { email, password }); + + if (response.data.success) { + this.currentUser = response.data.user; + this.showLoginModal = false; + this.loginForm = { email: '', password: '' }; + this.showToast(this.isRegister ? '注册成功' : '登录成功', 'success'); + + // 重新加载数据 + await this.loadHistory(); + await this.loadTrades(); + this.refreshAlerts(); + } else { + this.loginError = response.data.error || '操作失败'; + } + } catch (e) { + this.loginError = e.response?.data?.error || '网络错误'; + } finally { + this.loginLoading = false; + } + }, + + async handleChangePassword() { + if (this.passwordLoading) return; + + const { oldPassword, newPassword, confirmPassword } = this.passwordForm; + + if (!oldPassword || !newPassword || !confirmPassword) { + this.passwordError = '请填写所有字段'; + return; + } + + if (newPassword.length < 6) { + this.passwordError = '新密码至少6位'; + return; + } + + if (newPassword !== confirmPassword) { + this.passwordError = '两次输入的新密码不一致'; + return; + } + + this.passwordError = ''; + this.passwordSuccess = ''; + this.passwordLoading = true; + + try { + const response = await axios.post('/api/change_password', { + old_password: oldPassword, + new_password: newPassword + }); + + if (response.data.success) { + this.passwordSuccess = '密码修改成功'; + this.passwordForm = { oldPassword: '', newPassword: '', confirmPassword: '' }; + setTimeout(() => { + this.showPasswordModal = false; + this.passwordSuccess = ''; + }, 1500); + } else { + this.passwordError = response.data.error || '修改失败'; + } + } catch (e) { + this.passwordError = e.response?.data?.error || '网络错误'; + } finally { + this.passwordLoading = false; + } + }, + + handleLogout() { + this.showConfirm('确定要退出登录吗?', async () => { + try { + await axios.post('/api/logout'); + this.currentUser = null; + + // 清空用户数据 + this.searchHistory = []; + this.trades = []; + this.stockAlerts = []; + + this.showToast('已退出登录', 'info'); + } catch (e) { + console.error('退出失败', e); + } + }); + }, + + // 显示提示信息(替代alert) + showToast(message, type = 'info', duration = 3000) { + this.toastMessage = message; + this.toastType = type; + this.toastVisible = true; + + // 自动关闭 + setTimeout(() => { + this.toastVisible = false; + }, duration); + }, + + // 显示确认对话框(替代confirm) + showConfirm(message, callback) { + this.confirmMessage = message; + this.confirmCallback = callback; + this.confirmVisible = true; + }, + + // 确认对话框 - 确定 + handleConfirmOk() { + this.confirmVisible = false; + if (this.confirmCallback) { + this.confirmCallback(); + } + }, + + // 确认对话框 - 取消 + handleConfirmCancel() { + this.confirmVisible = false; + this.confirmCallback = null; + }, + + // 格式化金额,添加千分位 + formatMoney(amount, decimals = 2) { + if (amount === null || amount === undefined || isNaN(amount)) return '0.00'; + return Number(amount).toLocaleString('zh-CN', { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals + }); + }, + + // 格式化市值为亿元 + formatMarketCap(value) { + if (!value || value === '-') return '-'; + const num = parseFloat(value); + if (isNaN(num)) return value; + // 转换为亿元 + const yi = num / 100000000; + return yi.toLocaleString('zh-CN', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }) + '亿'; + }, + + // 基本面弹窗中的AI分析(流式输出) + async aiAnalyze(code, name) { + if (!code) return; + + this.aiAnalyzing = true; + this.aiAnalysisResult = ''; + this.aiReasoningResult = ''; + + // 使用EventSource接收流式数据 + const eventSource = new EventSource(`/api/ai_analyze_stream/${code}`); + + eventSource.onmessage = (event) => { + if (event.data === '[DONE]') { + eventSource.close(); + this.aiAnalyzing = false; + return; + } + + try { + const data = JSON.parse(event.data); + + if (data.type === 'reasoning') { + this.aiReasoningResult += data.content; + } else if (data.type === 'content') { + this.aiAnalysisResult += data.content; + } else if (data.type === 'error') { + this.showToast(data.content, 'error'); + eventSource.close(); + this.aiAnalyzing = false; + } + } catch (e) { + console.error('解析SSE数据失败', e); + } + }; + + eventSource.onerror = (e) => { + console.error('SSE连接错误', e); + eventSource.close(); + this.aiAnalyzing = false; + if (!this.aiAnalysisResult) { + this.showToast('AI分析连接失败', 'error'); + } + }; + }, + + // 格式化AI分析结果(支持Markdown格式) + formatAiAnalysis(text) { + if (!text) return ''; + return text + // 处理二级标题 + .replace(/^## (.+)$/gm, '

$1

') + // 处理三级标题 + .replace(/^### (.+)$/gm, '
$1
') + // 处理加粗 + .replace(/\*\*([^*]+)\*\*/g, '$1') + // 处理列表项 + .replace(/^- (.+)$/gm, '
  • $1
  • ') + // 处理换行 + .replace(/\n/g, '
    ') + // 清理多余的br + .replace(/

    /g, '') + .replace(/<\/h5>
    /g, ''); + }, + + // 根据股票代码获取名称 + async getStockNameByCode(code) { + try { + const response = await axios.get(`/api/fundamental/${code}`); + if (response.data.success && response.data.data) { + return response.data.data.stock_name; + } + } catch (e) { + console.log('获取股票名称失败'); + } + return null; + }, + + async analyze() { + if (!this.stockCode) { + this.error = '请输入股票代码'; + return; + } + + this.loading = true; + this.error = null; + this.result = null; + + try { + const response = await axios.post('/api/analyze', { + stock_code: this.stockCode, + start_date: this.startDate, + end_date: this.endDate + }); + + if (response.data.success) { + this.result = response.data; + + // 缓存分析结果(用于交易记录页面) + this.analysisCache[this.stockCode] = response.data; + + // 保存到历史记录 + this.saveHistory(response.data.stock_code, response.data.stock_name); + + // 获取最新数据 + this.calculateLatestData(); + + // 如果图表区域已展开,渲染图表 + if (this.expandedSections.charts) { + this.$nextTick(() => { + this.renderCharts(); + }); + } + + // 跳转到提醒页面并刷新 + this.activeTab = 'alerts'; + this.$nextTick(() => { + this.refreshAlerts(); + }); + } else { + this.error = response.data.error || '分析失败'; + } + } catch (err) { + console.error('请求错误:', err); + this.error = err.response?.data?.error || err.message || '请求失败'; + } finally { + this.loading = false; + } + }, + + // 从基本面弹窗添加到关注 + async addToWatchFromFundamental() { + if (!this.fundamentalData) return; + + const code = this.fundamentalData.stock_code; + const name = this.fundamentalData.stock_name; + + if (this.searchHistory.some(h => h.code === code)) { + this.showToast('该股票已在关注列表中', 'warning'); + return; + } + + // 添加到关注列表(通过API) + try { + const response = await axios.post('/api/watchlist', { code, name }); + if (response.data.success) { + this.searchHistory = [...response.data.watchlist]; + + // 强制更新stockAlerts触发关注列表刷新 + this.stockAlerts = [...this.stockAlerts]; + + // 强制更新fundamentalData触发按钮状态刷新 + this.fundamentalData = { ...this.fundamentalData }; + + this.showToast('已添加到关注列表', 'success'); + } + } catch (e) { + console.error('添加关注失败', e); + this.showToast('添加关注失败', 'error'); + } + }, + + // 从基本面弹窗取消关注 + async removeFromWatchFromFundamental() { + if (!this.fundamentalData) return; + + const code = this.fundamentalData.stock_code; + + try { + const response = await axios.delete(`/api/watchlist/${code}`); + if (response.data.success) { + this.searchHistory = [...response.data.watchlist]; + // 同时从stockAlerts中移除 + this.stockAlerts = this.stockAlerts.filter(a => a.code !== code); + + // 强制更新fundamentalData触发按钮状态刷新 + this.fundamentalData = { ...this.fundamentalData }; + + this.showToast('已取消关注', 'success'); + } + } catch (e) { + console.error('取消关注失败', e); + this.showToast('取消关注失败', 'error'); + } + }, + + async addToWatchFromSignal() { + if (!this.techSignalResult) return; + const code = this.techSignalResult.stock_code; + const name = this.techSignalResult.stock_name; + if (this.searchHistory.some(h => h.code === code)) { + this.showToast('该股票已在关注列表中', 'warning'); + return; + } + try { + const response = await axios.post('/api/watchlist', { code, name }); + if (response.data.success) { + this.searchHistory = [...response.data.watchlist]; + if (!this.stockAlerts.some(a => a.code === code)) { + try { + const alertResp = await axios.post('/api/signal_alerts', { + stocks: [{ code, name }], + holding_codes: this.holdingStocks + }); + if (alertResp.data.success && alertResp.data.results?.length) { + this.stockAlerts = [...this.stockAlerts, ...alertResp.data.results]; + this.saveAlertsCache(this.stockAlerts); + } + } catch (e) { /* fallback: alerts will refresh on tab switch */ } + } + this.showToast('已添加到关注列表', 'success'); + } + } catch (e) { + console.error('添加关注失败', e); + this.showToast('添加关注失败', 'error'); + } + }, + + async removeFromWatchFromSignal() { + if (!this.techSignalResult) return; + const code = this.techSignalResult.stock_code; + try { + const response = await axios.delete(`/api/watchlist/${code}`); + if (response.data.success) { + this.searchHistory = [...response.data.watchlist]; + this.stockAlerts = this.stockAlerts.filter(a => a.code !== code); + this.saveAlertsCache(this.stockAlerts); + this.showToast('已取消关注', 'success'); + } + } catch (e) { + console.error('取消关注失败', e); + this.showToast('取消关注失败', 'error'); + } + }, + + async toggleWatchFromScan(code, name) { + const isWatched = this.searchHistory.some(h => h.code === code); + try { + if (isWatched) { + const response = await axios.delete(`/api/watchlist/${code}`); + if (response.data.success) { + this.searchHistory = response.data.watchlist || []; + this.stockAlerts = this.stockAlerts.filter(a => a.code !== code); + this.saveAlertsCache(this.stockAlerts); + this.refreshAlerts(true); + this.showToast('已取消关注', 'success'); + } + } else { + const response = await axios.post('/api/watchlist', { code, name: name || code }); + if (response.data.success) { + this.searchHistory = response.data.watchlist || []; + this.refreshAlerts(true); + this.showToast('已添加关注', 'success'); + } + } + } catch (e) { + console.error(isWatched ? '取消关注失败' : '添加关注失败', e); + this.showToast(isWatched ? '取消关注失败' : '添加关注失败', 'error'); + } + }, + + calculateLatestData() { + // 从API返回的最新数据中获取 + const latest = this.result.data['最新数据']; + + if (latest) { + this.latestData = { + date: latest['日期'], + price: latest['收盘价'], + change: latest['涨跌幅'], + pricePosition: latest['价格位置'], + superRatio: latest['超大单净流入占比'], + mainRatio: latest['主力净流入占比'], + superDirection: latest['超大单流向'], + mainDirection: latest['主力流向'] + }; + } else { + this.latestData = { + price: 0, + pricePosition: 50, + superRatio: 0, + mainRatio: 0 + }; + } + }, + + renderCharts() { + // 准备数据 + const labels = this.periods; + + // 超大单数据 + const superInflowData = labels.map(period => { + const val = this.getComparisonValue('超大单', period, '大额流入表现'); + return val !== null ? val : 0; + }); + const superOutflowData = labels.map(period => { + const val = this.getComparisonValue('超大单', period, '大额流出表现'); + return val !== null ? val : 0; + }); + + // 主力数据 + const mainInflowData = labels.map(period => { + const val = this.getComparisonValue('主力', period, '大额流入表现'); + return val !== null ? val : 0; + }); + const mainOutflowData = labels.map(period => { + const val = this.getComparisonValue('主力', period, '大额流出表现'); + return val !== null ? val : 0; + }); + + // 销毁旧图表 + if (this.superChart) this.superChart.destroy(); + if (this.mainChart) this.mainChart.destroy(); + + // 超大单图表 + const superCtx = this.$refs.superChart.getContext('2d'); + this.superChart = new Chart(superCtx, { + type: 'bar', + data: { + labels: labels, + datasets: [ + { + label: '大额流入日表现', + data: superInflowData, + backgroundColor: 'rgba(231, 76, 60, 0.7)', + borderColor: 'rgba(231, 76, 60, 1)', + borderWidth: 1 + }, + { + label: '大额流出日表现', + data: superOutflowData, + backgroundColor: 'rgba(39, 174, 96, 0.7)', + borderColor: 'rgba(39, 174, 96, 1)', + borderWidth: 1 + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + scales: { + y: { + beginAtZero: true, + ticks: { + callback: function(value) { + return value + '%'; + } + } + } + }, + plugins: { + legend: { + position: 'top' + } + } + } + }); + + // 主力图表 + const mainCtx = this.$refs.mainChart.getContext('2d'); + this.mainChart = new Chart(mainCtx, { + type: 'bar', + data: { + labels: labels, + datasets: [ + { + label: '大额流入日表现', + data: mainInflowData, + backgroundColor: 'rgba(231, 76, 60, 0.7)', + borderColor: 'rgba(231, 76, 60, 1)', + borderWidth: 1 + }, + { + label: '大额流出日表现', + data: mainOutflowData, + backgroundColor: 'rgba(39, 174, 96, 0.7)', + borderColor: 'rgba(39, 174, 96, 1)', + borderWidth: 1 + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + scales: { + y: { + beginAtZero: true, + ticks: { + callback: function(value) { + return value + '%'; + } + } + } + }, + plugins: { + legend: { + position: 'top' + } + } + } + }); + }, + + getComparisonValue(flowType, period, key) { + try { + const comparison = this.result.data['对比分析'][flowType]; + if (comparison && comparison[period]) { + return comparison[period][key]; + } + } catch (e) {} + return null; + }, + + formatPercent(value) { + if (value === null || value === undefined) return '-'; + return value.toFixed(2) + '%'; + }, + + getClass(value) { + if (value === null || value === undefined) return ''; + return value >= 0 ? 'positive' : 'negative'; + }, + + toggleSection(section) { + this.expandedSections[section] = !this.expandedSections[section]; + // 如果展开图表区域,需要重新渲染图表 + if (section === 'charts' && this.expandedSections.charts && this.result) { + this.$nextTick(() => { + this.renderCharts(); + }); + } + }, + + // 关注列表相关方法(使用服务器存储) + async loadHistory() { + try { + const response = await axios.get('/api/watchlist'); + if (response.data.success) { + this.searchHistory = response.data.watchlist; + } + } catch (e) { + console.error('加载关注列表失败', e); + } + }, + + async saveHistory(code, name) { + try { + const response = await axios.post('/api/watchlist', { code, name }); + if (response.data.success) { + this.searchHistory = response.data.watchlist; + } + } catch (e) { + console.error('保存关注列表失败', e); + } + }, + + selectHistory(item) { + this.stockCode = item.code; + this.showHistory = false; + // 自动开始分析 + this.analyze(); + }, + + async removeHistory(code) { + try { + const response = await axios.delete(`/api/watchlist/${code}`); + if (response.data.success) { + this.searchHistory = response.data.watchlist; + } + } catch (e) { + console.error('移除关注失败', e); + } + }, + + hideHistory() { + // 延迟隐藏,让点击事件能够触发 + setTimeout(() => { + this.showHistory = false; + }, 200); + }, + + // ========== 提醒页面相关方法 ========== + async refreshAlerts(forceRefresh = false) { + // 合并关注列表和持有股票(去重) + const allStocks = [...this.searchHistory]; + + // 添加持有的股票(如果不在关注列表中) + this.holdingStocks.forEach(code => { + if (!allStocks.some(item => item.code === code)) { + const trade = this.trades.find(t => t.stock_code === code); + allStocks.push({ + code: code, + name: trade?.stock_name || `股票${code}` + }); + } + }); + + if (allStocks.length === 0) { + this.stockAlerts = []; + this.alertsLoading = false; + return; + } + + const today = new Date().toISOString().split('T')[0]; + + // 1. 先尝试加载缓存 + if (!forceRefresh) { + try { + const cacheResponse = await axios.get('/api/alerts_cache'); + if (cacheResponse.data.success && cacheResponse.data.alerts?.length > 0) { + const cachedAlerts = cacheResponse.data.alerts; + const lastUpdate = cacheResponse.data.lastUpdate; + const cacheDate = lastUpdate ? lastUpdate.split(' ')[0] : null; + const cacheVer = cacheResponse.data.version || 0; + + // 版本不匹配则强制刷新 + if (cacheVer < ALERT_CACHE_VERSION) { + console.log(`缓存版本过旧(v${cacheVer} < v${ALERT_CACHE_VERSION}),强制刷新`); + } else { + this.stockAlerts = cachedAlerts; + console.log(`加载缓存数据: ${cachedAlerts.length}条, 更新时间: ${lastUpdate}, v${cacheVer}`); + + if (cacheDate === today) { + console.log('缓存是今天的,无需更新'); + this.alertsLoading = false; + this.updateHoldingPricesFromAlerts(); + const cachedCodes = new Set(cachedAlerts.map(a => a.code)); + const newStocks = allStocks.filter(s => !cachedCodes.has(s.code)); + if (newStocks.length > 0) { + console.log(`发现${newStocks.length}只新股票,增量更新`); + await this.analyzeNewStocks(newStocks, today); + } + return; + } + } + } + } catch (err) { + console.log('加载缓存失败,将重新分析:', err); + } + } + + // 2. 使用信号扫描接口 + this.alertsLoading = true; + this.alertsProgress = { current: 0, total: allStocks.length }; + + try { + console.log(`开始信号分析 ${allStocks.length} 只股票...`); + const startTime = Date.now(); + + const response = await axios.post('/api/signal_alerts', { + stocks: allStocks, + holding_codes: this.holdingStocks + }); + + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + console.log(`信号分析完成,耗时 ${elapsed}s`); + + if (response.data.success) { + const results = response.data.results || []; + this.alertsProgress.current = allStocks.length; + this.stockAlerts = [...results]; + + console.log(`成功: ${response.data.success_count}`); + + // 3. 保存到缓存 + this.saveAlertsCache(this.stockAlerts); + + // 4. 更新持仓现价 + this.updateHoldingPricesFromAlerts(); + + // 5. 后台用实时接口更新提醒中的现价(避免显示表里的旧价) + this.refreshAlertPricesFromRealtime(); + } else { + console.error('信号分析失败:', response.data.error); + } + } catch (err) { + console.error('批量分析请求失败:', err); + // 回退到逐个分析 + console.log('回退到逐个分析模式...'); + await this.analyzeStocksOneByOne(allStocks, today, forceRefresh); + } + + this.alertsLoading = false; + }, + + // 逐个分析(批量失败时的回退方案) + async analyzeStocksOneByOne(allStocks, today, forceRefresh) { + const results = []; + const BATCH_SIZE = 5; + + for (let i = 0; i < allStocks.length; i += BATCH_SIZE) { + const batch = allStocks.slice(i, i + BATCH_SIZE); + const batchPromises = batch.map(item => + this.analyzeStock(item, today).catch(err => { + console.error(`分析${item.code}失败:`, err); + return null; + }) + ); + + const batchResults = await Promise.all(batchPromises); + batchResults.forEach(alertData => { + if (alertData) results.push(alertData); + }); + + this.alertsProgress.current = Math.min(i + BATCH_SIZE, allStocks.length); + this.stockAlerts = [...results]; + } + + this.stockAlerts = results; + + if (forceRefresh && results.length > 0) { + for (const alert of results) { + try { + const resp = await axios.get(`/api/realtime_price/${alert.code}`); + const priceData = resp.data.price || resp.data.data?.price; + if (resp.data.success && priceData) { + const newPrice = priceData; + const oldPrice = alert.price || 0; + if (oldPrice > 0 && newPrice > oldPrice) { + alert.priceDirection = 'up'; + } else if (oldPrice > 0 && newPrice < oldPrice) { + alert.priceDirection = 'down'; + } + alert.price = newPrice; + } + } catch (err) {} + } + this.stockAlerts = [...results]; + } + + this.saveAlertsCache(this.stockAlerts); + this.updateHoldingPricesFromAlerts(); + }, + + // 分析单只股票 + async analyzeStock(item, today) { + const response = await axios.post('/api/analyze', { + stock_code: item.code, + start_date: this.startDate, + end_date: today + }); + + if (response.data.success) { + const latest = response.data.data['最新数据']; + if (latest) { + const pricePos = latest['价格位置'] || 50; + const superRatio = latest['超大单净流入占比'] || 0; + const mainRatio = latest['主力净流入占比'] || 0; + + const isLow = pricePos <= 50; + const isHigh = pricePos > 50; + const isSuperInflow = superRatio >= 2; + const isSuperOutflow = superRatio <= -2; + const isMainInflow = mainRatio >= 2; + const isMainOutflow = mainRatio <= -2; + + let signalType = 'watch'; + let reason = '无明确信号'; + let recommendRate = 0; + + if (isLow && (isSuperInflow || isMainInflow)) { + signalType = 'buy'; + reason = `低位${pricePos.toFixed(0)}% + ${isSuperInflow ? '超大单' : '主力'}流入`; + const posScore = Math.max(0, (50 - pricePos) / 50) * 50; + const flowScore = Math.min(Math.max(superRatio, mainRatio), 10) * 5; + recommendRate = Math.round(posScore + flowScore); + } else if (isHigh && (isSuperOutflow || isMainOutflow)) { + signalType = 'sell'; + reason = `高位${pricePos.toFixed(0)}% + ${isSuperOutflow ? '超大单' : '主力'}流出`; + const posScore = Math.max(0, (pricePos - 50) / 50) * 50; + const flowScore = Math.min(Math.abs(Math.min(superRatio, mainRatio)), 10) * 5; + recommendRate = Math.round(posScore + flowScore); + } + + const closePrice = latest['收盘价']; + return { + code: item.code, + name: response.data.stock_name || item.name, + price: closePrice, + changePct: 0, + scanPrice: closePrice, + scanChangePct: 0, + pricePosition: pricePos, + superRatio: superRatio, + mainRatio: mainRatio, + signalType: signalType, + reason: reason, + recommendRate: recommendRate, + updateDate: today + }; + } + } + return null; + }, + + // 分析新增股票(增量更新,并行) + async analyzeNewStocks(newStocks, today) { + this.alertsLoading = true; + try { + const response = await axios.post('/api/signal_alerts', { + stocks: newStocks, + holding_codes: this.holdingStocks + }); + if (response.data.success) { + const newResults = response.data.results || []; + newResults.forEach(r => { + if (!this.stockAlerts.some(a => a.code === r.code)) { + this.stockAlerts.push(r); + } + }); + this.stockAlerts = [...this.stockAlerts]; + } + } catch (err) { + console.error('增量信号分析失败:', err); + } + this.alertsLoading = false; + this.saveAlertsCache(this.stockAlerts); + }, + + // 保存分析结果到缓存 + async saveAlertsCache(alerts) { + try { + await axios.post('/api/alerts_cache', { + alerts: alerts, + lastUpdate: new Date().toISOString().replace('T', ' ').split('.')[0], + version: ALERT_CACHE_VERSION + }); + console.log('分析结果已保存到缓存'); + } catch (err) { + console.error('保存缓存失败:', err); + } + }, + + // 添加关注 + async addToWatch() { + if (!this.newWatchCode || this.newWatchCode.length < 6) return; + + const code = this.newWatchCode.trim(); + + // 检查是否已存在 + if (this.searchHistory.some(item => item.code === code)) { + this.showToast('该股票已在关注列表中', 'warning'); + return; + } + + // 获取股票信息 + try { + const today = new Date().toISOString().split('T')[0]; + const response = await axios.post('/api/analyze', { + stock_code: code, + start_date: this.startDate, + end_date: today + }); + + if (response.data.success) { + const stockName = response.data.stock_name || code; + + // 添加到关注列表(通过API) + await axios.post('/api/watchlist', { code: code, name: stockName }); + await this.loadHistory(); + + // 刷新提醒 + await this.refreshAlerts(); + + // 清空输入并隐藏 + this.newWatchCode = ''; + this.showAddWatch = false; + + // 切换到关注标签 + this.alertSubTab = 'watching'; + } else { + this.showToast('添加失败:' + (response.data.error || '未知错误'), 'error'); + } + } catch (err) { + this.showToast('添加失败:' + (err.response?.data?.error || err.message), 'error'); + } + }, + + // 移除关注 + removeFromWatch(code) { + this.showConfirm('确定移除该股票的关注吗?', async () => { + // 从关注列表中移除(通过API) + try { + const response = await axios.delete(`/api/watchlist/${code}`); + if (response.data.success) { + this.searchHistory = response.data.watchlist; + } + } catch (e) { + console.error('移除关注失败', e); + } + + // 从提醒列表中移除 + this.stockAlerts = this.stockAlerts.filter(a => a.code !== code); + + // 清除缓存 + delete this.analysisCache[code]; + }); + }, + + // 从提醒页面快速交易 + quickTradeFromAlert(alert, tradeType) { + // 如果股票不在关注列表中,临时添加 + if (!this.searchHistory.some(item => item.code === alert.code)) { + this.searchHistory.push({ + code: alert.code, + name: alert.name || `股票${alert.code}` + }); + } + + // 预填充交易表单 + this.newTrade.stock_code = alert.code; + this.newTrade.stock_name = alert.name; + this.newTrade.trade_type = tradeType; + this.newTrade.price = alert.price; + this.newTrade.trade_date = new Date().toISOString().split('T')[0]; + this.newTrade.reason = alert.reason; + this.selectedStockFromHistory = alert.code; + this.selectedStockSignal = { + type: tradeType, + icon: tradeType === 'buy' ? '✓' : '!', + title: tradeType === 'buy' ? '买入信号' : '卖出信号', + description: alert.reason + }; + + // 切换到交易页面并打开表单 + this.activeTab = 'trades'; + this.showTradeForm = true; + }, + + // ========== 交易记录相关方法 ========== + async loadTrades() { + try { + const response = await axios.get('/api/trades'); + if (response.data.success) { + this.trades = response.data.trades || []; + await this.calculateHoldingPositions(); + this.calculateTradeStats(); + // 检查止损线 + await this.checkStopLoss(); + } + // 加载可用资金 + await this.loadAvailableCash(); + } catch (err) { + console.error('加载交易记录失败:', err); + } + }, + + async loadAvailableCash() { + try { + const response = await axios.get('/api/available_cash'); + if (response.data.success) { + this.availableCash = response.data.available_cash || 0; + } + } catch (err) { + console.error('加载可用资金失败:', err); + } + }, + + startEditCash() { + this.editingCash = true; + this.cashInputValue = this.availableCash.toString(); + this.$nextTick(() => { + const input = this.$refs.cashInput; + if (input) input.focus(); + }); + }, + + async saveCash() { + try { + const amount = parseFloat(this.cashInputValue); + if (isNaN(amount)) { + alert('请输入有效金额'); + return; + } + const response = await axios.put('/api/available_cash', { amount }); + if (response.data.success) { + this.availableCash = response.data.available_cash; + this.editingCash = false; + } else { + alert('保存失败: ' + (response.data.error || '未知错误')); + } + } catch (err) { + console.error('保存可用资金失败:', err); + alert('保存失败'); + } + }, + + cancelEditCash() { + this.editingCash = false; + this.cashInputValue = ''; + }, + + // 计算持仓(不获取现价,等refreshAlerts后再更新) + async calculateHoldingPositions() { + const positions = {}; + + // 计算每只股票的持仓数量和成本 + this.trades.forEach(trade => { + const code = trade.stock_code; + if (!positions[code]) { + positions[code] = { + quantity: 0, + totalCost: 0, + name: trade.stock_name, + currentPrice: 0 + }; + } + + const qty = parseInt(trade.quantity) || 0; + const price = parseFloat(trade.price) || 0; + + if (trade.trade_type === 'buy') { + positions[code].totalCost += qty * price; + positions[code].quantity += qty; + } else if (trade.trade_type === 'sell') { + // 卖出时按比例减少成本 + const avgCost = positions[code].quantity > 0 + ? positions[code].totalCost / positions[code].quantity + : 0; + positions[code].totalCost -= qty * avgCost; + positions[code].quantity -= qty; + } + }); + + // 先从 stockAlerts 缓存中更新现价(不发请求) + const holdingCodes = Object.keys(positions).filter(code => positions[code].quantity > 0); + for (const code of holdingCodes) { + const alert = this.stockAlerts.find(a => a.code === code); + if (alert && alert.price) { + positions[code].currentPrice = alert.price; + } + } + + this.holdingPositions = positions; + }, + + // 从分析结果更新持仓现价 + updateHoldingPricesFromAlerts() { + const holdingCodes = Object.keys(this.holdingPositions).filter( + code => this.holdingPositions[code].quantity > 0 + ); + for (const code of holdingCodes) { + const alert = this.stockAlerts.find(a => a.code === code); + if (alert && alert.price) { + this.holdingPositions[code].currentPrice = alert.price; + } + } + this.calculateTradeStats(); + }, + + /** + * 用实时接口更新现价(提醒与交易共用)。 + * @param {string[]} [codesOnly] - 若传则只刷新这些 code(交易 tab 传持仓 code);不传则刷新全部 stockAlerts(提醒 tab) + * @param {number} [timeoutMs] - 单次请求超时,默认 12s + */ + async refreshAlertPricesFromRealtime(codesOnly, timeoutMs = 12000) { + const list = codesOnly && codesOnly.length > 0 + ? (this.stockAlerts || []).filter(a => codesOnly.includes(a.code)) + : (this.stockAlerts || []); + if (list.length === 0) { + if (codesOnly && codesOnly.length > 0) { + // 持仓在 stockAlerts 里可能没有,仍要拉价并写回 holdingPositions + for (const code of codesOnly) { + try { + const resp = await axios.get(`/api/realtime_price/${code}`, { timeout: timeoutMs }); + const priceData = resp.data.price ?? resp.data.data?.price; + const changeData = resp.data.data?.change; + if (resp.data.success && priceData != null && this.holdingPositions[code]) { + this.holdingPositions[code].currentPrice = Number(priceData); + this.holdingPositions = { ...this.holdingPositions }; + this.calculateTradeStats(); + } + } catch (e) { console.error(`获取${code}实时价格失败:`, e); } + } + } + return; + } + const BATCH = 5; + for (let i = 0; i < list.length; i += BATCH) { + const batch = list.slice(i, i + BATCH); + await Promise.all(batch.map(async (alert) => { + try { + const resp = await axios.get(`/api/realtime_price/${alert.code}`, { timeout: timeoutMs }); + const priceData = resp.data.price ?? resp.data.data?.price; + const changeData = resp.data.data?.change; + if (resp.data.success && priceData != null) { + const newPrice = Number(priceData); + const oldPrice = alert.price || 0; + alert.price = newPrice; + // 同步更新实时涨跌幅 + if (changeData != null) alert.changePct = Number(changeData); + if (oldPrice > 0 && newPrice > oldPrice) alert.priceDirection = 'up'; + else if (oldPrice > 0 && newPrice < oldPrice) alert.priceDirection = 'down'; + if (alert.latest_data) alert.latest_data['收盘价'] = newPrice; + } + } catch (e) { /* 单只失败忽略 */ } + })); + this.stockAlerts = [...this.stockAlerts]; + if (codesOnly) { + this.updateHoldingPricesFromAlerts(); + this.holdingPositions = { ...this.holdingPositions }; + this.calculateTradeStats(); + } + } + this.saveAlertsCache(this.stockAlerts); + this.updateHoldingPricesFromAlerts(); + }, + + /** 交易 tab:刷新持仓现价,与提醒 tab 共用 refreshAlertPricesFromRealtime,仅传持仓 code */ + async refreshHoldingPrices() { + const holdingCodes = Object.keys(this.holdingPositions).filter( + code => this.holdingPositions[code].quantity > 0 + ); + if (holdingCodes.length === 0) return; + this.priceRefreshing = true; + try { + await this.refreshAlertPricesFromRealtime(holdingCodes, 12000); + await this.checkStopLoss(); + } catch (e) { + console.error('刷新持仓价格异常:', e); + } finally { + this.priceRefreshing = false; + } + }, + + // 检查止损线 + async checkStopLoss() { + try { + const response = await axios.get('/api/stoploss_check'); + if (response.data.success) { + this.stopLossAlerts = response.data.alerts || []; + } + } catch (error) { + console.error('检查止损失败:', error); + } + }, + + // 切换交易组展开/折叠 + toggleTradeGroup(code) { + if (this.expandedTradeGroups[code]) { + delete this.expandedTradeGroups[code]; + } else { + this.expandedTradeGroups[code] = true; + } + // 触发响应式更新 + this.expandedTradeGroups = { ...this.expandedTradeGroups }; + }, + + // 加载基本面数据 + async loadFundamental(code, name) { + this.aiAnalysisResult = ''; + this.aiReasoningResult = ''; + this.aiAnalyzing = false; + this.showFundamentalModal = true; + this.fundamentalLoading = true; + this.fundamentalData = { stock_code: code, stock_name: name, fundFlow: [], klineData: [] }; + + try { + // 使用 Promise.allSettled 确保单个请求失败不影响其他数据 + const [fundResult, flowResult, klineResult] = await Promise.allSettled([ + axios.get(`/api/fundamental/${code}`), + axios.get(`/api/fundflow/${code}?days=3`), + axios.get(`/api/kline/${code}?period=${this.klinePeriod}`) + ]); + + // 基本面数据 + if (fundResult.status === 'fulfilled' && fundResult.value.data.success) { + this.fundamentalData = { ...fundResult.value.data.data, fundFlow: [], klineData: [] }; + } + + // 近3天资金流向 + if (flowResult.status === 'fulfilled' && flowResult.value.data.success) { + this.fundamentalData.fundFlow = flowResult.value.data.data || []; + } + + // K线数据(可能失败,不影响其他数据显示) + if (klineResult.status === 'fulfilled' && klineResult.value.data.success) { + this.fundamentalData.klineData = klineResult.value.data.data || []; + } + } catch (error) { + console.error('获取数据失败:', error); + } finally { + this.fundamentalLoading = false; + // 绘制K线图 + this.$nextTick(() => { + this.renderKlineChart(); + }); + } + }, + + // 切换K线周期 + async changeKlinePeriod(period) { + if (!this.fundamentalData?.stock_code) return; + this.klinePeriod = period; + this.klineLoading = true; + + try { + const response = await axios.get(`/api/kline/${this.fundamentalData.stock_code}?period=${period}`); + if (response.data.success) { + this.fundamentalData.klineData = response.data.data || []; + this.renderKlineChart(); + } + } catch (error) { + console.error('获取K线数据失败:', error); + } finally { + this.klineLoading = false; + } + }, + + // 渲染K线图(使用收盘价折线图) + renderKlineChart() { + if (!this.fundamentalData?.klineData || this.fundamentalData.klineData.length === 0) return; + + const canvas = this.$refs.klineCanvas; + if (!canvas) return; + + // 销毁旧图表 + if (this.klineChart) { + this.klineChart.destroy(); + } + + const data = this.fundamentalData.klineData; + const labels = data.map(d => d.date.slice(5)); // 只显示 MM-DD + const prices = data.map(d => d.close); + + // 计算涨跌颜色 + const firstPrice = prices[0]; + const lastPrice = prices[prices.length - 1]; + const isUp = lastPrice >= firstPrice; + const lineColor = isUp ? '#00ff88' : '#ff4444'; + const bgColor = isUp ? 'rgba(0, 255, 136, 0.1)' : 'rgba(255, 68, 68, 0.1)'; + + this.klineChart = new Chart(canvas, { + type: 'line', + data: { + labels: labels, + datasets: [{ + label: '收盘价', + data: prices, + borderColor: lineColor, + backgroundColor: bgColor, + fill: true, + tension: 0.3, + pointRadius: 0, + pointHoverRadius: 4, + borderWidth: 2 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { display: false }, + tooltip: { + callbacks: { + label: (ctx) => `¥${ctx.raw.toFixed(2)}` + } + } + }, + scales: { + x: { + display: true, + grid: { display: false }, + ticks: { + color: 'rgba(255,255,255,0.5)', + font: { size: 10 }, + maxTicksLimit: 6 + } + }, + y: { + display: true, + grid: { color: 'rgba(255,255,255,0.05)' }, + ticks: { + color: 'rgba(255,255,255,0.5)', + font: { size: 10 }, + callback: (v) => '¥' + v.toFixed(2) + } + } + } + } + }); + }, + + // 从止损预警快速卖出 + quickSellFromStopLoss(alert) { + // 添加到关注列表(如果不存在) + if (!this.searchHistory.some(h => h.code === alert.code)) { + this.searchHistory.push({ code: alert.code, name: alert.name }); + } + + this.editingTradeId = null; + this.selectedStockFromHistory = alert.code; + this.selectedStockSignal = { + type: 'sell', + icon: '🔴', + title: '止损信号', + description: alert.message + }; + this.newTrade = { + stock_code: alert.code, + stock_name: alert.name, + trade_type: 'sell', + price: alert.current_price, + quantity: alert.quantity, + trade_date: new Date().toISOString().split('T')[0], + reason: alert.message, + result: 'pending', + profit_amount: '', + stop_loss_price: '', + notes: `止损卖出,亏损 ${alert.profit_percent.toFixed(1)}%` + }; + this.showTradeForm = true; + }, + + async submitTrade() { + // 设置默认日期 + if (!this.newTrade.trade_date) { + this.newTrade.trade_date = new Date().toISOString().split('T')[0]; + } + + // 生成唯一ID + const tradeData = { + ...this.newTrade, + id: this.editingTradeId || Date.now().toString(), + created_at: new Date().toISOString() + }; + + try { + let response; + if (this.editingTradeId) { + // 更新已有记录 + response = await axios.put(`/api/trades/${this.editingTradeId}`, tradeData); + } else { + // 添加新记录 + response = await axios.post('/api/trades', tradeData); + } + if (response.data.success) { + this.showTradeForm = false; + this.resetTradeForm(); + if (response.data.available_cash != null) { + this.availableCash = response.data.available_cash; + } + await this.loadTrades(); + } + } catch (err) { + console.error('保存交易记录失败:', err); + this.showToast('保存失败: ' + (err.response?.data?.error || err.message), 'error'); + } + }, + + editTrade(trade) { + this.editingTradeId = trade.id; + this.newTrade = { ...trade }; + + // 如果股票不在关注列表中,临时添加 + if (!this.searchHistory.some(item => item.code === trade.stock_code)) { + this.searchHistory.push({ + code: trade.stock_code, + name: trade.stock_name || `股票${trade.stock_code}` + }); + } + + // 设置选中的股票,用于显示在下拉框中 + this.selectedStockFromHistory = trade.stock_code; + // 设置信号显示 + this.selectedStockSignal = { + type: trade.trade_type, + icon: trade.trade_type === 'buy' ? '✓' : '!', + title: trade.trade_type === 'buy' ? '买入' : '卖出', + description: trade.reason || '' + }; + this.showTradeForm = true; + }, + + deleteTrade(tradeId) { + this.showConfirm('确定删除这条交易记录吗?', async () => { + try { + const response = await axios.delete(`/api/trades/${tradeId}`); + if (response.data.success) { + if (response.data.available_cash != null) { + this.availableCash = response.data.available_cash; + } + await this.loadTrades(); + } + } catch (err) { + console.error('删除交易记录失败:', err); + this.showToast('删除失败: ' + (err.response?.data?.error || err.message), 'error'); + } + }); + }, + + resetTradeForm() { + this.editingTradeId = null; + this.selectedStockFromHistory = ''; + this.selectedStockSignal = { type: '', icon: '', title: '', description: '' }; + this.newTrade = { + stock_code: '', + stock_name: '', + trade_type: 'buy', + price: '', + quantity: '', + trade_date: new Date().toISOString().split('T')[0], // 默认当日 + reason: '', + result: 'pending', + profit_amount: '', + stop_loss_price: '', + notes: '' + }; + }, + + openTradeForm() { + if (this.allStocksForTrade.length === 0) { + this.showToast('请先在分析页面查询至少一支股票或有持有股票后再记录交易', 'warning'); + return; + } + this.resetTradeForm(); + // 设置默认日期为当日 + this.newTrade.trade_date = new Date().toISOString().split('T')[0]; + this.showTradeForm = true; + }, + + async onStockSelected() { + if (!this.selectedStockFromHistory) { + this.selectedStockSignal = { type: '', icon: '', title: '', description: '' }; + return; + } + + const stockCode = this.selectedStockFromHistory; + const stockItem = this.searchHistory.find(item => item.code === stockCode); + + // 更新交易表单的股票信息 + this.newTrade.stock_code = stockCode; + this.newTrade.stock_name = stockItem ? stockItem.name : ''; + + // 检查是否有缓存的分析结果 + if (this.analysisCache[stockCode]) { + this.applyAnalysisToTrade(this.analysisCache[stockCode]); + return; + } + + // 重新获取最新分析 + try { + const response = await axios.post('/api/analyze', { + stock_code: stockCode, + start_date: this.startDate, + end_date: this.endDate || new Date().toISOString().split('T')[0] + }); + + if (response.data.success) { + // 缓存分析结果 + this.analysisCache[stockCode] = response.data; + this.applyAnalysisToTrade(response.data); + } else { + this.selectedStockSignal = { + type: '', + icon: '⚠️', + title: '无法获取分析', + description: response.data.error || '分析失败' + }; + } + } catch (err) { + this.selectedStockSignal = { + type: '', + icon: '⚠️', + title: '获取分析失败', + description: err.message + }; + } + }, + + async fetchTechSignals(scanItem) { + if (!this.techSignalCode) return; + this.techLoading = true; + const code = scanItem?.code || this.techSignalCode; + const name = scanItem?.name || ''; + if (scanItem && scanItem.code) { + // 方案C:先展示扫描结果,后台请求实时检测 + const scanCount = (scanItem.signal_status || []).filter(s => s.triggered).length; + this.techSignalResult = { + stock_code: code, + stock_name: name, + signals: [], + latest_signals: scanItem.latest_signals || [], + signal_summary: {}, + indicators: scanItem.indicators || {}, + signal_status: scanItem.signal_status || [], + recommend_type: scanItem.recommend_type, + recommend_text: scanItem.recommend_text, + recommend_reason: scanItem.recommend_reason || '', + recommend_rate: scanItem.recommend_rate, + has_scan_data: true, + scan_triggered_count: scanCount, + realtime_loading: true, + realtime_signal_status: null, + realtime_triggered_count: null, + realtime_recommend_text: null, + realtime_recommend_type: null, + realtime_recommend_reason: null, + realtime_recommend_rate: null, + realtime_indicators: null, + }; + this.techLoading = false; + // 后台请求实时检测 + try { + const holding = (this.holdingStocks || []).join(','); + const url = holding + ? `/api/technical_signals/${code}?lookback=5&days=120&holding_codes=${encodeURIComponent(holding)}` + : `/api/technical_signals/${code}?lookback=5&days=120`; + const resp = await axios.get(url); + if (resp.data.success && this.techSignalResult && this.techSignalResult.stock_code === code) { + const rt = resp.data; + const rtCount = (rt.signal_status || []).filter(s => s.triggered).length; + this.techSignalResult.realtime_loading = false; + this.techSignalResult.realtime_signal_status = rt.signal_status || []; + this.techSignalResult.realtime_triggered_count = rtCount; + this.techSignalResult.realtime_recommend_text = rt.recommend_text; + this.techSignalResult.realtime_recommend_type = rt.recommend_type; + this.techSignalResult.realtime_recommend_reason = rt.recommend_reason; + this.techSignalResult.realtime_recommend_rate = rt.recommend_rate; + this.techSignalResult.realtime_indicators = rt.indicators; + this.techSignalResult.realtime_holding_note = rt.holding_note; + this.techSignalResult.signals = rt.signals || []; + } + } catch (err) { + if (this.techSignalResult && this.techSignalResult.stock_code === code) { + this.techSignalResult.realtime_loading = false; + this.techSignalResult.realtime_error = err.message || '获取失败'; + } + } + return; + } + try { + const holding = (this.holdingStocks || []).join(','); + const url = holding + ? `/api/technical_signals/${this.techSignalCode}?lookback=5&days=120&holding_codes=${encodeURIComponent(holding)}` + : `/api/technical_signals/${this.techSignalCode}?lookback=5&days=120`; + const resp = await axios.get(url); + if (resp.data.success) { + this.techSignalResult = resp.data; + this.techSignalResult.has_scan_data = false; + } + } catch (err) { + console.error('技术信号检测失败:', err); + } finally { + this.techLoading = false; + } + }, + + async batchTechSignals() { + if (!this.searchHistory || this.searchHistory.length === 0) { + alert('请先添加关注股票'); + return; + } + this.techLoading = true; + this.techBatchResults = []; + try { + const codes = this.searchHistory.map(h => h.code); + const resp = await axios.post('/api/batch_technical_signals', { + codes: codes, + lookback: 5, + days: 120, + holding_codes: this.holdingStocks + }); + if (resp.data.success) { + this.techBatchResults = (resp.data.results || []) + .sort((a, b) => (b.triggered_count || 0) - (a.triggered_count || 0)); + } + } catch (err) { + console.error('批量技术信号检测失败:', err); + } finally { + this.techLoading = false; + } + }, + + async fetchFullScanStatus() { + try { + const resp = await axios.get('/api/scan_status'); + if (resp.data.success) { + this.fullScanStatus = resp.data; + } + } catch (err) { + console.error('获取扫描状态失败:', err); + } + }, + + async fetchFullScanResults(page) { + this.fullScanLoading = true; + if (page) this.fullScanPage = page; + try { + const minTriggered = (this.fullScanFilter === 'triggered' || this.fullScanFilter === 'multi') ? 1 : 0; + let signalTypes = ''; + if (this.fullScanFilterTypes.length > 0) { + signalTypes = this.fullScanFilterTypes.join(','); + } + const resp = await axios.get('/api/scan_results', { + params: { + page: this.fullScanPage, + per_page: 50, + min_triggered: minTriggered, + signal_type: signalTypes, + holding_codes: (this.holdingStocks || []).join(','), + recommend_text: this.fullScanFilterRecommend === 'all' ? '' : this.fullScanFilterRecommend, + } + }); + if (resp.data.success) { + this.fullScanResults = resp.data.results || []; + this.fullScanTotalPages = resp.data.total_pages || 1; + this.fullScanSignalDist = resp.data.signal_distribution || []; + this.fullScanStatus = { + total: resp.data.total_stocks || resp.data.total_scanned, + scanned: resp.data.total_scanned, + triggered: resp.data.triggered_stocks, + progress: 100, + is_complete: true, + scan_date: resp.data.scan_date, + scan_start: resp.data.scan_start, + scan_end: resp.data.scan_end, + recommend_counts: resp.data.recommend_counts || {}, + }; + } + } catch (err) { + console.error('获取扫描结果失败:', err); + } finally { + this.fullScanLoading = false; + } + }, + + async fetchBullStocks() { + this.bullStocksLoading = true; + try { + const holding = (this.holdingStocks || []).join(','); + const resp = await axios.get('/api/bull_stocks', { + params: holding ? { holdingStocks: holding } : {} + }); + if (resp.data.success) { + this.bullStocksData = resp.data; // { stages, summary, total, stage_info } + } else { + this.showToast(resp.data.error || '获取牛股数据失败', 'error'); + } + } catch (err) { + console.error('获取牛股数据失败:', err); + this.showToast('获取牛股数据失败', 'error'); + } finally { + this.bullStocksLoading = false; + } + }, + + getBullStageStocks(stageNum) { + if (!this.bullStocksData || !this.bullStocksData.stages) return []; + return this.bullStocksData.stages[String(stageNum)] || []; + }, + + getBullStageCount(stageNum) { + if (!this.bullStocksData || !this.bullStocksData.summary) return 0; + return this.bullStocksData.summary[String(stageNum)] || this.bullStocksData.summary[stageNum] || 0; + }, + + getBullStageInfo(stageNum) { + if (!this.bullStocksData || !this.bullStocksData.stage_info) return null; + return this.bullStocksData.stage_info.find(s => s.stage === stageNum); + }, + + getScanRecommend(item) { + if (item.recommend_type != null && item.recommend_text != null) + return { text: item.recommend_text, cls: item.recommend_type }; + if (!item.signal_status || item.signal_status.length === 0) return { text: '观望', cls: 'watch' }; + const sigMap = {}; + item.signal_status.forEach(s => { sigMap[s.type] = s.triggered; }); + const hasDivergence = sigMap['daily_bottom_divergence']; + const hasDragon = sigMap['dragon_head']; + const hasMainWave = sigMap['main_rising_wave']; + const hasRealDragon = sigMap['true_dragon']; + const macd = (item.indicators || {}).macd || {}; + const dif = macd.dif, dea = macd.dea; + if (hasDivergence && hasDragon) return { text: '买入', cls: 'buy' }; + if (hasMainWave) return { text: '加仓', cls: 'buy' }; + if (hasRealDragon) return { text: '持有', cls: 'hold' }; + if (!hasMainWave && dif != null && dea != null && dif < dea) return { text: '卖出', cls: 'sell' }; + if (hasDivergence) return { text: '关注', cls: 'watch-active' }; + if (hasDragon) return { text: '关注', cls: 'watch-active' }; + if (item.triggered_count > 0) return { text: '观察', cls: 'watch' }; + return { text: '观望', cls: 'watch' }; + }, + + changeFullScanFilter(filter) { + this.fullScanFilter = filter; + this.fullScanFilterTypes = []; + this.fullScanPage = 1; + this.fetchFullScanResults(1); + }, + + changeFullScanFilterRecommend(recommend) { + this.fullScanFilterRecommend = recommend; + this.fullScanPage = 1; + this.fetchFullScanResults(1); + }, + getRecommendFilterClass(text) { + const map = { '买入': 'rec-buy', '加仓': 'rec-buy', '卖出': 'rec-sell', '持有': 'rec-hold', '关注': 'rec-watch', '观察': 'rec-watch', '观望': 'rec-watch' }; + return map[text] || 'rec-watch'; + }, + + toggleSignalFilter(type) { + const idx = this.fullScanFilterTypes.indexOf(type); + if (idx >= 0) { + this.fullScanFilterTypes.splice(idx, 1); + } else { + this.fullScanFilterTypes.push(type); + } + if (this.fullScanFilterTypes.length > 0) { + this.fullScanFilter = 'multi'; + } else { + this.fullScanFilter = 'triggered'; + } + this.fullScanPage = 1; + this.fetchFullScanResults(1); + }, + + async fetchTodaySignal() { + if (!this.todaySignalStock) { + this.todaySignalData = null; + return; + } + + const stockCode = this.todaySignalStock; + const stockItem = this.searchHistory.find(item => item.code === stockCode); + const today = new Date().toISOString().split('T')[0]; + + // 检查缓存 + if (this.analysisCache[stockCode]) { + this.processSignalData(stockCode, stockItem?.name, this.analysisCache[stockCode]); + return; + } + + // 获取最新分析 + try { + const response = await axios.post('/api/analyze', { + stock_code: stockCode, + start_date: this.startDate, + end_date: today + }); + + if (response.data.success) { + this.analysisCache[stockCode] = response.data; + this.processSignalData(stockCode, response.data.stock_name, response.data); + } else { + this.showToast('获取信号失败: ' + (response.data.error || '未知错误'), 'error'); + } + } catch (err) { + this.showToast('获取信号失败: ' + err.message, 'error'); + } + }, + + processSignalData(stockCode, stockName, analysisData) { + const latest = analysisData.data['最新数据']; + if (!latest) { + this.todaySignalData = null; + return; + } + + const pricePos = latest['价格位置'] || 50; + const superRatio = latest['超大单净流入占比'] || 0; + const mainRatio = latest['主力净流入占比'] || 0; + + // 生成信号 + const isLow = pricePos <= 50; + const isHigh = pricePos > 50; + const isSuperInflow = superRatio >= 2; + const isSuperOutflow = superRatio <= -2; + const isMainInflow = mainRatio >= 2; + const isMainOutflow = mainRatio <= -2; + + let signal = { type: '', icon: '⏸️', title: '观望信号', description: '当前无明确买卖信号,建议继续观察' }; + let reason = '无明确信号'; + + if (isLow && (isSuperInflow || isMainInflow)) { + signal = { + type: 'buy', + icon: '✅', + title: '买入信号', + description: `价格处于${pricePos <= 30 ? '低位' : '中低位'}(${pricePos.toFixed(1)}%),${isSuperInflow ? '超大单' : '主力'}大额流入,建议买入,仓位30-50%,止损-5%` + }; + reason = isSuperInflow ? '低位+超大单流入' : '低位+主力流入'; + } else if (isHigh && (isSuperOutflow || isMainOutflow)) { + signal = { + type: 'sell', + icon: '🔴', + title: '卖出信号', + description: `价格处于${pricePos >= 70 ? '高位' : '中高位'}(${pricePos.toFixed(1)}%),${isSuperOutflow ? '超大单' : '主力'}大额流出,建议卖出或减仓` + }; + reason = isSuperOutflow ? '高位+超大单流出' : '高位+主力流出'; + } + + this.todaySignalData = { + stock_code: stockCode, + stock_name: stockName, + date: latest['日期'], + price: latest['收盘价'], + pricePosition: pricePos, + superRatio: superRatio, + mainRatio: mainRatio, + signal: signal, + reason: reason + }; + }, + + quickTrade(tradeType) { + if (!this.todaySignalData) return; + + // 预填充交易表单 + this.newTrade.stock_code = this.todaySignalData.stock_code; + this.newTrade.stock_name = this.todaySignalData.stock_name; + this.newTrade.trade_type = tradeType; + this.newTrade.price = this.todaySignalData.price; + this.newTrade.trade_date = new Date().toISOString().split('T')[0]; + this.newTrade.reason = this.todaySignalData.reason; + this.selectedStockFromHistory = this.todaySignalData.stock_code; + this.selectedStockSignal = this.todaySignalData.signal; + + this.showTradeForm = true; + }, + + openTradeFormWithStock() { + if (!this.todaySignalData) return; + + this.newTrade.stock_code = this.todaySignalData.stock_code; + this.newTrade.stock_name = this.todaySignalData.stock_name; + this.newTrade.trade_date = new Date().toISOString().split('T')[0]; + this.newTrade.reason = this.todaySignalData.reason; + this.selectedStockFromHistory = this.todaySignalData.stock_code; + this.selectedStockSignal = this.todaySignalData.signal; + + this.showTradeForm = true; + }, + + applyAnalysisToTrade(analysisData) { + const latest = analysisData.data['最新数据']; + + if (!latest) { + this.selectedStockSignal = { + type: '', + icon: '⚠️', + title: '无最新数据', + description: '无法获取最新分析数据' + }; + return; + } + + const pricePos = latest['价格位置'] || 50; + const superRatio = latest['超大单净流入占比'] || 0; + const mainRatio = latest['主力净流入占比'] || 0; + + // 判断信号 + const isLow = pricePos <= 50; + const isHigh = pricePos > 50; + const isSuperInflow = superRatio >= 2; + const isSuperOutflow = superRatio <= -2; + const isMainInflow = mainRatio >= 2; + const isMainOutflow = mainRatio <= -2; + + // 生成信号描述 + let signal = { type: '', icon: '⏸️', title: '观望信号', description: '当前无明确买卖信号' }; + + if (isLow && (isSuperInflow || isMainInflow)) { + signal = { + type: 'buy', + icon: '✅', + title: '买入信号', + description: `价格处于${pricePos <= 30 ? '低位' : '中低位'}(${pricePos.toFixed(1)}%),${isSuperInflow ? '超大单' : '主力'}大额流入` + }; + this.newTrade.reason = isLow && isSuperInflow ? '低位+超大单流入' : '低位+主力流入'; + } else if (isHigh && (isSuperOutflow || isMainOutflow)) { + signal = { + type: 'sell', + icon: '🔴', + title: '卖出信号', + description: `价格处于${pricePos >= 70 ? '高位' : '中高位'}(${pricePos.toFixed(1)}%),${isSuperOutflow ? '超大单' : '主力'}大额流出` + }; + this.newTrade.reason = isHigh && isSuperOutflow ? '高位+超大单流出' : '高位+主力流出'; + } else { + this.newTrade.reason = '无明确信号-谨慎操作'; + } + + this.selectedStockSignal = signal; + }, + + calculateTradeStats() { + const completedTrades = this.trades.filter(t => t.result !== 'pending'); + const profitTrades = completedTrades.filter(t => t.result === 'profit').length; + const lossTrades = completedTrades.filter(t => t.result === 'loss').length; + + // 用平均成本法计算所有已实现盈亏(从卖出交易中计算) + let realizedProfit = 0; + const stockGroups = {}; + + // 按股票分组并按时间排序 + this.trades.forEach(trade => { + const code = trade.stock_code; + if (!stockGroups[code]) { + stockGroups[code] = []; + } + stockGroups[code].push(trade); + }); + + // 计算每只股票的已实现盈亏 + Object.values(stockGroups).forEach(trades => { + // 按时间排序(从早到晚) + trades.sort((a, b) => { + if (a.trade_date !== b.trade_date) { + return a.trade_date.localeCompare(b.trade_date); + } + return (a.created_at || '').localeCompare(b.created_at || ''); + }); + + let holdingQty = 0; + let holdingCost = 0; + + trades.forEach(trade => { + const qty = parseInt(trade.quantity) || 0; + const price = parseFloat(trade.price) || 0; + + if (trade.trade_type === 'buy') { + holdingCost += qty * price; + holdingQty += qty; + } else if (trade.trade_type === 'sell' && holdingQty > 0) { + // 计算平均成本 + const avgCost = holdingCost / holdingQty; + // 已实现盈亏 = (卖出价 - 平均成本) × 卖出数量 + realizedProfit += (price - avgCost) * qty; + // 更新持仓 + holdingCost -= avgCost * qty; + holdingQty -= qty; + } + }); + }); + + // 计算总市值和总成本(基于现价,仅计算持仓股票) + let totalMarketValue = 0; + let totalCost = 0; + Object.keys(this.holdingPositions).forEach(code => { + const pos = this.holdingPositions[code]; + if (pos.quantity > 0) { + totalMarketValue += pos.quantity * (pos.currentPrice || 0); + totalCost += pos.totalCost; + } + }); + + // 浮动盈亏 = 总市值 - 总成本 + const unrealizedProfit = totalMarketValue - totalCost; + // 总盈亏 = 已实现盈亏 + 浮动盈亏 + const totalProfit = realizedProfit + unrealizedProfit; + + this.tradeStats = { + total_trades: this.trades.length, + completed_trades: completedTrades.length, + profit_trades: profitTrades, + loss_trades: lossTrades, + win_rate: completedTrades.length > 0 ? (profitTrades / completedTrades.length * 100) : 0, + total_profit: totalProfit, + total_market_value: totalMarketValue, + total_cost: totalCost, + unrealized_profit: unrealizedProfit, + realized_profit: realizedProfit // 新增:已实现盈亏 + }; + }, + + // ========== 模拟交易相关方法 ========== + + async loadSimData() { + // 加载模拟交易数据(含智能引擎数据) + await Promise.all([ + this.loadSimStats(), + this.loadSimPositions(), + this.loadSimTrades(), + this.loadSmartAlgoConfig(), + this.loadSmartTemplates(), + this.loadSmartSignals(), + this.loadSmartPositionMeta(), + ]); + }, + + async loadSimStats() { + try { + const response = await axios.get('/api/sim/stats'); + if (response.data.success) { + this.simStats = response.data.stats; + } + } catch (e) { + console.error('加载模拟交易统计失败', e); + } + }, + + async loadSimPositions() { + try { + const response = await axios.get('/api/sim/positions'); + if (response.data.success) { + this.simPositions = response.data.positions; + } + } catch (e) { + console.error('加载模拟持仓失败', e); + } + }, + + async loadSimTrades() { + try { + const response = await axios.get('/api/sim/trades'); + if (response.data.success) { + this.simTrades = response.data.trades; + } + } catch (e) { + console.error('加载模拟交易记录失败', e); + } + }, + + // ========== 智能交易引擎方法 ========== + + async loadSmartAlgoConfig() { + try { + const resp = await axios.get('/api/smart/config'); + if (resp.data.success) { + this.smartAlgoConfig = resp.data.config; + this.smartAlgoIsDefault = resp.data.is_default; + } + } catch (e) { + console.error('加载算法配置失败', e); + } + }, + + async loadSmartTemplates() { + try { + const resp = await axios.get('/api/smart/templates'); + if (resp.data.success) { + this.smartTemplates = resp.data.templates; + } + } catch (e) { + console.error('加载算法模板失败', e); + } + }, + + async loadSmartSignals() { + try { + const resp = await axios.get('/api/smart/signals?limit=30&days=7'); + if (resp.data.success) { + this.smartSignals = resp.data.signals; + } + } catch (e) { + console.error('加载信号日志失败', e); + } + }, + + async loadSmartPositionMeta() { + try { + const resp = await axios.get('/api/smart/position_meta'); + if (resp.data.success) { + this.smartPositionMeta = resp.data.positions; + } + } catch (e) { + console.error('加载持仓元数据失败', e); + } + }, + + async applyAlgoTemplate(templateName) { + try { + const resp = await axios.post('/api/smart/apply_template', { template_name: templateName }); + if (resp.data.success) { + this.showToast(resp.data.message, 'success'); + await this.loadSmartAlgoConfig(); + this.smartConfigEditing = false; + } else { + this.showToast(resp.data.error, 'error'); + } + } catch (e) { + this.showToast('应用模板失败: ' + (e.response?.data?.error || e.message), 'error'); + } + }, + + async saveSmartConfig() { + if (!this.smartAlgoConfig) return; + try { + const resp = await axios.post('/api/smart/config', this.smartAlgoConfig); + if (resp.data.success) { + this.showToast('算法配置已保存', 'success'); + this.smartConfigEditing = false; + this.smartAlgoIsDefault = false; + } else { + this.showToast(resp.data.error, 'error'); + } + } catch (e) { + this.showToast('保存失败: ' + (e.response?.data?.error || e.message), 'error'); + } + }, + + getPositionMeta(stockCode) { + return this.smartPositionMeta.find(m => m.stock_code === stockCode) || null; + }, + + getActiveRules(stockCode) { + const meta = this.getPositionMeta(stockCode); + if (!meta) return []; + const rules = []; + if (meta.breakeven_active) rules.push('🛡️保本'); + if (meta.momentum_trailing_active) rules.push('📈跟踪'); + if (meta.partial_exit_done) rules.push('✂️已减仓'); + return rules; + }, + + async executeAutoTrade() { + // 使用智能引擎执行交易 + this.simAutoTrading = true; + + try { + this.showToast('正在执行智能交易引擎...', 'info'); + + // 优先使用智能引擎(30秒超时) + const response = await axios.post('/api/smart/trigger', {}, { timeout: 30000 }); + + if (response.data.success) { + const results = response.data.results || []; + const buyCount = results.filter(r => r.type === 'buy').length; + const sellCount = results.filter(r => ['sell', 'partial_sell'].includes(r.type)).length; + const algo = response.data.algo || '?'; + const fees = response.data.total_fees || 0; + const detailReasons = response.data.detail_reasons || []; + const availableCash = response.data.available_cash || 0; + + if (buyCount === 0 && sellCount === 0) { + // 无交易 — 用模态框显示详细原因 + this.smartResultData = { + algo: algo, + type: 'no_trade', + title: '智能引擎执行完成', + reasons: detailReasons.length > 0 ? detailReasons : ['当前没有符合条件的交易信号'], + results: [], + fees: 0, + availableCash: availableCash, + summary: '无交易操作' + }; + this.smartResultVisible = true; + } else { + // 有交易 — 用模态框显示交易结果 + 原因 + this.smartResultData = { + algo: algo, + type: 'traded', + title: '智能引擎执行完成', + reasons: detailReasons, + results: results, + fees: fees, + availableCash: availableCash, + summary: `买入${buyCount}笔, 卖出${sellCount}笔` + }; + this.smartResultVisible = true; + } + + // 刷新数据 + await this.loadSimData(); + } else { + this.showToast(response.data.error || '智能交易失败', 'error'); + } + } catch (e) { + console.error('智能交易失败', e); + this.showToast('智能交易失败: ' + (e.response?.data?.error || e.message), 'error'); + } finally { + this.simAutoTrading = false; + } + }, + + // 智能执行结果模态框 — 原因分类 + getReasonClass(reason) { + if (reason.includes('T+1') || reason.includes('限制')) return 'reason-limit'; + if (reason.includes('涨停') || reason.includes('跌停')) return 'reason-limit'; + if (reason.includes('可用现金') || reason.includes('资金')) return 'reason-cash'; + if (reason.includes('卖出') || reason.includes('止盈') || reason.includes('止损')) return 'reason-sell'; + if (reason.includes('买入')) return 'reason-buy'; + return 'reason-info'; + }, + getReasonIcon(reason) { + if (reason.includes('T+1')) return '🔒'; + if (reason.includes('涨停') || reason.includes('跌停')) return '🚫'; + if (reason.includes('可用现金') || reason.includes('资金')) return '💰'; + if (reason.includes('卖出') || reason.includes('止盈')) return '📤'; + if (reason.includes('止损')) return '🛡️'; + if (reason.includes('买入')) return '📥'; + return 'ℹ️'; + }, + + confirmResetSim() { + this.showConfirm('确定要重置模拟交易吗?所有交易记录和持仓将被清空。', async () => { + try { + const response = await axios.post('/api/sim/reset'); + if (response.data.success) { + this.showToast('模拟交易已重置', 'success'); + this.simStats = {}; + this.simPositions = []; + this.simTrades = []; + } else { + this.showToast(response.data.error || '重置失败', 'error'); + } + } catch (e) { + this.showToast('重置失败', 'error'); + } + }); + }, + + async updateSimPrices() { + if (this.simPositions.length === 0) return; + this.simPriceRefreshing = true; + const prices = {}; + try { + await Promise.all(this.simPositions.map(async (pos) => { + try { + const resp = await axios.get(`/api/realtime_price/${pos.stock_code}`, { timeout: 12000 }); + if (resp.data.success && (resp.data.price ?? resp.data.data?.price) != null) { + const p = Number(resp.data.price ?? resp.data.data?.price); + prices[pos.stock_code] = p; + pos.current_price = p; + } + } catch (e) { + console.error(`获取${pos.stock_code}价格失败`, e); + } + })); + if (Object.keys(prices).length > 0) { + await axios.post('/api/sim/update_prices', { prices }); + await this.loadSimStats(); + } + } finally { + this.simPriceRefreshing = false; + } + } + } + }).mount('#app'); diff --git a/stock-html/static/manifest.json b/stock-html/static/manifest.json new file mode 100644 index 0000000..1ba13b2 --- /dev/null +++ b/stock-html/static/manifest.json @@ -0,0 +1,18 @@ +{ + "name": "股票投资", + "short_name": "股票投资", + "description": "股票投资分析与管理系统", + "start_url": "/", + "display": "standalone", + "background_color": "#0a0a0a", + "theme_color": "#0a0a0a", + "orientation": "portrait", + "icons": [ + { + "src": "/static/icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ] +} diff --git a/stock-html/stock-data-service.service b/stock-html/stock-data-service.service new file mode 100644 index 0000000..3c42a34 --- /dev/null +++ b/stock-html/stock-data-service.service @@ -0,0 +1,19 @@ +[Unit] +Description=Stock Data Collection Service +After=network.target postgresql.service + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/stock-app +Environment="DB_HOST=localhost" +Environment="DB_PORT=5432" +Environment="DB_NAME=stock_app" +Environment="DB_USER=postgres" +Environment="DB_PASSWORD=stock_password_2025" +ExecStart=/opt/stock-app/venv/bin/python stock_data_service.py daemon +Restart=always +RestartSec=30 + +[Install] +WantedBy=multi-user.target diff --git a/stock-html/stock_data_service.log b/stock-html/stock_data_service.log new file mode 100644 index 0000000..fbd0afe --- /dev/null +++ b/stock-html/stock_data_service.log @@ -0,0 +1,11 @@ +2026-02-23 08:57:20,177 [INFO] 开始更新实时价格... +2026-02-23 08:58:35,748 [INFO] 实时价格更新完成: 5810 条 +2026-02-23 08:59:04,812 [INFO] 开始更新今日资金流向... +2026-02-23 08:59:16,109 [INFO] 今日资金流向更新完成: 5270 条 +2026-02-23 08:59:24,434 [INFO] 开始更新历史资金流向... +2026-02-23 08:59:24,563 [INFO] 需要更新 2 只股票 +2026-02-23 08:59:25,341 [INFO] 600519: 120 条 +2026-02-23 08:59:25,410 [INFO] 000001: 120 条 +2026-02-23 08:59:25,412 [INFO] 历史资金流向更新完成: 240 条, 错误: 0 +2026-02-25 10:19:05,463 [INFO] 开始更新实时价格... +2026-02-25 10:19:18,105 [ERROR] 更新实时价格失败: ('Connection aborted.', RemoteDisconnected('Remote end closed connection without response')) diff --git a/stock-html/stock_data_service.py b/stock-html/stock_data_service.py new file mode 100644 index 0000000..f275390 --- /dev/null +++ b/stock-html/stock_data_service.py @@ -0,0 +1,574 @@ +#!/usr/bin/env python3 +""" +股票数据采集服务 +定时采集实时行情数据并存入数据库 +数据源: 腾讯财经 (qt.gtimg.cn) +""" +import os +import sys +import time +import logging +import schedule +import psycopg2 +from psycopg2.extras import execute_values +from datetime import datetime + +# 配置日志 +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s [%(levelname)s] %(message)s', + handlers=[ + logging.StreamHandler(), + logging.FileHandler('stock_data_service.log') + ] +) +logger = logging.getLogger(__name__) + +# 数据库配置 +DB_CONFIG = { + 'host': os.environ.get('DB_HOST', 'localhost'), + 'port': int(os.environ.get('DB_PORT', 5432)), + 'database': os.environ.get('DB_NAME', 'stock_app'), + 'user': os.environ.get('DB_USER', 'postgres'), + 'password': os.environ.get('DB_PASSWORD', '') +} + + +def get_db(): + """获取数据库连接""" + try: + return psycopg2.connect(**DB_CONFIG) + except Exception as e: + logger.error(f"数据库连接失败: {e}") + return None + + +def log_update(data_type, status, records_count=0, error_message=None, started_at=None): + """记录更新日志""" + conn = get_db() + if not conn: + return + try: + cur = conn.cursor() + cur.execute(""" + INSERT INTO data_update_log (data_type, status, records_count, error_message, started_at) + VALUES (%s, %s, %s, %s, %s) + """, (data_type, status, records_count, error_message, started_at)) + conn.commit() + except Exception as e: + logger.error(f"记录日志失败: {e}") + finally: + conn.close() + + +def _to_tencent_code(code): + """将纯数字股票代码转为腾讯格式 (sh/sz/bj前缀)""" + if code.startswith('6'): + return f'sh{code}' + elif code.startswith('0') or code.startswith('3'): + return f'sz{code}' + elif code.startswith('8') or code.startswith('4'): + return f'bj{code}' + else: + return f'sz{code}' + + +def _fetch_realtime_from_tencent(stock_codes): + """ + 从腾讯财经API批量获取实时行情(最佳数据源,腾讯云极快) + 腾讯API字段(88个)关键映射: + [1]=名称 [2]=代码 [3]=现价 [4]=昨收 [5]=开盘 + [6]=成交量(手) [31]=涨跌额 [32]=涨跌% [33]=最高 [34]=最低 + [37]=成交额(万) [39]=市盈率 [45]=总市值(亿) [46]=市净率 + """ + import requests + import math + + def safe_float(val, default=0): + try: + if val is None or val == '' or val == ' ': + return default + f = float(val) + return default if math.isnan(f) else f + except: + return default + + logger.info(f" 使用腾讯财经数据源 ({len(stock_codes)} 只股票)...") + tencent_codes = [_to_tencent_code(c) for c in stock_codes] + + records = [] + batch_size = 80 + errors = 0 + + for i in range(0, len(tencent_codes), batch_size): + batch = tencent_codes[i:i+batch_size] + url = f"http://qt.gtimg.cn/q={','.join(batch)}" + try: + r = requests.get(url, timeout=15, headers={'Referer': 'https://finance.qq.com'}) + if r.status_code != 200: + errors += 1 + continue + + lines = r.text.strip().split(';') + for line in lines: + if '\"' not in line: + continue + data = line.split('\"')[1] + fields = data.split('~') + if len(fields) < 40 or not fields[3]: + continue + + code = fields[2] + price = safe_float(fields[3]) + if price <= 0: + continue + + # 成交量: 腾讯API返回的是手(1手=100股) + volume_hands = safe_float(fields[6]) + volume = int(volume_hands * 100) + # 成交额: 万元 -> 元 + amount = safe_float(fields[37]) * 10000 + # 总市值: 亿元 -> 元 + total_market_cap_yi = safe_float(fields[45]) if len(fields) > 45 else 0 + total_market_cap = total_market_cap_yi * 100000000 if total_market_cap_yi > 0 else None + + records.append(( + code, # 代码 + fields[1], # 名称 + price, # 现价 + safe_float(fields[32]), # 涨跌% + safe_float(fields[31]), # 涨跌额 + volume, # 成交量(股) + amount, # 成交额(元) + safe_float(fields[33]), # 最高 + safe_float(fields[34]), # 最低 + safe_float(fields[5]), # 开盘 + safe_float(fields[4]), # 昨收 + safe_float(fields[39]) if len(fields) > 39 and fields[39].strip() else None, # PE + safe_float(fields[46]) if len(fields) > 46 and fields[46].strip() else None, # PB + total_market_cap, # 总市值 + )) + except Exception as e: + errors += 1 + if errors <= 3: + logger.warning(f" 腾讯API批次 {i//batch_size+1} 失败: {e}") + + import time + time.sleep(0.1) # 控制请求频率 + + if errors > 0: + logger.warning(f" 腾讯API共 {errors} 个批次失败") + + return records if records else None, 'tencent' + + +def update_realtime_prices(): + """更新实时价格(全市场A股)- 使用腾讯财经数据源""" + started_at = datetime.now() + logger.info("开始更新实时价格...") + + conn = get_db() + if not conn: + log_update('realtime_price', 'failed', 0, '数据库连接失败', started_at) + return + + try: + # 先从DB获取已有的股票代码列表 + cur = conn.cursor() + cur.execute("SELECT code FROM stock_realtime_price") + existing_codes = [r[0] for r in cur.fetchall()] + + records = None + source = None + + # 腾讯财经数据源 + if existing_codes: + try: + result = _fetch_realtime_from_tencent(existing_codes) + if result and result[0]: + records, source = result + except Exception as e: + logger.warning(f" 腾讯数据源失败: {e}") + + if not records: + log_update('realtime_price', 'failed', 0, '所有数据源均失败', started_at) + return + + logger.info(f" 数据源={source}, 获取 {len(records)} 条记录") + + # 批量插入/更新 + cur = conn.cursor() + execute_values(cur, """ + INSERT INTO stock_realtime_price + (code, name, price, change_pct, change_amount, volume, amount, + high, low, open, prev_close, pe, pb, total_market_cap, updated_at) + VALUES %s + ON CONFLICT (code) DO UPDATE SET + name = EXCLUDED.name, + price = EXCLUDED.price, + change_pct = EXCLUDED.change_pct, + change_amount = EXCLUDED.change_amount, + volume = EXCLUDED.volume, + amount = EXCLUDED.amount, + high = EXCLUDED.high, + low = EXCLUDED.low, + open = EXCLUDED.open, + prev_close = EXCLUDED.prev_close, + pe = COALESCE(EXCLUDED.pe, stock_realtime_price.pe), + pb = COALESCE(EXCLUDED.pb, stock_realtime_price.pb), + total_market_cap = COALESCE(EXCLUDED.total_market_cap, stock_realtime_price.total_market_cap), + updated_at = NOW() + """, records, template="(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())") + + conn.commit() + logger.info(f"实时价格更新完成({source}): {len(records)} 条") + log_update('realtime_price', 'success', len(records), f'source={source}', started_at) + + except Exception as e: + logger.error(f"更新实时价格失败: {e}") + log_update('realtime_price', 'failed', 0, str(e), started_at) + finally: + conn.close() + + +def _calc_fund_flow_from_5min(conn, target_date=None): + """ + 从5分钟K线数据计算资金流向(替代东方财富API) + + 算法: + 1. 每根5分钟K线根据 close vs open 判断方向(买入/卖出) + 2. 根据成交额(amount)分类: + - 超大单: amount >= 100万 + - 大单: 20万 <= amount < 100万 + - 中单: 4万 <= amount < 20万 + - 小单: amount < 4万 + 3. 主力 = 超大单 + 大单 + 4. 聚合每只股票的各类净流入 + """ + from datetime import date as date_cls + if target_date is None: + target_date = date_cls.today() + + cur = conn.cursor() + # 获取当天所有5分钟K线数据 + cur.execute(""" + SELECT code, open, close, volume, amount + FROM stock_kline_5min + WHERE dt::date = %s AND amount > 0 + ORDER BY code, dt + """, (target_date,)) + rows = cur.fetchall() + + if not rows: + return {} + + # 按股票聚合 + from collections import defaultdict + stock_flows = defaultdict(lambda: { + 'super_buy': 0, 'super_sell': 0, + 'big_buy': 0, 'big_sell': 0, + 'mid_buy': 0, 'mid_sell': 0, + 'small_buy': 0, 'small_sell': 0, + 'total_amount': 0 + }) + + for code, open_p, close_p, volume, amount in rows: + if not amount or float(amount) <= 0: + continue + + amt = float(amount) + sf = stock_flows[code] + sf['total_amount'] += amt + + # 方向: close > open 视为买入, close < open 视为卖出, 相等则各半 + is_buy = float(close_p) >= float(open_p) if close_p and open_p else True + + # 分类 + if amt >= 1000000: # 超大单 >= 100万 + cat = 'super' + elif amt >= 200000: # 大单 >= 20万 + cat = 'big' + elif amt >= 40000: # 中单 >= 4万 + cat = 'mid' + else: # 小单 + cat = 'small' + + if is_buy: + sf[f'{cat}_buy'] += amt + else: + sf[f'{cat}_sell'] += amt + + # 计算各类净流入和占比 + results = {} + for code, sf in stock_flows.items(): + total = sf['total_amount'] + if total <= 0: + continue + + super_net = sf['super_buy'] - sf['super_sell'] + big_net = sf['big_buy'] - sf['big_sell'] + mid_net = sf['mid_buy'] - sf['mid_sell'] + small_net = sf['small_buy'] - sf['small_sell'] + main_net = super_net + big_net # 主力 = 超大单 + 大单 + + results[code] = { + 'main_net_inflow': round(main_net, 2), + 'main_net_inflow_pct': round(main_net / total * 100, 4) if total > 0 else 0, + 'super_net_inflow': round(super_net, 2), + 'super_net_inflow_pct': round(super_net / total * 100, 4) if total > 0 else 0, + 'big_net_inflow': round(big_net, 2), + 'big_net_inflow_pct': round(big_net / total * 100, 4) if total > 0 else 0, + 'mid_net_inflow': round(mid_net, 2), + 'mid_net_inflow_pct': round(mid_net / total * 100, 4) if total > 0 else 0, + 'small_net_inflow': round(small_net, 2), + 'small_net_inflow_pct': round(small_net / total * 100, 4) if total > 0 else 0, + } + + return results + + +def update_fund_flow_today(): + """更新今日资金流向 — 从5分钟K线数据自行计算""" + started_at = datetime.now() + logger.info("开始更新今日资金流向(从5分钟K线数据计算)...") + + conn = get_db() + if not conn: + log_update('fund_flow_today', 'failed', 0, '数据库连接失败', started_at) + return + + try: + flows = _calc_fund_flow_from_5min(conn) + if not flows: + logger.info("今日资金流向: 无5分钟K线数据,跳过") + log_update('fund_flow_today', 'skipped', 0, '无5分钟K线数据', started_at) + return + + # 获取实时价格和名称 + cur = conn.cursor() + codes = list(flows.keys()) + cur.execute(""" + SELECT code, name, price, change_pct + FROM stock_realtime_price + WHERE code = ANY(%s) + """, (codes,)) + price_map = {} + for row in cur.fetchall(): + price_map[row[0]] = {'name': row[1], 'price': float(row[2] or 0), 'change_pct': float(row[3] or 0)} + + # 批量写入 + records = [] + for code, f in flows.items(): + info = price_map.get(code, {}) + records.append(( + code, info.get('name', ''), + f['main_net_inflow'], f['main_net_inflow_pct'], + f['super_net_inflow'], f['super_net_inflow_pct'], + f['big_net_inflow'], f['big_net_inflow_pct'], + f['mid_net_inflow'], f['mid_net_inflow_pct'], + f['small_net_inflow'], f['small_net_inflow_pct'], + info.get('price', 0), info.get('change_pct', 0), + )) + + execute_values(cur, """ + INSERT INTO stock_fund_flow_today + (code, name, main_net_inflow, main_net_inflow_pct, + super_net_inflow, super_net_inflow_pct, + big_net_inflow, big_net_inflow_pct, + mid_net_inflow, mid_net_inflow_pct, + small_net_inflow, small_net_inflow_pct, + price, change_pct, updated_at) + VALUES %s + ON CONFLICT (code) DO UPDATE SET + name = EXCLUDED.name, + main_net_inflow = EXCLUDED.main_net_inflow, + main_net_inflow_pct = EXCLUDED.main_net_inflow_pct, + super_net_inflow = EXCLUDED.super_net_inflow, + super_net_inflow_pct = EXCLUDED.super_net_inflow_pct, + big_net_inflow = EXCLUDED.big_net_inflow, + big_net_inflow_pct = EXCLUDED.big_net_inflow_pct, + mid_net_inflow = EXCLUDED.mid_net_inflow, + mid_net_inflow_pct = EXCLUDED.mid_net_inflow_pct, + small_net_inflow = EXCLUDED.small_net_inflow, + small_net_inflow_pct = EXCLUDED.small_net_inflow_pct, + price = EXCLUDED.price, + change_pct = EXCLUDED.change_pct, + updated_at = NOW() + """, records, + template="(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())") + + conn.commit() + logger.info(f"今日资金流向更新完成: {len(records)} 条 (来源: 5分钟K线计算)") + log_update('fund_flow_today', 'success', len(records), '来源: 5分钟K线计算', started_at) + + except Exception as e: + logger.error(f"更新今日资金流向失败: {e}") + log_update('fund_flow_today', 'failed', 0, str(e), started_at) + finally: + conn.close() + + +def update_fund_flow_history(stock_codes=None): + """更新历史资金流向 — 从5分钟K线数据自行计算 + + 遍历有5分钟K线但尚未写入fund_flow_history的日期,补算资金流向 + """ + started_at = datetime.now() + logger.info("开始更新历史资金流向(从5分钟K线数据计算)...") + + conn = get_db() + if not conn: + log_update('fund_flow_history', 'failed', 0, '数据库连接失败', started_at) + return + + try: + cur = conn.cursor() + + # 查找有5分钟K线数据但尚未计算资金流向的日期 + cur.execute(""" + SELECT DISTINCT dt::date as d + FROM stock_kline_5min + WHERE dt::date NOT IN ( + SELECT DISTINCT trade_date FROM stock_fund_flow_history + ) + AND dt::date < CURRENT_DATE + ORDER BY d DESC + LIMIT 30 + """) + missing_dates = [row[0] for row in cur.fetchall()] + + if not missing_dates: + logger.info("历史资金流向: 无需补算") + log_update('fund_flow_history', 'success', 0, '无需补算', started_at) + return + + logger.info(f"需补算 {len(missing_dates)} 天的历史资金流向") + + total_records = 0 + for d in missing_dates: + flows = _calc_fund_flow_from_5min(conn, target_date=d) + if not flows: + continue + + # 获取当天收盘价和涨跌幅 + cur.execute(""" + SELECT code, close, change_pct + FROM stock_kline_daily + WHERE trade_date = %s AND code = ANY(%s) + """, (d, list(flows.keys()))) + price_map = {} + for row in cur.fetchall(): + price_map[row[0]] = {'close': float(row[1] or 0), 'change_pct': float(row[2] or 0)} + + records = [] + for code, f in flows.items(): + info = price_map.get(code, {}) + records.append(( + code, d, + info.get('close', 0), info.get('change_pct', 0), + f['main_net_inflow'], f['main_net_inflow_pct'], + f['super_net_inflow'], f['super_net_inflow_pct'], + f['big_net_inflow'], f['big_net_inflow_pct'], + f['mid_net_inflow'], f['mid_net_inflow_pct'], + f['small_net_inflow'], f['small_net_inflow_pct'], + )) + + if records: + execute_values(cur, """ + INSERT INTO stock_fund_flow_history + (code, trade_date, close_price, change_pct, + main_net_inflow, main_net_inflow_pct, + super_net_inflow, super_net_inflow_pct, + big_net_inflow, big_net_inflow_pct, + mid_net_inflow, mid_net_inflow_pct, + small_net_inflow, small_net_inflow_pct, + updated_at) + VALUES %s + ON CONFLICT (code, trade_date) DO UPDATE SET + close_price = EXCLUDED.close_price, + change_pct = EXCLUDED.change_pct, + main_net_inflow = EXCLUDED.main_net_inflow, + main_net_inflow_pct = EXCLUDED.main_net_inflow_pct, + super_net_inflow = EXCLUDED.super_net_inflow, + super_net_inflow_pct = EXCLUDED.super_net_inflow_pct, + big_net_inflow = EXCLUDED.big_net_inflow, + big_net_inflow_pct = EXCLUDED.big_net_inflow_pct, + mid_net_inflow = EXCLUDED.mid_net_inflow, + mid_net_inflow_pct = EXCLUDED.mid_net_inflow_pct, + small_net_inflow = EXCLUDED.small_net_inflow, + small_net_inflow_pct = EXCLUDED.small_net_inflow_pct, + updated_at = NOW() + """, records, + template="(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())") + conn.commit() + total_records += len(records) + logger.info(f" {d}: {len(records)} 只股票") + + logger.info(f"历史资金流向补算完成: {total_records} 条记录,{len(missing_dates)} 天") + log_update('fund_flow_history', 'success', total_records, + f'补算{len(missing_dates)}天, 来源: 5分钟K线计算', started_at) + + except Exception as e: + logger.error(f"更新历史资金流向失败: {e}") + log_update('fund_flow_history', 'failed', 0, str(e), started_at) + finally: + conn.close() + + +def is_trading_time(): + """检查当前是否为交易时间""" + now = datetime.now() + # 周一到周五 + if now.weekday() >= 5: + return False + # 9:15-11:35, 12:55-15:05 + hour_min = now.hour * 100 + now.minute + return (915 <= hour_min <= 1135) or (1255 <= hour_min <= 1505) + + +def run_scheduled_tasks(): + """运行定时任务""" + logger.info("股票数据采集服务启动...") + + # 交易时间每5分钟更新实时价格(腾讯财经数据源) + schedule.every(5).minutes.do(lambda: update_realtime_prices() if is_trading_time() else None) + + # 每天18:05更新今日资金流向(从5分钟K线计算,需在5分钟K线采集17:30后) + schedule.every().day.at("18:05").do(update_fund_flow_today) + + # 每天18:15补算历史资金流向(从5分钟K线计算) + schedule.every().day.at("18:15").do(update_fund_flow_history) + + # 立即执行一次 + logger.info("首次执行数据更新...") + update_realtime_prices() + + # 主循环 + while True: + schedule.run_pending() + time.sleep(60) + + +def main(): + """主函数""" + if len(sys.argv) > 1: + cmd = sys.argv[1] + if cmd == 'realtime': + update_realtime_prices() + elif cmd == 'fund_today': + update_fund_flow_today() + elif cmd == 'fund_history': + stock_codes = sys.argv[2:] if len(sys.argv) > 2 else None + update_fund_flow_history(stock_codes) + elif cmd == 'daemon': + run_scheduled_tasks() + else: + print(f"未知命令: {cmd}") + print("用法: python stock_data_service.py [realtime|fund_today|fund_history|daemon]") + else: + # 默认执行一次实时价格更新 + update_realtime_prices() + + +if __name__ == '__main__': + main() diff --git a/stock-html/stock_names.json b/stock-html/stock_names.json new file mode 100644 index 0000000..50fc4f7 --- /dev/null +++ b/stock-html/stock_names.json @@ -0,0 +1,5475 @@ +{ + "000001": "平安银行", + "000002": "万 科A", + "000004": "*ST国华", + "000006": "深振业A", + "000007": "全新好", + "000008": "神州高铁", + "000009": "中国宝安", + "000010": "美丽生态", + "000011": "深物业A", + "000012": "南 玻A", + "000014": "沙河股份", + "000016": "深康佳A", + "000017": "深中华A", + "000019": "深粮控股", + "000020": "深华发A", + "000021": "深科技", + "000025": "特 力A", + "000026": "飞亚达", + "000027": "深圳能源", + "000028": "国药一致", + "000029": "深深房A", + "000030": "富奥股份", + "000031": "大悦城", + "000032": "深桑达A", + "000034": "神州数码", + "000035": "中国天楹", + "000036": "华联控股", + "000037": "深南电A", + "000039": "中集集团", + "000042": "中洲控股", + "000045": "深纺织A", + "000048": "京基智农", + "000049": "德赛电池", + "000050": "深天马A", + "000055": "方大集团", + "000056": "皇庭国际", + "000058": "深 赛 格", + "000059": "华锦股份", + "000060": "中金岭南", + "000061": "农 产 品", + "000062": "深圳华强", + "000063": "中兴通讯", + "000065": "北方国际", + "000066": "中国长城", + "000068": "华控赛格", + "000069": "华侨城A", + "000070": "特发信息", + "000078": "海王生物", + "000088": "盐 田 港", + "000089": "深圳机场", + "000090": "天健集团", + "000096": "广聚能源", + "000099": "中信海直", + "000100": "TCL科技", + "000151": "中成股份", + "000153": "丰原药业", + "000155": "川能动力", + "000156": "华数传媒", + "000157": "中联重科", + "000158": "常山北明", + "000159": "国际实业", + "000166": "申万宏源", + "000301": "东方盛虹", + "000333": "美的集团", + "000338": "潍柴动力", + "000400": "许继电气", + "000401": "金隅冀东", + "000402": "金 融 街", + "000403": "派林生物", + "000404": "长虹华意", + "000407": "胜利股份", + "000408": "藏格矿业", + "000409": "云鼎科技", + "000410": "沈阳机床", + "000411": "英特集团", + "000415": "渤海租赁", + "000417": "合百集团", + "000419": "通程控股", + "000420": "吉林化纤", + "000421": "南京公用", + "000422": "湖北宜化", + "000423": "东阿阿胶", + "000425": "徐工机械", + "000426": "兴业银锡", + "000428": "华天酒店", + "000429": "粤高速A", + "000430": "*ST张股", + "000488": "ST晨鸣", + "000498": "山东路桥", + "000501": "武商集团", + "000503": "国新健康", + "000504": "*ST生物", + "000505": "京粮控股", + "000506": "招金黄金", + "000507": "珠海港", + "000509": "华塑控股", + "000510": "新金路", + "000513": "丽珠集团", + "000514": "渝 开 发", + "000516": "国际医学", + "000517": "荣安地产", + "000518": "*ST四环", + "000519": "中兵红箭", + "000520": "凤凰航运", + "000521": "长虹美菱", + "000523": "红棉股份", + "000524": "岭南控股", + "000525": "红太阳", + "000526": "学大教育", + "000528": "柳 工", + "000529": "广弘控股", + "000530": "冰山冷热", + "000531": "穗恒运A", + "000532": "华金资本", + "000533": "顺钠股份", + "000534": "万泽股份", + "000536": "华映科技", + "000537": "绿发电力", + "000538": "云南白药", + "000539": "粤电力A", + "000541": "佛山照明", + "000543": "皖能电力", + "000544": "中原环保", + "000545": "金浦钛业", + "000546": "金圆股份", + "000547": "航天发展", + "000548": "湖南投资", + "000550": "江铃汽车", + "000551": "创元科技", + "000552": "甘肃能化", + "000553": "安道麦A", + "000554": "泰山石油", + "000555": "神州信息", + "000557": "西部创业", + "000558": "天府文旅", + "000559": "万向钱潮", + "000560": "我爱我家", + "000561": "烽火电子", + "000563": "陕国投A", + "000564": "供销大集", + "000565": "渝三峡A", + "000566": "海南海药", + "000567": "海德股份", + "000568": "泸州老窖", + "000570": "苏常柴A", + "000571": "新大洲A", + "000572": "海马汽车", + "000573": "粤宏远A", + "000576": "甘化科工", + "000581": "威孚高科", + "000582": "北部湾港", + "000586": "汇源通信", + "000589": "贵州轮胎", + "000590": "古汉医药", + "000591": "太阳能", + "000592": "平潭发展", + "000593": "德龙汇能", + "000595": "*ST宝实", + "000596": "古井贡酒", + "000597": "东北制药", + "000598": "兴蓉环境", + "000599": "青岛双星", + "000600": "建投能源", + "000601": "韶能股份", + "000603": "盛达资源", + "000605": "渤海股份", + "000607": "华媒控股", + "000608": "*ST阳光", + "000609": "ST中迪", + "000610": "西安旅游", + "000612": "焦作万方", + "000615": "*ST美谷", + "000617": "中油资本", + "000619": "海螺新材", + "000620": "盈新发展", + "000623": "吉林敖东", + "000625": "长安汽车", + "000626": "远大控股", + "000628": "高新发展", + "000629": "钒钛股份", + "000630": "铜陵有色", + "000631": "顺发恒能", + "000632": "三木集团", + "000633": "合金投资", + "000635": "英 力 特", + "000636": "风华高科", + "000637": "茂化实华", + "000638": "*ST万方", + "000639": "西王食品", + "000650": "仁和药业", + "000651": "格力电器", + "000652": "泰达股份", + "000655": "金岭矿业", + "000656": "*ST金科", + "000657": "中钨高新", + "000659": "珠海中富", + "000661": "长春高新", + "000663": "永安林业", + "000665": "湖北广电", + "000668": "*ST荣控", + "000669": "ST金鸿", + "000670": "盈方微", + "000672": "上峰水泥", + "000676": "智度股份", + "000677": "恒天海龙", + "000678": "襄阳轴承", + "000679": "大连友谊", + "000680": "山推股份", + "000681": "视觉中国", + "000682": "东方电子", + "000683": "博源化工", + "000685": "中山公用", + "000686": "东北证券", + "000688": "国城矿业", + "000690": "宝新能源", + "000691": "*ST亚太", + "000692": "惠天热电", + "000695": "滨海能源", + "000697": "ST炼石", + "000698": "ST沈化", + "000700": "模塑科技", + "000701": "厦门信达", + "000702": "正虹科技", + "000703": "恒逸石化", + "000705": "浙江震元", + "000707": "双环科技", + "000708": "中信特钢", + "000709": "河钢股份", + "000710": "贝瑞基因", + "000711": "ST京蓝", + "000712": "锦龙股份", + "000713": "国投丰乐", + "000715": "中兴商业", + "000716": "黑芝麻", + "000717": "中南股份", + "000718": "苏宁环球", + "000719": "中原传媒", + "000720": "新能泰山", + "000721": "西安饮食", + "000722": "湖南发展", + "000723": "美锦能源", + "000725": "京东方A", + "000726": "鲁 泰A", + "000727": "冠捷科技", + "000728": "国元证券", + "000729": "燕京啤酒", + "000731": "四川美丰", + "000733": "振华科技", + "000735": "罗 牛 山", + "000736": "*ST中地", + "000737": "北方铜业", + "000738": "航发控制", + "000739": "普洛药业", + "000750": "国海证券", + "000751": "锌业股份", + "000752": "ST西发", + "000753": "漳州发展", + "000755": "山西高速", + "000756": "新华制药", + "000757": "浩物股份", + "000758": "中色股份", + "000759": "中百集团", + "000761": "本钢板材", + "000762": "西藏矿业", + "000766": "通化金马", + "000767": "晋控电力", + "000768": "中航西飞", + "000776": "广发证券", + "000777": "中核科技", + "000778": "新兴铸管", + "000779": "甘咨询", + "000782": "恒申新材", + "000783": "长江证券", + "000785": "居然智家", + "000786": "北新建材", + "000788": "北大医药", + "000789": "万年青", + "000790": "华神科技", + "000791": "甘肃能源", + "000792": "盐湖股份", + "000793": "ST华闻", + "000795": "英洛华", + "000796": "凯撒旅业", + "000797": "中国武夷", + "000798": "中水渔业", + "000799": "酒鬼酒", + "000800": "一汽解放", + "000801": "四川九洲", + "000802": "北京文化", + "000803": "山高环能", + "000807": "云铝股份", + "000809": "和展能源", + "000810": "创维数字", + "000811": "冰轮环境", + "000812": "陕西金叶", + "000813": "德展健康", + "000815": "美利云", + "000816": "智慧农业", + "000818": "航锦科技", + "000819": "岳阳兴长", + "000820": "*ST节能", + "000821": "ST京机", + "000822": "山东海化", + "000823": "超声电子", + "000825": "太钢不锈", + "000826": "启迪环境", + "000828": "东莞控股", + "000829": "天音控股", + "000830": "鲁西化工", + "000831": "中国稀土", + "000833": "粤桂股份", + "000837": "秦川机床", + "000838": "财信发展", + "000839": "国安股份", + "000848": "承德露露", + "000850": "华茂股份", + "000852": "石化机械", + "000856": "冀东装备", + "000858": "五 粮 液", + "000859": "国风新材", + "000860": "顺鑫农业", + "000862": "银星能源", + "000863": "三湘印象", + "000868": "安凯客车", + "000869": "张 裕A", + "000875": "吉电股份", + "000876": "新 希 望", + "000877": "天山股份", + "000878": "云南铜业", + "000880": "潍柴重机", + "000881": "中广核技", + "000882": "华联股份", + "000883": "湖北能源", + "000885": "城发环境", + "000886": "海南高速", + "000887": "中鼎股份", + "000888": "峨眉山A", + "000889": "中嘉博创", + "000890": "法尔胜", + "000892": "欢瑞世纪", + "000893": "亚钾国际", + "000895": "双汇发展", + "000897": "津滨发展", + "000898": "鞍钢股份", + "000899": "赣能股份", + "000900": "现代投资", + "000901": "航天科技", + "000902": "新洋丰", + "000903": "ST云动", + "000905": "厦门港务", + "000906": "浙商中拓", + "000908": "*ST景峰", + "000909": "ST数源", + "000910": "大亚圣象", + "000911": "广农糖业", + "000912": "泸天化", + "000913": "钱江摩托", + "000915": "华特达因", + "000917": "电广传媒", + "000919": "金陵药业", + "000920": "沃顿科技", + "000921": "海信家电", + "000922": "佳电股份", + "000923": "河钢资源", + "000925": "众合科技", + "000926": "福星股份", + "000927": "中国铁物", + "000928": "中钢国际", + "000929": "*ST兰黄", + "000930": "中粮科技", + "000931": "中 关 村", + "000932": "华菱钢铁", + "000933": "神火股份", + "000935": "四川双马", + "000936": "华西股份", + "000937": "冀中能源", + "000938": "紫光股份", + "000948": "南天信息", + "000949": "新乡化纤", + "000950": "重药控股", + "000951": "中国重汽", + "000952": "广济药业", + "000953": "河化股份", + "000955": "欣龙控股", + "000957": "中通客车", + "000958": "电投产融", + "000959": "首钢股份", + "000960": "锡业股份", + "000962": "东方钽业", + "000963": "华东医药", + "000965": "天保基建", + "000966": "长源电力", + "000967": "盈峰环境", + "000968": "蓝焰控股", + "000969": "安泰科技", + "000970": "中科三环", + "000972": "*ST中基", + "000973": "佛塑科技", + "000975": "山金国际", + "000977": "浪潮信息", + "000978": "桂林旅游", + "000980": "众泰汽车", + "000981": "山子高科", + "000983": "山西焦煤", + "000985": "大庆华科", + "000987": "越秀资本", + "000988": "华工科技", + "000989": "九芝堂", + "000990": "诚志股份", + "000993": "闽东电力", + "000995": "皇台酒业", + "000997": "新 大 陆", + "000998": "隆平高科", + "000999": "华润三九", + "001201": "东瑞股份", + "001202": "炬申股份", + "001203": "大中矿业", + "001205": "盛航股份", + "001206": "依依股份", + "001207": "联科科技", + "001208": "华菱线缆", + "001209": "洪兴股份", + "001210": "金房能源", + "001211": "双枪科技", + "001212": "中旗新材", + "001213": "中铁特货", + "001215": "千味央厨", + "001216": "华瓷股份", + "001217": "华尔泰", + "001218": "丽臣实业", + "001219": "青岛食品", + "001221": "悍高集团", + "001222": "源飞宠物", + "001223": "欧克科技", + "001225": "和泰机电", + "001226": "拓山重工", + "001227": "兰州银行", + "001228": "永泰运", + "001229": "魅视科技", + "001230": "劲旅环境", + "001231": "农心科技", + "001233": "海安集团", + "001234": "泰慕士", + "001236": "弘业期货", + "001238": "浙江正特", + "001239": "永达股份", + "001255": "博菲电气", + "001256": "炜冈科技", + "001258": "立新能源", + "001259": "利仁科技", + "001260": "坤泰股份", + "001266": "宏英智能", + "001267": "汇绿生态", + "001268": "联合精密", + "001269": "欧晶科技", + "001270": "*ST铖昌", + "001277": "速达股份", + "001278": "一彬科技", + "001279": "强邦新材", + "001280": "中国铀业", + "001282": "三联锻造", + "001283": "豪鹏科技", + "001285": "瑞立科密", + "001286": "陕西能源", + "001287": "中电港", + "001288": "运机集团", + "001289": "龙源电力", + "001296": "长江材料", + "001298": "好上好", + "001299": "美能能源", + "001300": "三柏硕", + "001301": "尚太科技", + "001306": "夏厦精密", + "001308": "康冠科技", + "001309": "德明利", + "001311": "多利科技", + "001313": "粤海饲料", + "001314": "亿道信息", + "001316": "润贝航科", + "001317": "三羊马", + "001318": "阳光乳业", + "001319": "铭科精技", + "001322": "箭牌家居", + "001323": "慕思股份", + "001324": "长青科技", + "001325": "元创股份", + "001326": "联域股份", + "001328": "登康口腔", + "001330": "博纳影业", + "001331": "胜通能源", + "001332": "锡装股份", + "001333": "光华股份", + "001335": "信凯科技", + "001336": "楚环科技", + "001337": "四川黄金", + "001338": "永顺泰", + "001339": "智微智能", + "001356": "富岭股份", + "001358": "兴欣新材", + "001359": "平安电工", + "001360": "南矿集团", + "001366": "播恩集团", + "001367": "海森药业", + "001368": "通达创智", + "001369": "双欣环保", + "001373": "翔腾新材", + "001376": "百通能源", + "001378": "德冠新材", + "001379": "腾达科技", + "001380": "华纬科技", + "001382": "新亚电缆", + "001386": "马可波罗", + "001387": "雪祺电气", + "001388": "信通电子", + "001389": "广合科技", + "001390": "古麒绒材", + "001391": "国货航", + "001395": "亚联机械", + "001396": "誉帆科技", + "001400": "江顺科技", + "001696": "宗申动力", + "001872": "招商港口", + "001896": "豫能控股", + "001914": "招商积余", + "001965": "招商公路", + "001979": "招商蛇口", + "002001": "新 和 成", + "002003": "伟星股份", + "002004": "华邦健康", + "002005": "ST德豪", + "002006": "精工科技", + "002007": "华兰生物", + "002008": "大族激光", + "002009": "天奇股份", + "002010": "传化智联", + "002011": "盾安环境", + "002012": "凯恩股份", + "002014": "永新股份", + "002015": "协鑫能科", + "002016": "世荣兆业", + "002017": "东信和平", + "002019": "亿帆医药", + "002020": "京新药业", + "002021": "中捷资源", + "002022": "科华生物", + "002023": "海特高新", + "002024": "ST易购", + "002025": "航天电器", + "002026": "山东威达", + "002027": "分众传媒", + "002028": "思源电气", + "002029": "七 匹 狼", + "002030": "达安基因", + "002031": "巨轮智能", + "002032": "苏 泊 尔", + "002033": "丽江股份", + "002034": "旺能环境", + "002035": "华帝股份", + "002036": "联创电子", + "002037": "保利联合", + "002038": "双鹭药业", + "002039": "黔源电力", + "002040": "南 京 港", + "002041": "登海种业", + "002042": "华孚时尚", + "002043": "兔 宝 宝", + "002044": "美年健康", + "002045": "国光电器", + "002046": "国机精工", + "002047": "*ST宝鹰", + "002048": "宁波华翔", + "002049": "紫光国微", + "002050": "三花智控", + "002051": "中工国际", + "002052": "同洲电子", + "002053": "云南能投", + "002054": "德美化工", + "002055": "ST得润", + "002056": "横店东磁", + "002057": "中钢天源", + "002058": "*ST威尔", + "002059": "云南旅游", + "002060": "广东建工", + "002061": "浙江交科", + "002062": "宏润建设", + "002063": "远光软件", + "002064": "华峰化学", + "002065": "东华软件", + "002066": "瑞泰科技", + "002067": "景兴纸业", + "002068": "黑猫股份", + "002069": "獐子岛", + "002072": "凯瑞德", + "002073": "软控股份", + "002074": "国轩高科", + "002075": "沙钢股份", + "002076": "*ST星光", + "002077": "大港股份", + "002078": "太阳纸业", + "002079": "苏州固锝", + "002080": "中材科技", + "002081": "金 螳 螂", + "002082": "万邦德", + "002083": "孚日股份", + "002084": "海鸥住工", + "002085": "万丰奥威", + "002086": "东方海洋", + "002088": "鲁阳节能", + "002090": "金智科技", + "002091": "江苏国泰", + "002092": "中泰化学", + "002093": "国脉科技", + "002094": "青岛金王", + "002095": "生 意 宝", + "002096": "易普力", + "002097": "山河智能", + "002098": "浔兴股份", + "002099": "海翔药业", + "002100": "天康生物", + "002101": "广东鸿图", + "002102": "能特科技", + "002103": "广博股份", + "002104": "恒宝股份", + "002105": "信隆健康", + "002106": "莱宝高科", + "002107": "沃华医药", + "002108": "沧州明珠", + "002109": "兴化股份", + "002110": "三钢闽光", + "002111": "威海广泰", + "002112": "三变科技", + "002114": "罗平锌电", + "002115": "三维通信", + "002116": "中国海诚", + "002117": "东港股份", + "002119": "康强电子", + "002120": "韵达股份", + "002121": "科陆电子", + "002122": "ST汇洲", + "002123": "梦网科技", + "002124": "天邦食品", + "002125": "湘潭电化", + "002126": "银轮股份", + "002127": "南极电商", + "002128": "电投能源", + "002129": "TCL中环", + "002130": "沃尔核材", + "002131": "利欧股份", + "002132": "恒星科技", + "002133": "广宇集团", + "002134": "天津普林", + "002135": "东南网架", + "002136": "安 纳 达", + "002137": "实益达", + "002138": "顺络电子", + "002139": "拓邦股份", + "002140": "东华科技", + "002141": "贤丰控股", + "002142": "宁波银行", + "002144": "宏达高科", + "002145": "钛能化学", + "002146": "荣盛发展", + "002148": "北纬科技", + "002149": "西部材料", + "002150": "正泰电源", + "002151": "北斗星通", + "002152": "广电运通", + "002153": "石基信息", + "002154": "报 喜 鸟", + "002155": "湖南黄金", + "002156": "通富微电", + "002157": "正邦科技", + "002158": "汉钟精机", + "002159": "三特索道", + "002160": "常铝股份", + "002161": "远 望 谷", + "002162": "悦心健康", + "002163": "海南发展", + "002164": "宁波东力", + "002165": "红 宝 丽", + "002166": "莱茵生物", + "002167": "东方锆业", + "002168": "*ST惠程", + "002169": "智光电气", + "002170": "芭田股份", + "002171": "楚江新材", + "002172": "澳洋健康", + "002173": "创新医疗", + "002174": "游族网络", + "002175": "东方智造", + "002176": "江特电机", + "002177": "御银股份", + "002178": "延华智能", + "002179": "中航光电", + "002180": "纳思达", + "002181": "粤 传 媒", + "002182": "宝武镁业", + "002183": "怡 亚 通", + "002184": "海得控制", + "002185": "华天科技", + "002186": "全 聚 德", + "002187": "广百股份", + "002188": "中天服务", + "002189": "中光学", + "002190": "成飞集成", + "002191": "劲嘉股份", + "002192": "融捷股份", + "002193": "如意集团", + "002194": "武汉凡谷", + "002195": "岩山科技", + "002196": "方正电机", + "002197": "证通电子", + "002198": "嘉应制药", + "002199": "*ST东晶", + "002200": "*ST交投", + "002201": "九鼎新材", + "002202": "金风科技", + "002203": "海亮股份", + "002204": "大连重工", + "002205": "国统股份", + "002206": "海 利 得", + "002207": "准油股份", + "002208": "合肥城建", + "002209": "达 意 隆", + "002210": "飞马国际", + "002211": "ST宏达", + "002212": "天融信", + "002213": "大为股份", + "002214": "*ST大立", + "002215": "诺 普 信", + "002216": "三全食品", + "002217": "合力泰", + "002218": "拓日新能", + "002219": "新里程", + "002221": "东华能源", + "002222": "福晶科技", + "002223": "鱼跃医疗", + "002224": "三 力 士", + "002225": "濮耐股份", + "002226": "江南化工", + "002227": "奥 特 迅", + "002228": "合兴包装", + "002229": "鸿博股份", + "002230": "科大讯飞", + "002231": "*ST奥维", + "002232": "启明信息", + "002233": "塔牌集团", + "002234": "民和股份", + "002235": "安妮股份", + "002236": "大华股份", + "002237": "恒邦股份", + "002238": "天威视讯", + "002239": "奥特佳", + "002240": "盛新锂能", + "002241": "歌尔股份", + "002242": "九阳股份", + "002243": "力合科创", + "002244": "滨江集团", + "002245": "蔚蓝锂芯", + "002246": "北化股份", + "002247": "聚力文化", + "002248": "华东数控", + "002249": "大洋电机", + "002250": "联化科技", + "002251": "步步高", + "002252": "上海莱士", + "002253": "*ST智胜", + "002254": "泰和新材", + "002255": "海陆重工", + "002256": "兆新股份", + "002258": "利尔化学", + "002259": "升达林业", + "002261": "拓维信息", + "002262": "恩华药业", + "002263": "大东南", + "002264": "新 华 都", + "002265": "建设工业", + "002266": "浙富控股", + "002267": "陕天然气", + "002268": "电科网安", + "002269": "美邦服饰", + "002270": "华明装备", + "002271": "东方雨虹", + "002272": "川润股份", + "002273": "水晶光电", + "002274": "华昌化工", + "002275": "桂林三金", + "002276": "万马股份", + "002277": "友阿股份", + "002278": "神开股份", + "002279": "久其软件", + "002281": "光迅科技", + "002282": "博深股份", + "002283": "天润工业", + "002284": "亚太股份", + "002285": "世联行", + "002286": "保龄宝", + "002287": "奇正藏药", + "002289": "*ST宇顺", + "002290": "禾盛新材", + "002291": "遥望科技", + "002292": "奥飞娱乐", + "002293": "罗莱生活", + "002294": "信立泰", + "002295": "精艺股份", + "002296": "辉煌科技", + "002297": "博云新材", + "002298": "中电鑫龙", + "002299": "圣农发展", + "002300": "太阳电缆", + "002301": "齐心集团", + "002302": "西部建设", + "002303": "美盈森", + "002304": "洋河股份", + "002305": "*ST南置", + "002306": "*ST云网", + "002307": "北新路桥", + "002309": "中利集团", + "002310": "东方新能", + "002311": "海大集团", + "002312": "川发龙蟒", + "002313": "日海智能", + "002314": "南山控股", + "002315": "焦点科技", + "002316": "亚联发展", + "002317": "众生药业", + "002318": "久立特材", + "002319": "乐通股份", + "002320": "海峡股份", + "002321": "华英农业", + "002322": "理工能科", + "002323": "雅博股份", + "002324": "普利特", + "002326": "永太科技", + "002327": "富安娜", + "002328": "新朋股份", + "002329": "皇氏集团", + "002330": "得利斯", + "002331": "皖通科技", + "002332": "仙琚制药", + "002333": "罗普斯金", + "002334": "英威腾", + "002335": "科华数据", + "002337": "赛象科技", + "002338": "奥普光电", + "002339": "积成电子", + "002340": "格林美", + "002342": "巨力索具", + "002343": "慈文传媒", + "002344": "海宁皮城", + "002345": "潮宏基", + "002346": "柘中股份", + "002347": "泰尔股份", + "002348": "高乐股份", + "002349": "精华制药", + "002350": "北京科锐", + "002351": "漫步者", + "002352": "顺丰控股", + "002353": "杰瑞股份", + "002354": "天娱数科", + "002355": "兴民智通", + "002356": "赫美集团", + "002357": "富临运业", + "002358": "森源电气", + "002360": "同德化工", + "002361": "神剑股份", + "002362": "汉王科技", + "002363": "隆基机械", + "002364": "中恒电气", + "002365": "永安药业", + "002366": "融发核电", + "002367": "康力电梯", + "002368": "太极股份", + "002369": "卓翼科技", + "002370": "亚太药业", + "002371": "北方华创", + "002372": "伟星新材", + "002373": "千方科技", + "002374": "中锐股份", + "002375": "亚厦股份", + "002376": "新北洋", + "002377": "国创高新", + "002378": "章源钨业", + "002379": "宏创控股", + "002380": "科远智慧", + "002381": "双箭股份", + "002382": "蓝帆医疗", + "002383": "合众思壮", + "002384": "东山精密", + "002385": "大北农", + "002386": "天原股份", + "002387": "维信诺", + "002388": "新亚制程", + "002389": "航天彩虹", + "002390": "信邦制药", + "002391": "长青股份", + "002392": "北京利尔", + "002393": "力生制药", + "002394": "联发股份", + "002395": "双象股份", + "002396": "星网锐捷", + "002397": "梦洁股份", + "002398": "垒知集团", + "002399": "海普瑞", + "002400": "省广集团", + "002401": "中远海科", + "002402": "和而泰", + "002403": "爱仕达", + "002404": "嘉欣丝绸", + "002405": "四维图新", + "002406": "远东传动", + "002407": "多氟多", + "002408": "齐翔腾达", + "002409": "雅克科技", + "002410": "广联达", + "002412": "汉森制药", + "002413": "雷科防务", + "002414": "高德红外", + "002415": "海康威视", + "002416": "爱施德", + "002418": "康盛股份", + "002419": "天虹股份", + "002420": "毅昌科技", + "002421": "达实智能", + "002422": "科伦药业", + "002423": "中粮资本", + "002424": "ST百灵", + "002425": "凯撒文化", + "002426": "胜利精密", + "002427": "尤夫股份", + "002428": "云南锗业", + "002429": "兆驰股份", + "002430": "杭氧股份", + "002431": "棕榈股份", + "002432": "九安医疗", + "002434": "万里扬", + "002436": "兴森科技", + "002437": "誉衡药业", + "002438": "江苏神通", + "002439": "启明星辰", + "002440": "闰土股份", + "002441": "众业达", + "002442": "龙星科技", + "002443": "金洲管道", + "002444": "巨星科技", + "002445": "中南文化", + "002446": "盛路通信", + "002448": "中原内配", + "002449": "国星光电", + "002451": "摩恩电气", + "002452": "长高电新", + "002453": "华软科技", + "002454": "松芝股份", + "002455": "百川股份", + "002456": "欧菲光", + "002457": "青龙管业", + "002458": "益生股份", + "002459": "晶澳科技", + "002460": "赣锋锂业", + "002461": "珠江啤酒", + "002462": "嘉事堂", + "002463": "沪电股份", + "002465": "海格通信", + "002466": "天齐锂业", + "002467": "二六三", + "002468": "申通快递", + "002469": "三维化学", + "002470": "金正大", + "002471": "中超控股", + "002472": "双环传动", + "002474": "榕基软件", + "002475": "立讯精密", + "002476": "宝莫股份", + "002478": "常宝股份", + "002479": "富春环保", + "002480": "新筑股份", + "002481": "双塔食品", + "002482": "广田集团", + "002483": "润邦股份", + "002484": "江海股份", + "002485": "ST雪发", + "002486": "嘉麟杰", + "002487": "大金重工", + "002488": "金固股份", + "002489": "浙江永强", + "002490": "山东墨龙", + "002491": "通鼎互联", + "002492": "恒基达鑫", + "002493": "荣盛石化", + "002494": "华斯股份", + "002495": "佳隆股份", + "002496": "*ST辉丰", + "002497": "雅化集团", + "002498": "汉缆股份", + "002500": "山西证券", + "002501": "利源股份", + "002506": "协鑫集成", + "002507": "涪陵榨菜", + "002508": "老板电器", + "002510": "天汽模", + "002511": "中顺洁柔", + "002512": "达华智能", + "002513": "蓝丰生化", + "002514": "宝馨科技", + "002515": "金字火腿", + "002516": "旷达科技", + "002517": "恺英网络", + "002518": "科士达", + "002519": "银河电子", + "002520": "日发精机", + "002521": "齐峰新材", + "002522": "浙江众成", + "002523": "天桥起重", + "002524": "光正眼科", + "002526": "山东矿机", + "002527": "新时达", + "002528": "ST英飞拓", + "002529": "*ST海源", + "002530": "金财互联", + "002531": "天顺风能", + "002532": "天山铝业", + "002533": "金杯电工", + "002534": "西子洁能", + "002535": "林州重机", + "002536": "飞龙股份", + "002537": "海联金汇", + "002538": "司尔特", + "002539": "云图控股", + "002540": "亚太科技", + "002541": "鸿路钢构", + "002542": "中化岩土", + "002543": "万和电气", + "002544": "普天科技", + "002545": "东方铁塔", + "002546": "新联电子", + "002547": "春兴精工", + "002548": "金新农", + "002549": "凯美特气", + "002550": "千红制药", + "002551": "尚荣医疗", + "002552": "宝鼎科技", + "002553": "南方精工", + "002554": "惠博普", + "002555": "三七互娱", + "002556": "辉隆股份", + "002557": "洽洽食品", + "002558": "巨人网络", + "002559": "亚威股份", + "002560": "通达股份", + "002561": "徐家汇", + "002562": "兄弟科技", + "002563": "森马服饰", + "002564": "天沃科技", + "002565": "顺灏股份", + "002566": "益盛药业", + "002567": "唐人神", + "002568": "百润股份", + "002569": "*ST步森", + "002570": "贝因美", + "002571": "德力股份", + "002572": "索菲亚", + "002573": "清新环境", + "002574": "明牌珠宝", + "002575": "群兴玩具", + "002576": "通达动力", + "002577": "雷柏科技", + "002578": "闽发铝业", + "002579": "中京电子", + "002580": "圣阳股份", + "002581": "ST未名", + "002582": "好想你", + "002583": "海能达", + "002584": "西陇科学", + "002585": "双星新材", + "002586": "ST围海", + "002587": "奥拓电子", + "002588": "史丹利", + "002589": "瑞康医药", + "002590": "万安科技", + "002591": "恒大高新", + "002592": "ST八菱", + "002593": "日上集团", + "002594": "比亚迪", + "002595": "豪迈科技", + "002596": "海南瑞泽", + "002597": "金禾实业", + "002598": "山东章鼓", + "002599": "盛通股份", + "002600": "领益智造", + "002601": "龙佰集团", + "002602": "世纪华通", + "002603": "以岭药业", + "002605": "姚记科技", + "002606": "大连电瓷", + "002607": "中公教育", + "002608": "江苏国信", + "002609": "捷顺科技", + "002611": "东方精工", + "002612": "朗姿股份", + "002613": "北玻股份", + "002614": "奥佳华", + "002615": "哈尔斯", + "002616": "长青集团", + "002617": "露笑科技", + "002620": "ST瑞和", + "002622": "皓宸医疗", + "002623": "亚玛顿", + "002624": "完美世界", + "002625": "光启技术", + "002626": "金达威", + "002627": "三峡旅游", + "002628": "成都路桥", + "002629": "仁智股份", + "002630": "ST华西", + "002631": "德尔未来", + "002632": "道明光学", + "002633": "申科股份", + "002634": "棒杰股份", + "002635": "安洁科技", + "002636": "金安国纪", + "002637": "赞宇科技", + "002638": "勤上股份", + "002639": "雪人集团", + "002640": "跨境通", + "002641": "公元股份", + "002642": "荣联科技", + "002643": "万润股份", + "002644": "佛慈制药", + "002645": "华宏科技", + "002646": "天佑德酒", + "002647": "*ST仁东", + "002648": "卫星化学", + "002649": "博彦科技", + "002650": "ST加加", + "002651": "利君股份", + "002652": "扬子新材", + "002653": "海思科", + "002654": "万润科技", + "002655": "共达电声", + "002656": "*ST摩登", + "002657": "中科金财", + "002658": "雪迪龙", + "002659": "凯文教育", + "002660": "茂硕电源", + "002661": "克明食品", + "002662": "峰璟股份", + "002663": "普邦股份", + "002664": "信质集团", + "002666": "德联集团", + "002667": "威领股份", + "002668": "TCL智家", + "002669": "康达新材", + "002670": "国盛证券", + "002671": "龙泉股份", + "002672": "东江环保", + "002673": "西部证券", + "002674": "兴业科技", + "002675": "东诚药业", + "002676": "顺威股份", + "002677": "浙江美大", + "002678": "珠江钢琴", + "002679": "福建金森", + "002681": "奋达科技", + "002682": "龙洲股份", + "002683": "广东宏大", + "002685": "华东重机", + "002686": "亿利达", + "002687": "乔治白", + "002688": "金河生物", + "002689": "ST远智", + "002690": "美亚光电", + "002691": "冀凯股份", + "002692": "远程股份", + "002693": "*ST双成", + "002694": "顾地科技", + "002695": "煌上煌", + "002696": "百洋股份", + "002697": "红旗连锁", + "002698": "博实股份", + "002700": "万憬能源", + "002701": "奥瑞金", + "002702": "海欣食品", + "002703": "浙江世宝", + "002705": "新宝股份", + "002706": "良信股份", + "002707": "众信旅游", + "002708": "光洋股份", + "002709": "天赐材料", + "002712": "思美传媒", + "002713": "*ST东易", + "002714": "牧原股份", + "002715": "登云股份", + "002716": "湖南白银", + "002717": "ST岭南", + "002718": "友邦吊顶", + "002719": "麦趣尔", + "002721": "金一文化", + "002722": "物产金轮", + "002723": "小崧股份", + "002724": "海洋王", + "002725": "跃岭股份", + "002726": "龙大美食", + "002727": "一心堂", + "002728": "特一药业", + "002729": "好利科技", + "002730": "电光科技", + "002731": "萃华珠宝", + "002732": "燕塘乳业", + "002733": "雄韬股份", + "002734": "利民股份", + "002735": "王子新材", + "002736": "国信证券", + "002737": "葵花药业", + "002738": "中矿资源", + "002739": "万达电影", + "002741": "光华科技", + "002742": "*ST三圣", + "002743": "富煌钢构", + "002745": "木林森", + "002746": "仙坛股份", + "002747": "埃斯顿", + "002748": "世龙实业", + "002749": "国光股份", + "002752": "昇兴股份", + "002753": "永东股份", + "002755": "奥赛康", + "002756": "永兴材料", + "002757": "南兴股份", + "002758": "浙农股份", + "002759": "天际股份", + "002760": "凤形股份", + "002761": "浙江建投", + "002762": "*ST金比", + "002763": "汇洁股份", + "002765": "蓝黛科技", + "002766": "索菱股份", + "002767": "先锋电子", + "002768": "国恩股份", + "002769": "普路通", + "002771": "真视通", + "002772": "众兴菌业", + "002773": "康弘药业", + "002774": "快意电梯", + "002775": "文科股份", + "002777": "久远银海", + "002778": "中晟高科", + "002779": "中坚科技", + "002780": "三夫户外", + "002782": "可立克", + "002783": "凯龙股份", + "002785": "万里石", + "002786": "银宝山新", + "002787": "华源控股", + "002788": "鹭燕医药", + "002789": "*ST建艺", + "002790": "瑞尔特", + "002791": "坚朗五金", + "002792": "通宇通讯", + "002793": "罗欣药业", + "002795": "永和智控", + "002796": "世嘉科技", + "002797": "第一创业", + "002798": "帝欧水华", + "002799": "环球印务", + "002800": "天顺股份", + "002801": "微光股份", + "002802": "洪汇新材", + "002803": "吉宏股份", + "002805": "丰元股份", + "002806": "华锋股份", + "002807": "江阴银行", + "002808": "*ST恒久", + "002809": "红墙股份", + "002810": "山东赫达", + "002811": "郑中设计", + "002812": "恩捷股份", + "002813": "路畅科技", + "002815": "崇达技术", + "002816": "*ST和科", + "002817": "黄山胶囊", + "002818": "富森美", + "002819": "东方中科", + "002820": "桂发祥", + "002821": "凯莱英", + "002822": "ST中装", + "002823": "凯中精密", + "002824": "和胜股份", + "002825": "纳尔股份", + "002826": "易明医药", + "002827": "高争民爆", + "002828": "贝肯能源", + "002829": "星网宇达", + "002830": "名雕股份", + "002831": "裕同科技", + "002832": "比音勒芬", + "002833": "弘亚数控", + "002835": "同为股份", + "002836": "新宏泽", + "002837": "英维克", + "002838": "道恩股份", + "002839": "张家港行", + "002840": "华统股份", + "002841": "视源股份", + "002842": "翔鹭钨业", + "002843": "泰嘉股份", + "002845": "同兴达", + "002846": "英联股份", + "002847": "盐津铺子", + "002848": "*ST高斯", + "002849": "威星智能", + "002850": "科达利", + "002851": "麦格米特", + "002852": "道道全", + "002853": "皮阿诺", + "002855": "捷荣技术", + "002856": "美芝股份", + "002857": "三晖电气", + "002858": "力盛体育", + "002859": "洁美科技", + "002860": "星帅尔", + "002861": "瀛通通讯", + "002862": "实丰文化", + "002863": "今飞凯达", + "002864": "盘龙药业", + "002865": "钧达股份", + "002866": "传艺科技", + "002867": "周大生", + "002868": "*ST绿康", + "002869": "金溢科技", + "002870": "香山股份", + "002871": "伟隆股份", + "002872": "ST天圣", + "002873": "新天药业", + "002875": "安奈儿", + "002876": "三利谱", + "002877": "智能自控", + "002878": "元隆雅图", + "002879": "长缆科技", + "002880": "卫光生物", + "002881": "美格智能", + "002882": "金龙羽", + "002883": "中设股份", + "002884": "凌霄泵业", + "002885": "京泉华", + "002886": "沃特股份", + "002887": "绿茵生态", + "002888": "惠威科技", + "002889": "东方嘉盛", + "002890": "弘宇股份", + "002891": "中宠股份", + "002892": "科力尔", + "002893": "京能热力", + "002895": "川恒股份", + "002896": "中大力德", + "002897": "意华股份", + "002898": "*ST赛隆", + "002899": "英派斯", + "002900": "哈三联", + "002901": "大博医疗", + "002902": "铭普光磁", + "002903": "宇环数控", + "002905": "金逸影视", + "002906": "华阳集团", + "002907": "华森制药", + "002908": "德生科技", + "002909": "集泰股份", + "002910": "庄园牧场", + "002911": "佛燃能源", + "002912": "中新赛克", + "002913": "奥士康", + "002915": "中欣氟材", + "002916": "深南电路", + "002917": "金奥博", + "002918": "蒙娜丽莎", + "002919": "名臣健康", + "002920": "德赛西威", + "002921": "联诚精密", + "002922": "伊戈尔", + "002923": "润都股份", + "002925": "盈趣科技", + "002926": "华西证券", + "002927": "泰永长征", + "002928": "华夏航空", + "002929": "润建股份", + "002930": "宏川智慧", + "002931": "锋龙股份", + "002932": "明德生物", + "002933": "新兴装备", + "002935": "天奥电子", + "002936": "郑州银行", + "002937": "兴瑞科技", + "002938": "鹏鼎控股", + "002939": "长城证券", + "002940": "昂利康", + "002941": "新疆交建", + "002942": "新农股份", + "002943": "宇晶股份", + "002945": "华林证券", + "002946": "新乳业", + "002947": "恒铭达", + "002948": "青岛银行", + "002949": "华阳国际", + "002950": "奥美医疗", + "002951": "金时科技", + "002952": "亚世光电", + "002953": "日丰股份", + "002955": "鸿合科技", + "002956": "西麦食品", + "002957": "科瑞技术", + "002958": "青农商行", + "002959": "小熊电器", + "002960": "青鸟消防", + "002961": "瑞达期货", + "002962": "五方光电", + "002963": "豪尔赛", + "002965": "祥鑫科技", + "002966": "苏州银行", + "002967": "广电计量", + "002968": "新大正", + "002969": "嘉美包装", + "002970": "锐明技术", + "002971": "和远气体", + "002972": "科安达", + "002973": "侨银股份", + "002975": "博杰股份", + "002976": "瑞玛精密", + "002977": "天箭科技", + "002978": "安宁股份", + "002979": "雷赛智能", + "002980": "华盛昌", + "002981": "朝阳科技", + "002982": "湘佳股份", + "002983": "芯瑞达", + "002984": "森麒麟", + "002985": "北摩高科", + "002986": "宇新股份", + "002987": "京北方", + "002988": "豪美新材", + "002989": "中天精装", + "002990": "盛视科技", + "002991": "甘源食品", + "002992": "宝明科技", + "002993": "奥海科技", + "002995": "天地在线", + "002996": "顺博合金", + "002997": "瑞鹄模具", + "002998": "优彩资源", + "002999": "天禾股份", + "003000": "劲仔食品", + "003001": "中岩大地", + "003002": "壶化股份", + "003003": "天元股份", + "003004": "*ST声迅", + "003005": "竞业达", + "003006": "百亚股份", + "003007": "直真科技", + "003008": "开普检测", + "003009": "中天火箭", + "003010": "若羽臣", + "003011": "海象新材", + "003012": "东鹏控股", + "003013": "地铁设计", + "003015": "日久光电", + "003016": "欣贺股份", + "003017": "大洋生物", + "003018": "金富科技", + "003019": "宸展光电", + "003020": "立方制药", + "003021": "兆威机电", + "003022": "联泓新科", + "003023": "彩虹集团", + "003025": "思进智能", + "003026": "中晶科技", + "003027": "同兴科技", + "003028": "振邦智能", + "003029": "吉大正元", + "003030": "祖名股份", + "003031": "中瓷电子", + "003032": "*ST传智", + "003033": "征和工业", + "003035": "南网能源", + "003036": "泰坦股份", + "003037": "三和管桩", + "003038": "鑫铂股份", + "003039": "顺控发展", + "003040": "楚天龙", + "003041": "真爱美家", + "003042": "中农联合", + "003043": "华亚智能", + "003816": "中国广核", + "300001": "特锐德", + "300002": "神州泰岳", + "300003": "乐普医疗", + "300004": "南风股份", + "300005": "探路者", + "300006": "莱美药业", + "300007": "汉威科技", + "300008": "天海防务", + "300009": "安科生物", + "300010": "豆神教育", + "300011": "鼎汉技术", + "300012": "华测检测", + "300013": "新宁物流", + "300014": "亿纬锂能", + "300015": "爱尔眼科", + "300016": "北陆药业", + "300017": "网宿科技", + "300018": "中元股份", + "300019": "硅宝科技", + "300020": "ST银江", + "300021": "大禹节水", + "300022": "吉峰科技", + "300024": "机器人", + "300025": "华星创业", + "300026": "红日药业", + "300027": "华谊兄弟", + "300029": "*ST天龙", + "300030": "阳普医疗", + "300031": "宝通科技", + "300032": "金龙机电", + "300033": "同花顺", + "300034": "钢研高纳", + "300035": "中科电气", + "300036": "超图软件", + "300037": "新宙邦", + "300039": "上海凯宝", + "300040": "九洲集团", + "300041": "回天新材", + "300042": "朗科科技", + "300043": "星辉娱乐", + "300044": "ST赛为", + "300045": "华力创通", + "300046": "台基股份", + "300047": "天源迪科", + "300048": "合康新能", + "300049": "福瑞医科", + "300050": "世纪鼎利", + "300051": "琏升科技", + "300052": "ST中青宝", + "300053": "航宇微", + "300054": "鼎龙股份", + "300055": "万邦达", + "300056": "中创环保", + "300057": "万顺新材", + "300058": "蓝色光标", + "300059": "东方财富", + "300061": "旗天科技", + "300062": "中能电气", + "300063": "天龙集团", + "300065": "海兰信", + "300066": "三川智慧", + "300067": "安诺其", + "300068": "南都电源", + "300069": "金利华电", + "300070": "碧水源", + "300071": "福石控股", + "300072": "海新能科", + "300073": "当升科技", + "300074": "华平股份", + "300075": "数字政通", + "300076": "GQY视讯", + "300077": "国民技术", + "300078": "思创智联", + "300079": "数码视讯", + "300080": "易成新能", + "300081": "恒信东方", + "300082": "奥克股份", + "300083": "创世纪", + "300084": "海默科技", + "300085": "银之杰", + "300086": "康芝药业", + "300087": "荃银高科", + "300088": "长信科技", + "300091": "*ST金灵", + "300092": "科新机电", + "300093": "*ST金刚", + "300094": "国联水产", + "300095": "华伍股份", + "300096": "ST易联众", + "300097": "ST智云", + "300098": "高新兴", + "300099": "尤洛卡", + "300100": "双林股份", + "300101": "振芯科技", + "300102": "乾照光电", + "300103": "达刚控股", + "300105": "龙源技术", + "300106": "西部牧业", + "300107": "建新股份", + "300109": "新开源", + "300110": "华仁药业", + "300111": "向日葵", + "300112": "万讯自控", + "300113": "顺网科技", + "300115": "长盈精密", + "300118": "东方日升", + "300119": "瑞普生物", + "300120": "经纬辉开", + "300121": "阳谷华泰", + "300122": "智飞生物", + "300123": "亚光科技", + "300124": "汇川技术", + "300125": "*ST聆达", + "300126": "锐奇股份", + "300127": "银河磁体", + "300128": "锦富技术", + "300129": "泰胜风能", + "300130": "新国都", + "300131": "英唐智控", + "300132": "青松股份", + "300133": "华策影视", + "300134": "大富科技", + "300135": "宝利国际", + "300136": "信维通信", + "300137": "先河环保", + "300138": "晨光生物", + "300139": "晓程科技", + "300140": "节能环境", + "300141": "和顺电气", + "300142": "沃森生物", + "300143": "盈康生命", + "300144": "宋城演艺", + "300145": "南方泵业", + "300146": "汤臣倍健", + "300147": "ST香雪", + "300148": "天舟文化", + "300149": "睿智医药", + "300150": "世纪瑞尔", + "300151": "昌红科技", + "300152": "ST新动力", + "300153": "科泰电源", + "300154": "瑞凌股份", + "300155": "安居宝", + "300157": "新锦动力", + "300158": "振东制药", + "300159": "*ST新研", + "300160": "秀强股份", + "300161": "华中数控", + "300162": "雷曼光电", + "300163": "先锋新材", + "300164": "通源石油", + "300165": "ST天瑞", + "300166": "东方国信", + "300167": "ST迪威迅", + "300168": "万达信息", + "300169": "天晟新材", + "300170": "汉得信息", + "300171": "东富龙", + "300172": "中电环保", + "300173": "ST福能", + "300174": "元力股份", + "300175": "ST朗源", + "300176": "鸿特科技", + "300177": "中海达", + "300179": "四方达", + "300180": "华峰超纤", + "300181": "佐力药业", + "300182": "捷成股份", + "300183": "东软载波", + "300184": "力源信息", + "300185": "通裕重工", + "300187": "永清环保", + "300188": "国投智能", + "300189": "神农种业", + "300190": "维尔利", + "300191": "潜能恒信", + "300192": "科德教育", + "300193": "佳士科技", + "300194": "福安药业", + "300195": "长荣股份", + "300196": "长海股份", + "300197": "节能铁汉", + "300198": "ST纳川", + "300199": "翰宇药业", + "300200": "高盟新材", + "300201": "海伦哲", + "300203": "聚光科技", + "300204": "舒泰神", + "300205": "*ST天喻", + "300206": "理邦仪器", + "300207": "欣旺达", + "300209": "有棵树", + "300210": "森远股份", + "300211": "*ST亿通", + "300212": "易华录", + "300213": "佳讯飞鸿", + "300214": "日科化学", + "300215": "电科院", + "300217": "东方电热", + "300218": "安利股份", + "300219": "鸿利智汇", + "300220": "金运激光", + "300221": "银禧科技", + "300222": "科大智能", + "300223": "北京君正", + "300224": "正海磁材", + "300225": "*ST金泰", + "300226": "上海钢联", + "300227": "光韵达", + "300228": "富瑞特装", + "300229": "拓尔思", + "300230": "永利股份", + "300231": "银信科技", + "300232": "洲明科技", + "300233": "金城医药", + "300234": "开尔新材", + "300235": "方直科技", + "300236": "上海新阳", + "300237": "ST美晨", + "300238": "冠昊生物", + "300239": "东宝生物", + "300240": "飞力达", + "300241": "瑞丰光电", + "300242": "佳云科技", + "300243": "瑞丰高材", + "300244": "迪安诊断", + "300245": "天玑科技", + "300246": "宝莱特", + "300247": "融捷健康", + "300248": "新开普", + "300249": "依米康", + "300250": "初灵信息", + "300251": "光线传媒", + "300252": "金信诺", + "300253": "卫宁健康", + "300254": "仟源医药", + "300255": "常山药业", + "300256": "星星科技", + "300257": "开山股份", + "300258": "精锻科技", + "300259": "新天科技", + "300260": "新莱应材", + "300261": "雅本化学", + "300263": "隆华科技", + "300264": "佳创视讯", + "300265": "通光线缆", + "300266": "兴源环境", + "300267": "尔康制药", + "300268": "*ST佳沃", + "300269": "联建光电", + "300270": "中威电子", + "300271": "华宇软件", + "300272": "开能健康", + "300274": "阳光电源", + "300275": "梅安森", + "300276": "三丰智能", + "300277": "海联讯", + "300278": "华昌达", + "300279": "和晶科技", + "300281": "金明精机", + "300283": "温州宏丰", + "300284": "苏交科", + "300285": "国瓷材料", + "300286": "安科瑞", + "300287": "飞利信", + "300288": "朗玛信息", + "300289": "利德曼", + "300290": "荣科科技", + "300291": "百纳千成", + "300292": "吴通控股", + "300293": "蓝英装备", + "300294": "博雅生物", + "300295": "三六五网", + "300296": "利亚德", + "300298": "三诺生物", + "300299": "富春股份", + "300300": "海峡创新", + "300301": "ST长方", + "300302": "同有科技", + "300303": "聚飞光电", + "300304": "云意电气", + "300305": "裕兴股份", + "300306": "远方信息", + "300307": "慈星股份", + "300308": "中际旭创", + "300310": "宜通世纪", + "300311": "ST任子行", + "300313": "*ST天山", + "300314": "戴维医疗", + "300315": "掌趣科技", + "300316": "晶盛机电", + "300317": "珈伟新能", + "300318": "博晖创新", + "300319": "麦捷科技", + "300320": "海达股份", + "300321": "同大股份", + "300322": "硕贝德", + "300323": "华灿光电", + "300324": "旋极信息", + "300326": "ST凯利", + "300327": "中颖电子", + "300328": "宜安科技", + "300329": "海伦钢琴", + "300331": "苏大维格", + "300332": "天壕能源", + "300333": "兆日科技", + "300334": "津膜科技", + "300335": "迪森股份", + "300337": "银邦股份", + "300338": "ST开元", + "300339": "润和软件", + "300340": "科恒股份", + "300341": "麦克奥迪", + "300342": "天银机电", + "300343": "ST联创", + "300344": "*ST立方", + "300345": "华民股份", + "300346": "南大光电", + "300347": "泰格医药", + "300348": "长亮科技", + "300349": "金卡智能", + "300350": "华鹏飞", + "300351": "永贵电器", + "300352": "北信源", + "300353": "东土科技", + "300354": "东华测试", + "300355": "蒙草生态", + "300357": "我武生物", + "300358": "楚天科技", + "300359": "全通教育", + "300360": "炬华科技", + "300363": "博腾股份", + "300364": "中文在线", + "300365": "恒华科技", + "300366": "ST创意", + "300368": "汇金股份", + "300369": "绿盟科技", + "300370": "安控科技", + "300371": "汇中股份", + "300373": "扬杰科技", + "300374": "中铁装配", + "300375": "鹏翎股份", + "300376": "易事特", + "300377": "赢时胜", + "300378": "鼎捷数智", + "300380": "安硕信息", + "300381": "溢多利", + "300382": "斯莱克", + "300383": "光环新网", + "300384": "三联虹普", + "300385": "雪浪环境", + "300386": "飞天诚信", + "300387": "富邦科技", + "300388": "节能国祯", + "300389": "艾比森", + "300390": "天华新能", + "300391": "*ST长药", + "300393": "中来股份", + "300394": "天孚通信", + "300395": "菲利华", + "300396": "迪瑞医疗", + "300397": "天和防务", + "300398": "飞凯材料", + "300399": "天利科技", + "300400": "劲拓股份", + "300401": "花园生物", + "300402": "宝色股份", + "300403": "汉宇集团", + "300404": "博济医药", + "300405": "科隆股份", + "300406": "九强生物", + "300407": "凯发电气", + "300408": "三环集团", + "300409": "道氏技术", + "300410": "正业科技", + "300411": "金盾股份", + "300412": "迦南科技", + "300413": "芒果超媒", + "300414": "中光防雷", + "300415": "伊之密", + "300416": "苏试试验", + "300417": "南华仪器", + "300418": "昆仑万维", + "300419": "ST浩丰", + "300420": "五洋自控", + "300421": "力星股份", + "300422": "博世科", + "300423": "昇辉科技", + "300424": "航新科技", + "300425": "中建环能", + "300426": "华智数媒", + "300427": "红相股份", + "300428": "立中集团", + "300429": "强力新材", + "300430": "诚益通", + "300432": "富临精工", + "300433": "蓝思科技", + "300434": "金石亚药", + "300435": "中泰股份", + "300436": "广生堂", + "300437": "清水源", + "300438": "鹏辉能源", + "300439": "美康生物", + "300440": "运达科技", + "300441": "鲍斯股份", + "300442": "润泽科技", + "300443": "金雷股份", + "300444": "双杰电气", + "300445": "康斯特", + "300446": "航天智造", + "300447": "全信股份", + "300448": "浩云科技", + "300449": "汉邦高科", + "300450": "先导智能", + "300451": "创业慧康", + "300452": "山河药辅", + "300453": "三鑫医疗", + "300454": "深信服", + "300455": "航天智装", + "300456": "赛微电子", + "300457": "赢合科技", + "300458": "全志科技", + "300459": "汤姆猫", + "300460": "ST惠伦", + "300461": "田中精机", + "300462": "ST华铭", + "300463": "迈克生物", + "300464": "星徽股份", + "300465": "高伟达", + "300466": "赛摩智能", + "300467": "迅游科技", + "300468": "四方精创", + "300469": "信息发展", + "300470": "中密控股", + "300471": "厚普股份", + "300472": "*ST新元", + "300473": "德尔股份", + "300474": "景嘉微", + "300475": "香农芯创", + "300476": "胜宏科技", + "300477": "ST合纵", + "300478": "杭州高新", + "300479": "神思电子", + "300480": "光力科技", + "300481": "濮阳惠成", + "300482": "万孚生物", + "300483": "首华燃气", + "300484": "蓝海华腾", + "300485": "赛升药业", + "300486": "东杰智能", + "300487": "蓝晓科技", + "300488": "恒锋工具", + "300489": "光智科技", + "300490": "华自科技", + "300491": "通合科技", + "300492": "华图山鼎", + "300493": "润欣科技", + "300494": "盛天网络", + "300496": "中科创达", + "300497": "富祥药业", + "300498": "温氏股份", + "300499": "高澜股份", + "300500": "启迪设计", + "300501": "海顺新材", + "300502": "新易盛", + "300503": "昊志机电", + "300504": "天邑股份", + "300505": "川金诺", + "300506": "ST名家汇", + "300507": "苏奥传感", + "300508": "维宏股份", + "300509": "新美星", + "300510": "金冠股份", + "300511": "雪榕生物", + "300512": "中亚股份", + "300513": "恒实科技", + "300514": "友讯达", + "300515": "三德科技", + "300516": "久之洋", + "300517": "海波重科", + "300518": "新迅达", + "300519": "新光药业", + "300520": "科大国创", + "300521": "爱司凯", + "300522": "世名科技", + "300523": "辰安科技", + "300525": "博思软件", + "300527": "ST应急", + "300528": "幸福蓝海", + "300529": "健帆生物", + "300530": "领湃科技", + "300531": "优博讯", + "300532": "今天国际", + "300533": "冰川网络", + "300534": "陇神戎发", + "300535": "达威股份", + "300536": "农尚环境", + "300537": "广信材料", + "300538": "同益股份", + "300539": "横河精密", + "300540": "蜀道装备", + "300541": "先进数通", + "300542": "新晨科技", + "300543": "朗科智能", + "300545": "联得装备", + "300546": "雄帝科技", + "300547": "川环科技", + "300548": "长芯博创", + "300549": "优德精密", + "300550": "和仁科技", + "300551": "古鳌科技", + "300552": "万集科技", + "300553": "集智股份", + "300554": "三超新材", + "300555": "ST路通", + "300556": "丝路视觉", + "300557": "理工光科", + "300558": "贝达药业", + "300559": "佳发教育", + "300560": "中富通", + "300561": "*ST汇科", + "300562": "乐心医疗", + "300563": "神宇股份", + "300564": "筑博设计", + "300565": "科信技术", + "300566": "激智科技", + "300567": "精测电子", + "300568": "星源材质", + "300569": "天能重工", + "300570": "太辰光", + "300571": "平治信息", + "300572": "安车检测", + "300573": "兴齐眼药", + "300575": "中旗股份", + "300576": "容大感光", + "300577": "开润股份", + "300578": "会畅科技", + "300579": "数字认证", + "300580": "贝斯特", + "300581": "晨曦航空", + "300582": "英飞特", + "300583": "赛托生物", + "300584": "海辰药业", + "300585": "奥联电子", + "300586": "美联新材", + "300587": "天铁科技", + "300588": "熙菱信息", + "300589": "江龙船艇", + "300590": "移为通信", + "300591": "万里马", + "300592": "华凯易佰", + "300593": "新雷能", + "300594": "朗进科技", + "300595": "欧普康视", + "300596": "利安隆", + "300597": "吉大通信", + "300598": "诚迈科技", + "300599": "雄塑科技", + "300600": "国瑞科技", + "300601": "康泰生物", + "300602": "飞荣达", + "300603": "立昂技术", + "300604": "长川科技", + "300605": "恒锋信息", + "300606": "金太阳", + "300607": "拓斯达", + "300608": "思特奇", + "300609": "汇纳科技", + "300610": "晨化股份", + "300611": "美力科技", + "300612": "宣亚国际", + "300613": "富瀚微", + "300614": "百川畅银", + "300615": "欣天科技", + "300616": "尚品宅配", + "300617": "安靠智电", + "300618": "寒锐钴业", + "300619": "金银河", + "300620": "光库科技", + "300621": "维业股份", + "300622": "博士眼镜", + "300623": "捷捷微电", + "300624": "万兴科技", + "300625": "三雄极光", + "300626": "华瑞股份", + "300627": "华测导航", + "300628": "亿联网络", + "300629": "新劲刚", + "300631": "久吾高科", + "300632": "光莆股份", + "300633": "开立医疗", + "300634": "彩讯股份", + "300635": "中达安", + "300636": "同和药业", + "300637": "扬帆新材", + "300638": "广和通", + "300639": "凯普生物", + "300640": "德艺文创", + "300641": "正丹股份", + "300642": "透景生命", + "300643": "万通智控", + "300644": "南京聚隆", + "300645": "正元智慧", + "300647": "超频三", + "300648": "星云股份", + "300649": "杭州园林", + "300650": "太龙股份", + "300651": "金陵体育", + "300652": "雷迪克", + "300653": "正海生物", + "300654": "世纪天鸿", + "300655": "晶瑞电材", + "300656": "民德电子", + "300657": "弘信电子", + "300658": "延江股份", + "300659": "中孚信息", + "300660": "江苏雷利", + "300661": "圣邦股份", + "300662": "科锐国际", + "300663": "科蓝软件", + "300664": "鹏鹞环保", + "300665": "飞鹿股份", + "300666": "江丰电子", + "300667": "必创科技", + "300668": "杰恩设计", + "300669": "沪宁股份", + "300670": "大烨智能", + "300671": "富满微", + "300672": "国科微", + "300673": "佩蒂股份", + "300674": "宇信科技", + "300675": "建科院", + "300676": "华大基因", + "300677": "英科医疗", + "300678": "中科信息", + "300679": "电连技术", + "300680": "隆盛科技", + "300681": "英搏尔", + "300682": "朗新科技", + "300683": "海特生物", + "300684": "中石科技", + "300685": "艾德生物", + "300686": "智动力", + "300687": "赛意信息", + "300688": "创业黑马", + "300689": "澄天伟业", + "300690": "双一科技", + "300691": "联合光电", + "300692": "中赋科技", + "300693": "盛弘股份", + "300694": "蠡湖股份", + "300695": "兆丰股份", + "300696": "爱乐达", + "300697": "电工合金", + "300698": "万马科技", + "300699": "光威复材", + "300700": "岱勒新材", + "300701": "森霸传感", + "300702": "天宇股份", + "300703": "创源股份", + "300705": "九典制药", + "300706": "阿石创", + "300707": "威唐工业", + "300708": "聚灿光电", + "300709": "精研科技", + "300710": "万隆光电", + "300711": "广哈通信", + "300712": "永福股份", + "300713": "英可瑞", + "300715": "凯伦股份", + "300716": "ST泉为", + "300717": "华信新材", + "300718": "长盛轴承", + "300719": "安达维尔", + "300720": "海川智能", + "300721": "怡达股份", + "300722": "新余国科", + "300723": "一品红", + "300724": "捷佳伟创", + "300725": "药石科技", + "300726": "宏达电子", + "300727": "润禾材料", + "300729": "乐歌股份", + "300730": "科创信息", + "300731": "科创新源", + "300732": "设研院", + "300733": "西菱动力", + "300735": "光弘科技", + "300736": "百邦科技", + "300737": "科顺股份", + "300738": "奥飞数据", + "300739": "明阳电路", + "300740": "水羊股份", + "300741": "华宝股份", + "300743": "天地数码", + "300745": "欣锐科技", + "300746": "汉嘉数智", + "300747": "锐科激光", + "300748": "金力永磁", + "300749": "顶固集创", + "300750": "宁德时代", + "300751": "迈为股份", + "300752": "隆利科技", + "300753": "爱朋医疗", + "300755": "华致酒行", + "300756": "金马游乐", + "300757": "罗博特科", + "300758": "七彩化学", + "300759": "康龙化成", + "300760": "迈瑞医疗", + "300761": "立华股份", + "300762": "上海瀚讯", + "300763": "锦浪科技", + "300765": "新诺威", + "300766": "每日互动", + "300767": "震安科技", + "300768": "迪普科技", + "300769": "德方纳米", + "300770": "新媒股份", + "300771": "智莱科技", + "300772": "运达股份", + "300773": "拉卡拉", + "300774": "倍杰特", + "300775": "三角防务", + "300776": "帝尔激光", + "300777": "中简科技", + "300778": "新城市", + "300779": "惠城环保", + "300780": "德恩精工", + "300781": "因赛集团", + "300782": "卓胜微", + "300783": "三只松鼠", + "300784": "利安科技", + "300785": "值得买", + "300786": "国林科技", + "300787": "海能实业", + "300788": "中信出版", + "300789": "唐源电气", + "300790": "宇瞳光学", + "300791": "仙乐健康", + "300792": "壹网壹创", + "300793": "佳禾智能", + "300795": "米奥会展", + "300796": "贝斯美", + "300797": "钢研纳克", + "300798": "锦鸡股份", + "300800": "力合科技", + "300801": "泰和科技", + "300802": "矩子科技", + "300803": "指南针", + "300804": "广康生化", + "300805": "电声股份", + "300806": "斯迪克", + "300807": "天迈科技", + "300808": "久量股份", + "300809": "华辰装备", + "300810": "中科海讯", + "300811": "铂科新材", + "300812": "易天股份", + "300813": "泰林生物", + "300814": "中富电路", + "300815": "玉禾田", + "300816": "艾可蓝", + "300817": "双飞集团", + "300818": "耐普矿机", + "300819": "聚杰微纤", + "300820": "英杰电气", + "300821": "东岳硅材", + "300822": "贝仕达克", + "300823": "建科智能", + "300824": "北鼎股份", + "300825": "阿尔特", + "300826": "测绘股份", + "300827": "上能电气", + "300828": "锐新科技", + "300829": "金丹科技", + "300830": "金现代", + "300831": "派瑞股份", + "300832": "新产业", + "300833": "浩洋股份", + "300834": "星辉环材", + "300835": "龙磁科技", + "300836": "佰奥智能", + "300837": "浙矿股份", + "300838": "浙江力诺", + "300839": "博汇股份", + "300840": "酷特智能", + "300841": "康华生物", + "300842": "帝科股份", + "300843": "胜蓝股份", + "300844": "山水比德", + "300845": "捷安高科", + "300846": "首都在线", + "300847": "中船汉光", + "300848": "美瑞新材", + "300849": "锦盛新材", + "300850": "新强联", + "300851": "交大思诺", + "300852": "四会富仕", + "300853": "申昊科技", + "300854": "中兰环保", + "300855": "图南股份", + "300856": "科思股份", + "300857": "协创数据", + "300858": "科拓生物", + "300859": "西域旅游", + "300860": "锋尚文化", + "300861": "美畅股份", + "300862": "蓝盾光电", + "300863": "卡倍亿", + "300864": "南大环境", + "300865": "大宏立", + "300866": "安克创新", + "300867": "圣元环保", + "300868": "杰美特", + "300869": "康泰医学", + "300870": "欧陆通", + "300871": "回盛生物", + "300872": "天阳科技", + "300873": "海晨股份", + "300875": "捷强装备", + "300876": "蒙泰高新", + "300877": "金春股份", + "300878": "维康药业", + "300879": "大叶股份", + "300880": "迦南智能", + "300881": "盛德鑫泰", + "300882": "万胜智能", + "300883": "龙利得", + "300884": "狄耐克", + "300885": "海昌新材", + "300886": "华业香料", + "300887": "谱尼测试", + "300888": "稳健医疗", + "300889": "爱克股份", + "300890": "翔丰华", + "300891": "惠云钛业", + "300892": "品渥食品", + "300893": "松原安全", + "300894": "火星人", + "300895": "铜牛信息", + "300896": "爱美客", + "300897": "山科智能", + "300898": "熊猫乳品", + "300899": "*ST凯鑫", + "300900": "广联航空", + "300901": "中胤时尚", + "300902": "国安达", + "300903": "科翔股份", + "300904": "威力传动", + "300905": "宝丽迪", + "300906": "日月明", + "300907": "康平科技", + "300908": "仲景食品", + "300909": "汇创达", + "300910": "瑞丰新材", + "300911": "亿田智能", + "300912": "凯龙高科", + "300913": "兆龙互连", + "300915": "海融科技", + "300916": "朗特智能", + "300917": "特发服务", + "300918": "南山智尚", + "300919": "中伟新材", + "300920": "润阳科技", + "300921": "南凌科技", + "300922": "天秦装备", + "300923": "研奥股份", + "300925": "法本信息", + "300926": "博俊科技", + "300927": "江天化学", + "300928": "华安鑫创", + "300929": "华骐环保", + "300930": "屹通新材", + "300931": "通用电梯", + "300932": "三友联众", + "300933": "中辰股份", + "300935": "盈建科", + "300936": "中英科技", + "300937": "药易购", + "300938": "信测标准", + "300939": "秋田微", + "300940": "南极光", + "300941": "创识科技", + "300942": "易瑞生物", + "300943": "春晖智控", + "300945": "曼卡龙", + "300946": "恒而达", + "300947": "德必集团", + "300948": "冠中生态", + "300949": "奥雅股份", + "300950": "德固特", + "300951": "博硕科技", + "300952": "恒辉安防", + "300953": "震裕科技", + "300955": "嘉亨家化", + "300956": "英力股份", + "300957": "贝泰妮", + "300958": "建工修复", + "300959": "线上线下", + "300960": "通业科技", + "300961": "深水海纳", + "300962": "中金辐照", + "300963": "中洲特材", + "300964": "本川智能", + "300965": "恒宇信通", + "300966": "共同药业", + "300967": "晓鸣股份", + "300968": "格林精密", + "300969": "恒帅股份", + "300970": "华绿生物", + "300971": "博亚精工", + "300972": "万辰集团", + "300973": "立高食品", + "300975": "商络电子", + "300976": "达瑞电子", + "300977": "深圳瑞捷", + "300978": "东箭科技", + "300979": "华利集团", + "300980": "祥源新材", + "300981": "中红医疗", + "300982": "苏文电能", + "300983": "尤安设计", + "300984": "金沃股份", + "300985": "致远新能", + "300986": "志特新材", + "300987": "川网传媒", + "300988": "津荣天宇", + "300989": "蕾奥规划", + "300990": "同飞股份", + "300991": "创益通", + "300992": "泰福泵业", + "300993": "玉马科技", + "300994": "久祺股份", + "300995": "奇德新材", + "300996": "普联软件", + "300997": "欢乐家", + "300998": "宁波方正", + "300999": "金龙鱼", + "301000": "肇民科技", + "301001": "凯淳股份", + "301002": "崧盛股份", + "301003": "江苏博云", + "301004": "嘉益股份", + "301005": "超捷股份", + "301006": "迈拓股份", + "301007": "德迈仕", + "301008": "宏昌科技", + "301009": "可靠股份", + "301010": "晶雪节能", + "301011": "华立科技", + "301012": "扬电科技", + "301013": "利和兴", + "301015": "百洋医药", + "301016": "雷尔伟", + "301017": "漱玉平民", + "301018": "申菱环境", + "301019": "宁波色母", + "301020": "密封科技", + "301021": "英诺激光", + "301022": "海泰科", + "301023": "奕帆传动", + "301024": "霍普股份", + "301025": "读客文化", + "301026": "浩通科技", + "301027": "华蓝集团", + "301028": "东亚机械", + "301029": "怡合达", + "301030": "仕净科技", + "301031": "中熔电气", + "301032": "新柴股份", + "301033": "迈普医学", + "301035": "润丰股份", + "301036": "双乐股份", + "301037": "保立佳", + "301038": "深水规院", + "301039": "中集车辆", + "301040": "中环海陆", + "301041": "金百泽", + "301042": "安联锐视", + "301043": "绿岛风", + "301045": "天禄科技", + "301046": "能辉科技", + "301047": "义翘神州", + "301048": "金鹰重工", + "301049": "超越科技", + "301050": "雷电微力", + "301051": "信濠光电", + "301052": "果麦文化", + "301053": "远信工业", + "301055": "张小泉", + "301056": "森赫股份", + "301057": "汇隆新材", + "301058": "中粮科工", + "301059": "金三江", + "301060": "兰卫医学", + "301061": "匠心家居", + "301062": "上海艾录", + "301063": "海锅股份", + "301065": "本立科技", + "301066": "万事利", + "301067": "显盈科技", + "301068": "大地海洋", + "301069": "凯盛新材", + "301070": "开勒股份", + "301071": "力量钻石", + "301072": "中捷精工", + "301073": "君亭酒店", + "301075": "多瑞医药", + "301076": "新瀚新材", + "301077": "星华新材", + "301078": "孩子王", + "301079": "邵阳液压", + "301080": "百普赛斯", + "301081": "严牌股份", + "301082": "久盛电气", + "301083": "百胜智能", + "301085": "亚康股份", + "301086": "鸿富瀚", + "301087": "可孚医疗", + "301088": "戎美股份", + "301089": "拓新药业", + "301090": "华润材料", + "301091": "深城交", + "301092": "争光股份", + "301093": "华兰股份", + "301095": "广立微", + "301096": "百诚医药", + "301097": "天益医疗", + "301098": "金埔园林", + "301099": "雅创电子", + "301100": "风光股份", + "301101": "明月镜片", + "301102": "兆讯传媒", + "301103": "何氏眼科", + "301105": "鸿铭股份", + "301106": "骏成科技", + "301107": "瑜欣电子", + "301108": "洁雅股份", + "301109": "军信股份", + "301110": "青木科技", + "301111": "粤万年青", + "301112": "信邦智能", + "301113": "雅艺科技", + "301115": "联检科技", + "301116": "益客食品", + "301117": "佳缘科技", + "301118": "恒光股份", + "301119": "正强股份", + "301120": "新特电气", + "301121": "紫建电子", + "301122": "采纳股份", + "301123": "奕东电子", + "301125": "腾亚精工", + "301126": "达嘉维康", + "301127": "武汉天源", + "301128": "强瑞技术", + "301129": "瑞纳智能", + "301130": "西点药业", + "301131": "聚赛龙", + "301132": "满坤科技", + "301133": "金钟股份", + "301135": "瑞德智能", + "301136": "招标股份", + "301137": "哈焊华通", + "301138": "华研精机", + "301139": "元道通信", + "301141": "中科磁业", + "301148": "嘉戎技术", + "301149": "隆华新材", + "301150": "中一科技", + "301151": "冠龙节能", + "301152": "天力锂能", + "301153": "中科江南", + "301155": "海力风电", + "301156": "美农生物", + "301157": "华塑科技", + "301158": "德石股份", + "301159": "三维天地", + "301160": "翔楼新材", + "301161": "唯万密封", + "301162": "国能日新", + "301163": "宏德股份", + "301165": "锐捷网络", + "301166": "优宁维", + "301167": "建研设计", + "301168": "通灵股份", + "301169": "零点有数", + "301170": "锡南科技", + "301171": "易点天下", + "301172": "君逸数码", + "301173": "毓恬冠佳", + "301175": "中科环保", + "301176": "逸豪新材", + "301177": "迪阿股份", + "301178": "天亿马", + "301179": "泽宇智能", + "301180": "万祥科技", + "301181": "标榜股份", + "301182": "凯旺科技", + "301183": "东田微", + "301185": "鸥玛软件", + "301186": "超达装备", + "301187": "欧圣电气", + "301188": "力诺药包", + "301189": "奥尼电子", + "301190": "善水科技", + "301191": "菲菱科思", + "301192": "泰祥股份", + "301193": "家联科技", + "301195": "北路智控", + "301196": "唯科科技", + "301197": "工大科雅", + "301198": "喜悦智行", + "301199": "迈赫股份", + "301200": "大族数控", + "301201": "诚达药业", + "301202": "朗威股份", + "301203": "国泰环保", + "301205": "联特科技", + "301206": "三元生物", + "301207": "华兰疫苗", + "301208": "中亦科技", + "301209": "联合化学", + "301210": "金杨精密", + "301211": "亨迪药业", + "301212": "联盛化学", + "301213": "观想科技", + "301215": "中汽股份", + "301216": "万凯新材", + "301217": "铜冠铜箔", + "301218": "华是科技", + "301219": "腾远钴业", + "301220": "亚香股份", + "301221": "光庭信息", + "301222": "浙江恒威", + "301223": "中荣股份", + "301225": "恒勃股份", + "301226": "祥明智能", + "301227": "森鹰窗业", + "301228": "实朴检测", + "301229": "纽泰格", + "301230": "泓博医药", + "301231": "荣信文化", + "301232": "飞沃科技", + "301233": "盛帮股份", + "301234": "五洲医疗", + "301235": "华康洁净", + "301236": "软通动力", + "301237": "和顺科技", + "301238": "瑞泰新材", + "301239": "普瑞眼科", + "301246": "宏源药业", + "301248": "杰创智能", + "301251": "威尔高", + "301252": "同星科技", + "301255": "通力科技", + "301256": "华融化学", + "301257": "普蕊斯", + "301258": "富士莱", + "301259": "艾布鲁", + "301260": "格力博", + "301261": "恒工精密", + "301262": "海看股份", + "301263": "泰恩康", + "301265": "华新环保", + "301266": "宇邦新材", + "301267": "华厦眼科", + "301268": "铭利达", + "301269": "华大九天", + "301270": "汉仪股份", + "301272": "英华特", + "301273": "瑞晨环保", + "301275": "汉朔科技", + "301276": "嘉曼服饰", + "301277": "新天地", + "301278": "快可电子", + "301279": "金道科技", + "301280": "珠城科技", + "301281": "科源制药", + "301282": "金禄电子", + "301283": "聚胶股份", + "301285": "鸿日达", + "301286": "侨源股份", + "301287": "康力源", + "301288": "*ST清研", + "301289": "国缆检测", + "301290": "东星医疗", + "301291": "明阳电气", + "301292": "海科新源", + "301293": "三博脑科", + "301295": "美硕科技", + "301296": "新巨丰", + "301297": "富乐德", + "301298": "东利机械", + "301299": "卓创资讯", + "301300": "远翔新材", + "301301": "川宁生物", + "301302": "华如科技", + "301303": "真兰仪表", + "301305": "朗坤科技", + "301306": "西测测试", + "301307": "美利信", + "301308": "江波龙", + "301309": "万得凯", + "301310": "鑫宏业", + "301311": "昆船智能", + "301312": "智立方", + "301313": "凡拓数创", + "301314": "科瑞思", + "301315": "威士顿", + "301316": "慧博云通", + "301317": "鑫磊股份", + "301318": "维海德", + "301319": "唯特偶", + "301320": "豪江智能", + "301321": "翰博高新", + "301322": "绿通科技", + "301323": "新莱福", + "301325": "曼恩斯特", + "301326": "捷邦科技", + "301327": "华宝新能", + "301328": "维峰电子", + "301329": "信音电子", + "301330": "熵基科技", + "301331": "恩威医药", + "301332": "德尔玛", + "301333": "诺思格", + "301335": "天元宠物", + "301336": "趣睡科技", + "301337": "亚华电子", + "301338": "凯格精机", + "301339": "通行宝", + "301345": "涛涛车业", + "301348": "蓝箭电子", + "301349": "信德新材", + "301353": "普莱得", + "301355": "南王科技", + "301356": "天振股份", + "301357": "北方长龙", + "301358": "湖南裕能", + "301359": "东南电子", + "301360": "荣旗科技", + "301361": "众智科技", + "301362": "民爆光电", + "301363": "美好医疗", + "301365": "矩阵股份", + "301366": "一博科技", + "301367": "瑞迈特", + "301368": "丰立智能", + "301369": "联动科技", + "301370": "国科恒泰", + "301371": "敷尔佳", + "301372": "科净源", + "301373": "凌玮科技", + "301376": "致欧科技", + "301377": "鼎泰高科", + "301378": "通达海", + "301379": "天山电子", + "301380": "挖金客", + "301381": "赛维时代", + "301382": "蜂助手", + "301383": "天键股份", + "301386": "未来电器", + "301387": "光大同创", + "301388": "欣灵电气", + "301389": "隆扬电子", + "301390": "经纬股份", + "301391": "卡莱特", + "301392": "汇成真空", + "301393": "昊帆生物", + "301395": "仁信新材", + "301396": "宏景科技", + "301397": "溯联股份", + "301398": "星源卓镁", + "301399": "英特科技", + "301408": "华人健康", + "301413": "安培龙", + "301418": "协昌科技", + "301419": "阿莱德", + "301421": "波长光电", + "301428": "世纪恒通", + "301429": "森泰股份", + "301439": "泓淋电力", + "301446": "福事特", + "301448": "开创电气", + "301449": "天溯计量", + "301456": "盘古智能", + "301458": "钧崴电子", + "301459": "丰茂股份", + "301468": "博盈特焊", + "301469": "恒达新材", + "301479": "弘景光电", + "301486": "致尚科技", + "301487": "盟固利", + "301488": "豪恩汽电", + "301489": "思泉新材", + "301491": "汉桑科技", + "301498": "乖宝宠物", + "301499": "维科精密", + "301500": "飞南资源", + "301501": "恒鑫生活", + "301502": "华阳智能", + "301503": "智迪科技", + "301505": "苏州规划", + "301507": "民生健康", + "301508": "中机认检", + "301509": "金凯生科", + "301510": "固高科技", + "301511": "德福科技", + "301512": "智信精密", + "301515": "港通医疗", + "301516": "中远通", + "301517": "陕西华达", + "301518": "长华化学", + "301519": "舜禹股份", + "301520": "万邦医药", + "301522": "上大股份", + "301525": "儒竞科技", + "301526": "国际复材", + "301528": "多浦乐", + "301529": "福赛科技", + "301533": "威马农机", + "301535": "浙江华远", + "301536": "星宸科技", + "301538": "骏鼎达", + "301539": "宏鑫科技", + "301548": "崇德科技", + "301550": "斯菱智驱", + "301551": "无线传媒", + "301552": "科力装备", + "301555": "惠柏新材", + "301556": "托普云农", + "301557": "常友科技", + "301558": "三态股份", + "301559": "中集环科", + "301560": "众捷汽车", + "301563": "云汉芯城", + "301565": "中仑新材", + "301566": "达利凯普", + "301567": "贝隆精密", + "301568": "思泰克", + "301571": "国科天成", + "301575": "艾芬达", + "301577": "美信科技", + "301578": "辰奕智能", + "301580": "爱迪特", + "301581": "黄山谷捷", + "301584": "建发致新", + "301585": "蓝宇股份", + "301586": "佳力奇", + "301587": "中瑞股份", + "301588": "美新科技", + "301589": "诺瓦星云", + "301590": "优优绿能", + "301591": "肯特股份", + "301592": "六九一二", + "301595": "太力科技", + "301596": "瑞迪智驱", + "301598": "博科测试", + "301600": "慧翰股份", + "301601": "惠通科技", + "301602": "超研股份", + "301603": "乔锋智能", + "301606": "绿联科技", + "301607": "富特科技", + "301608": "博实结", + "301609": "山大电力", + "301611": "珂玛科技", + "301613": "新铝时代", + "301616": "浙江华业", + "301617": "博苑股份", + "301618": "长联科技", + "301622": "英思特", + "301626": "苏州天脉", + "301628": "强达电路", + "301629": "矽电股份", + "301630": "同宇新材", + "301631": "壹连科技", + "301632": "广东建科", + "301633": "港迪技术", + "301636": "泽润新能", + "301638": "南网数字", + "301656": "联合动力", + "301658": "首航新能", + "301662": "宏工科技", + "301665": "泰禾股份", + "301667": "纳百川", + "301668": "昊创瑞通", + "301678": "新恒汇", + "301687": "新广益", + "302132": "中航成飞", + "600000": "浦发银行", + "600004": "白云机场", + "600006": "东风股份", + "600007": "中国国贸", + "600008": "首创环保", + "600009": "上海机场", + "600010": "包钢股份", + "600011": "华能国际", + "600012": "皖通高速", + "600015": "华夏银行", + "600016": "民生银行", + "600017": "日照港", + "600018": "上港集团", + "600019": "宝钢股份", + "600020": "中原高速", + "600021": "上海电力", + "600022": "山东钢铁", + "600023": "浙能电力", + "600025": "华能水电", + "600026": "中远海能", + "600027": "华电国际", + "600028": "中国石化", + "600029": "南方航空", + "600030": "中信证券", + "600031": "三一重工", + "600032": "浙江新能", + "600033": "福建高速", + "600035": "楚天高速", + "600036": "招商银行", + "600037": "歌华有线", + "600038": "中直股份", + "600039": "四川路桥", + "600048": "保利发展", + "600050": "中国联通", + "600051": "宁波联合", + "600052": "东望时代", + "600053": "九鼎投资", + "600054": "黄山旅游", + "600055": "万东医疗", + "600056": "中国医药", + "600057": "厦门象屿", + "600058": "五矿发展", + "600059": "古越龙山", + "600060": "海信视像", + "600061": "国投资本", + "600062": "华润双鹤", + "600063": "皖维高新", + "600064": "南京高科", + "600066": "宇通客车", + "600067": "冠城新材", + "600071": "凤凰光学", + "600072": "中船科技", + "600073": "光明肉业", + "600075": "新疆天业", + "600076": "康欣新材", + "600078": "澄星股份", + "600079": "ST人福", + "600080": "金花股份", + "600081": "东风科技", + "600082": "海泰发展", + "600084": "中信尼雅", + "600085": "同仁堂", + "600088": "中视传媒", + "600089": "特变电工", + "600094": "大名城", + "600095": "湘财股份", + "600096": "云天化", + "600097": "开创国际", + "600098": "广州发展", + "600099": "林海股份", + "600100": "同方股份", + "600101": "明星电力", + "600103": "青山纸业", + "600104": "上汽集团", + "600105": "永鼎股份", + "600106": "重庆路桥", + "600107": "ST尔雅", + "600108": "亚盛集团", + "600109": "国金证券", + "600110": "诺德股份", + "600111": "北方稀土", + "600113": "浙江东日", + "600114": "东睦股份", + "600115": "中国东航", + "600116": "三峡水利", + "600117": "西宁特钢", + "600118": "中国卫星", + "600119": "长江投资", + "600120": "浙江东方", + "600121": "郑州煤电", + "600123": "兰花科创", + "600125": "铁龙物流", + "600126": "杭钢股份", + "600127": "金健米业", + "600128": "苏豪弘业", + "600129": "太极集团", + "600130": "*ST波导", + "600131": "国网信通", + "600132": "重庆啤酒", + "600133": "东湖高新", + "600135": "乐凯胶片", + "600136": "ST明诚", + "600137": "浪莎股份", + "600138": "中青旅", + "600141": "兴发集团", + "600143": "金发科技", + "600148": "长春一东", + "600149": "廊坊发展", + "600150": "中国船舶", + "600151": "航天机电", + "600152": "维科技术", + "600153": "建发股份", + "600155": "华创云信", + "600156": "华升股份", + "600157": "永泰能源", + "600158": "中体产业", + "600159": "大龙地产", + "600160": "巨化股份", + "600161": "天坛生物", + "600162": "香江控股", + "600163": "中闽能源", + "600165": "ST宁科", + "600166": "福田汽车", + "600167": "联美控股", + "600168": "武汉控股", + "600169": "ST太重", + "600170": "上海建工", + "600171": "上海贝岭", + "600172": "黄河旋风", + "600173": "卧龙新能", + "600176": "中国巨石", + "600177": "雅戈尔", + "600178": "东安动力", + "600179": "安通控股", + "600180": "瑞茂通", + "600182": "S佳通", + "600183": "生益科技", + "600184": "光电股份", + "600185": "珠免集团", + "600186": "莲花控股", + "600187": "国中水务", + "600188": "兖矿能源", + "600189": "泉阳泉", + "600191": "华资实业", + "600192": "长城电工", + "600193": "*ST创兴", + "600195": "中牧股份", + "600196": "复星医药", + "600197": "伊力特", + "600198": "大唐电信", + "600199": "金种子酒", + "600201": "生物股份", + "600202": "哈空调", + "600203": "福日电子", + "600206": "有研新材", + "600207": "安彩高科", + "600208": "衢州发展", + "600210": "紫江企业", + "600211": "西藏药业", + "600212": "绿能慧充", + "600215": "派斯林", + "600216": "浙江医药", + "600217": "中再资环", + "600218": "全柴动力", + "600219": "南山铝业", + "600221": "海航控股", + "600222": "太龙药业", + "600223": "福瑞达", + "600226": "亨通股份", + "600227": "赤天化", + "600228": "*ST返利", + "600229": "城市传媒", + "600230": "沧州大化", + "600231": "凌钢股份", + "600232": "金鹰股份", + "600233": "圆通速递", + "600234": "科新发展", + "600235": "民丰特纸", + "600236": "桂冠电力", + "600237": "铜峰电子", + "600238": "*ST椰岛", + "600239": "云南城投", + "600241": "时代万恒", + "600243": "*ST海华", + "600246": "万通发展", + "600248": "陕建股份", + "600249": "两面针", + "600250": "南京商旅", + "600251": "冠农股份", + "600252": "中恒集团", + "600255": "鑫科材料", + "600256": "广汇能源", + "600257": "大湖股份", + "600258": "首旅酒店", + "600259": "中稀有色", + "600261": "阳光照明", + "600262": "北方股份", + "600265": "ST景谷", + "600266": "城建发展", + "600267": "海正药业", + "600268": "国电南自", + "600269": "赣粤高速", + "600271": "航天信息", + "600272": "开开实业", + "600273": "嘉化能源", + "600276": "恒瑞医药", + "600278": "东方创业", + "600279": "重庆港", + "600280": "中央商场", + "600281": "华阳新材", + "600282": "南钢股份", + "600283": "钱江水利", + "600284": "浦东建设", + "600285": "羚锐制药", + "600287": "苏豪时尚", + "600288": "大恒科技", + "600289": "ST信通", + "600292": "电投水电", + "600293": "三峡新材", + "600295": "鄂尔多斯", + "600298": "安琪酵母", + "600299": "安迪苏", + "600300": "维维股份", + "600301": "华锡有色", + "600302": "标准股份", + "600303": "曙光股份", + "600305": "恒顺醋业", + "600307": "酒钢宏兴", + "600308": "华泰股份", + "600309": "万华化学", + "600310": "广西能源", + "600312": "平高电气", + "600313": "农发种业", + "600315": "上海家化", + "600316": "洪都航空", + "600318": "新力金融", + "600319": "亚星化学", + "600320": "振华重工", + "600322": "津投城开", + "600323": "瀚蓝环境", + "600325": "华发股份", + "600326": "西藏天路", + "600327": "大东方", + "600328": "中盐化工", + "600329": "达仁堂", + "600330": "天通股份", + "600331": "宏达股份", + "600332": "白云山", + "600333": "长春燃气", + "600335": "国机汽车", + "600336": "澳柯玛", + "600337": "美克家居", + "600338": "西藏珠峰", + "600339": "中油工程", + "600340": "华夏幸福", + "600343": "航天动力", + "600345": "长江通信", + "600346": "恒力石化", + "600348": "华阳股份", + "600350": "山东高速", + "600351": "亚宝药业", + "600352": "浙江龙盛", + "600353": "旭光电子", + "600354": "敦煌种业", + "600355": "*ST精伦", + "600356": "恒丰纸业", + "600358": "ST联合", + "600359": "新农开发", + "600360": "*ST华微", + "600361": "创新新材", + "600362": "江西铜业", + "600363": "联创光电", + "600365": "ST通葡", + "600366": "宁波韵升", + "600367": "红星发展", + "600368": "五洲交通", + "600369": "西南证券", + "600370": "三房巷", + "600371": "万向德农", + "600372": "中航机载", + "600373": "中文传媒", + "600375": "汉马科技", + "600376": "首开股份", + "600377": "宁沪高速", + "600378": "昊华科技", + "600379": "宝光股份", + "600380": "健康元", + "600381": "*ST春天", + "600382": "广东明珠", + "600383": "金地集团", + "600386": "北巴传媒", + "600388": "龙净环保", + "600389": "江山股份", + "600390": "五矿资本", + "600391": "航发科技", + "600392": "盛和资源", + "600395": "盘江股份", + "600396": "华电辽能", + "600397": "江钨装备", + "600398": "海澜之家", + "600399": "抚顺特钢", + "600400": "红豆股份", + "600403": "大有能源", + "600405": "动力源", + "600406": "国电南瑞", + "600408": "安泰集团", + "600409": "三友化工", + "600410": "华胜天成", + "600415": "小商品城", + "600416": "湘电股份", + "600418": "江淮汽车", + "600419": "天润乳业", + "600420": "国药现代", + "600421": "*ST华嵘", + "600422": "昆药集团", + "600423": "柳化股份", + "600425": "青松建化", + "600426": "华鲁恒升", + "600428": "中远海特", + "600429": "三元股份", + "600433": "冠豪高新", + "600435": "北方导航", + "600436": "片仔癀", + "600438": "通威股份", + "600439": "瑞贝卡", + "600444": "国机通用", + "600446": "金证股份", + "600448": "华纺股份", + "600449": "宁夏建材", + "600452": "涪陵电力", + "600455": "博通股份", + "600456": "宝钛股份", + "600458": "时代新材", + "600459": "贵研铂业", + "600460": "士兰微", + "600461": "洪城环境", + "600463": "空港股份", + "600467": "好当家", + "600468": "百利电气", + "600469": "风神股份", + "600470": "六国化工", + "600475": "华光环能", + "600476": "湘邮科技", + "600477": "杭萧钢构", + "600478": "科力远", + "600479": "千金药业", + "600480": "凌云股份", + "600481": "双良节能", + "600482": "中国动力", + "600483": "福能股份", + "600486": "扬农化工", + "600487": "亨通光电", + "600488": "津药药业", + "600489": "中金黄金", + "600490": "鹏欣资源", + "600491": "龙元建设", + "600493": "凤竹纺织", + "600495": "晋西车轴", + "600496": "精工钢构", + "600497": "驰宏锌锗", + "600498": "烽火通信", + "600499": "科达制造", + "600500": "中化国际", + "600501": "航天晨光", + "600502": "安徽建工", + "600503": "华丽家族", + "600505": "西昌电力", + "600506": "统一股份", + "600507": "方大特钢", + "600508": "上海能源", + "600509": "天富能源", + "600510": "黑牡丹", + "600511": "国药股份", + "600512": "腾达建设", + "600513": "联环药业", + "600515": "海南机场", + "600516": "方大炭素", + "600517": "国网英大", + "600518": "康美药业", + "600519": "贵州茅台", + "600520": "三佳科技", + "600521": "华海药业", + "600522": "中天科技", + "600523": "贵航股份", + "600525": "ST长园", + "600526": "菲达环保", + "600527": "江南高纤", + "600528": "中铁工业", + "600529": "山东药玻", + "600530": "交大昂立", + "600531": "豫光金铅", + "600533": "栖霞建设", + "600535": "天士力", + "600536": "中国软件", + "600537": "亿晶光电", + "600538": "国发股份", + "600539": "狮头股份", + "600540": "新赛股份", + "600543": "莫高股份", + "600545": "卓郎智能", + "600546": "山煤国际", + "600547": "山东黄金", + "600548": "深高速", + "600549": "厦门钨业", + "600550": "保变电气", + "600551": "时代出版", + "600552": "凯盛科技", + "600556": "天下秀", + "600557": "康缘药业", + "600558": "大西洋", + "600559": "老白干酒", + "600560": "金自天正", + "600561": "江西长运", + "600562": "国睿科技", + "600563": "法拉电子", + "600566": "济川药业", + "600567": "山鹰国际", + "600568": "ST中珠", + "600569": "安阳钢铁", + "600570": "恒生电子", + "600571": "信雅达", + "600572": "康恩贝", + "600573": "惠泉啤酒", + "600575": "淮河能源", + "600576": "祥源文旅", + "600577": "精达股份", + "600578": "京能电力", + "600579": "中化装备", + "600580": "卧龙电驱", + "600581": "八一钢铁", + "600582": "天地科技", + "600583": "海油工程", + "600584": "长电科技", + "600585": "海螺水泥", + "600586": "金晶科技", + "600587": "新华医疗", + "600588": "用友网络", + "600589": "大位科技", + "600590": "泰豪科技", + "600592": "龙溪股份", + "600593": "大连圣亚", + "600594": "益佰制药", + "600595": "中孚实业", + "600596": "新安股份", + "600597": "光明乳业", + "600598": "北大荒", + "600599": "*ST熊猫", + "600600": "青岛啤酒", + "600601": "方正科技", + "600602": "云赛智联", + "600603": "广汇物流", + "600604": "市北高新", + "600605": "汇通能源", + "600606": "绿地控股", + "600608": "*ST沪科", + "600609": "金杯汽车", + "600610": "中毅达", + "600611": "大众交通", + "600612": "老凤祥", + "600613": "神奇制药", + "600615": "鑫源智造", + "600616": "金枫酒业", + "600617": "国新能源", + "600618": "氯碱化工", + "600619": "海立股份", + "600620": "天宸股份", + "600621": "华鑫股份", + "600622": "光大嘉宝", + "600623": "华谊集团", + "600624": "ST复华", + "600626": "申达股份", + "600628": "新世界", + "600629": "华建集团", + "600630": "龙头股份", + "600633": "浙数文化", + "600635": "大众公用", + "600636": "*ST国化", + "600637": "东方明珠", + "600638": "新黄浦", + "600639": "浦东金桥", + "600640": "国脉文化", + "600641": "先导基电", + "600642": "申能股份", + "600643": "爱建集团", + "600644": "乐山电力", + "600645": "中源协和", + "600648": "外高桥", + "600649": "城投控股", + "600650": "锦江在线", + "600651": "飞乐音响", + "600653": "申华控股", + "600654": "中安科", + "600655": "豫园股份", + "600657": "信达地产", + "600658": "电子城", + "600660": "福耀玻璃", + "600661": "昂立教育", + "600662": "外服控股", + "600663": "陆家嘴", + "600664": "哈药股份", + "600665": "天地源", + "600666": "奥瑞德", + "600667": "太极实业", + "600668": "尖峰集团", + "600671": "天目药业", + "600673": "东阳光", + "600674": "川投能源", + "600675": "中华企业", + "600676": "交运股份", + "600678": "四川金顶", + "600679": "上海凤凰", + "600681": "百川能源", + "600682": "南京新百", + "600683": "京投发展", + "600684": "珠江股份", + "600685": "中船防务", + "600686": "金龙汽车", + "600688": "上海石化", + "600689": "上海三毛", + "600690": "海尔智家", + "600691": "潞化科技", + "600692": "亚通股份", + "600693": "东百集团", + "600694": "大商股份", + "600696": "*ST岩石", + "600697": "欧亚集团", + "600698": "湖南天雁", + "600699": "均胜电子", + "600702": "舍得酒业", + "600703": "三安光电", + "600704": "物产中大", + "600706": "曲江文旅", + "600707": "彩虹股份", + "600708": "光明地产", + "600710": "苏美达", + "600711": "盛屯矿业", + "600712": "南宁百货", + "600713": "南京医药", + "600714": "金瑞矿业", + "600715": "文投控股", + "600716": "凤凰股份", + "600717": "天津港", + "600718": "东软集团", + "600719": "大连热电", + "600720": "中交设计", + "600721": "百花医药", + "600722": "金牛化工", + "600724": "宁波富达", + "600725": "云维股份", + "600726": "华电能源", + "600727": "鲁北化工", + "600728": "佳都科技", + "600729": "重庆百货", + "600730": "中国高科", + "600731": "湖南海利", + "600732": "爱旭股份", + "600733": "北汽蓝谷", + "600734": "实达集团", + "600735": "ST新华锦", + "600736": "苏州高新", + "600737": "中粮糖业", + "600738": "丽尚国潮", + "600739": "辽宁成大", + "600740": "山西焦化", + "600741": "华域汽车", + "600742": "富维股份", + "600743": "华远控股", + "600744": "华银电力", + "600745": "闻泰科技", + "600746": "江苏索普", + "600748": "上实发展", + "600749": "西藏旅游", + "600750": "江中药业", + "600751": "海航科技", + "600753": "*ST海钦", + "600754": "锦江酒店", + "600755": "厦门国贸", + "600756": "浪潮软件", + "600757": "长江传媒", + "600758": "辽宁能源", + "600759": "洲际油气", + "600760": "中航沈飞", + "600761": "安徽合力", + "600763": "通策医疗", + "600764": "中国海防", + "600765": "中航重机", + "600768": "宁波富邦", + "600769": "祥龙电业", + "600770": "综艺股份", + "600771": "广誉远", + "600773": "西藏城投", + "600774": "汉商集团", + "600775": "南京熊猫", + "600776": "东方通信", + "600777": "*ST新潮", + "600778": "友好集团", + "600779": "水井坊", + "600780": "通宝能源", + "600782": "新钢股份", + "600783": "鲁信创投", + "600784": "鲁银投资", + "600785": "新华百货", + "600787": "中储股份", + "600789": "鲁抗医药", + "600790": "轻纺城", + "600791": "京能置业", + "600792": "云煤能源", + "600793": "宜宾纸业", + "600794": "保税科技", + "600795": "国电电力", + "600796": "钱江生化", + "600797": "浙大网新", + "600798": "宁波海运", + "600800": "渤海化学", + "600801": "华新建材", + "600802": "福建水泥", + "600803": "新奥股份", + "600805": "悦达投资", + "600807": "济高发展", + "600808": "马钢股份", + "600809": "山西汾酒", + "600810": "神马股份", + "600812": "华北制药", + "600814": "杭州解百", + "600815": "厦工股份", + "600816": "建元信托", + "600817": "宇通重工", + "600818": "中路股份", + "600819": "耀皮玻璃", + "600820": "隧道股份", + "600821": "金开新能", + "600822": "上海物贸", + "600824": "益民集团", + "600825": "新华传媒", + "600826": "兰生股份", + "600827": "百联股份", + "600828": "茂业商业", + "600829": "人民同泰", + "600830": "香溢融通", + "600831": "广电网络", + "600833": "第一医药", + "600834": "申通地铁", + "600835": "上海机电", + "600838": "上海九百", + "600839": "四川长虹", + "600841": "动力新科", + "600843": "上工申贝", + "600844": "金煤科技", + "600845": "宝信软件", + "600846": "同济科技", + "600847": "万里股份", + "600848": "上海临港", + "600850": "电科数字", + "600851": "海欣股份", + "600853": "龙建股份", + "600854": "春兰股份", + "600855": "航天长峰", + "600857": "宁波中百", + "600858": "银座股份", + "600859": "王府井", + "600860": "京城股份", + "600861": "北京人力", + "600862": "中航高科", + "600863": "内蒙华电", + "600864": "哈投股份", + "600865": "百大集团", + "600866": "星湖科技", + "600867": "通化东宝", + "600868": "梅雁吉祥", + "600869": "远东股份", + "600871": "石化油服", + "600872": "中炬高新", + "600873": "梅花生物", + "600874": "创业环保", + "600875": "东方电气", + "600876": "凯盛新能", + "600877": "电科芯片", + "600879": "航天电子", + "600880": "博瑞传播", + "600881": "亚泰集团", + "600882": "妙可蓝多", + "600883": "博闻科技", + "600884": "杉杉股份", + "600885": "宏发股份", + "600886": "国投电力", + "600887": "伊利股份", + "600888": "新疆众和", + "600889": "南京化纤", + "600892": "*ST大晟", + "600893": "航发动力", + "600894": "广日股份", + "600895": "张江高科", + "600897": "厦门空港", + "600900": "长江电力", + "600901": "江苏金租", + "600903": "贵州燃气", + "600905": "三峡能源", + "600906": "财达证券", + "600908": "无锡银行", + "600909": "华安证券", + "600916": "中国黄金", + "600917": "重庆燃气", + "600918": "中泰证券", + "600919": "江苏银行", + "600925": "苏能股份", + "600926": "杭州银行", + "600927": "永安期货", + "600928": "西安银行", + "600929": "雪天盐业", + "600930": "华电新能", + "600933": "爱柯迪", + "600935": "华塑股份", + "600936": "北投科技", + "600938": "中国海油", + "600939": "重庆建工", + "600941": "中国移动", + "600955": "维远股份", + "600956": "新天绿能", + "600958": "东方证券", + "600959": "江苏有线", + "600960": "渤海汽车", + "600961": "株冶集团", + "600962": "国投中鲁", + "600963": "岳阳林纸", + "600965": "福成股份", + "600966": "博汇纸业", + "600967": "内蒙一机", + "600968": "海油发展", + "600969": "郴电国际", + "600970": "中材国际", + "600971": "恒源煤电", + "600973": "宝胜股份", + "600975": "新五丰", + "600976": "健民集团", + "600977": "中国电影", + "600979": "广安爱众", + "600980": "北矿科技", + "600981": "苏豪汇鸿", + "600982": "宁波能源", + "600983": "惠而浦", + "600984": "建设机械", + "600985": "淮北矿业", + "600986": "浙文互联", + "600987": "航民股份", + "600988": "赤峰黄金", + "600989": "宝丰能源", + "600990": "四创电子", + "600992": "贵绳股份", + "600993": "马应龙", + "600995": "南网储能", + "600996": "贵广网络", + "600997": "开滦股份", + "600998": "九州通", + "600999": "招商证券", + "601000": "唐山港", + "601001": "晋控煤业", + "601002": "晋亿实业", + "601003": "柳钢股份", + "601005": "重庆钢铁", + "601006": "大秦铁路", + "601007": "金陵饭店", + "601008": "连云港", + "601009": "南京银行", + "601010": "文峰股份", + "601011": "宝泰隆", + "601012": "隆基绿能", + "601015": "陕西黑猫", + "601016": "节能风电", + "601018": "宁波港", + "601019": "山东出版", + "601020": "华钰矿业", + "601021": "春秋航空", + "601022": "宁波远洋", + "601026": "道生天合", + "601033": "永兴股份", + "601038": "一拖股份", + "601058": "赛轮轮胎", + "601059": "信达证券", + "601061": "中信金属", + "601065": "江盐集团", + "601066": "中信建投", + "601068": "中铝国际", + "601069": "西部黄金", + "601077": "渝农商行", + "601083": "锦江航运", + "601086": "国芳集团", + "601088": "中国神华", + "601089": "福元医药", + "601096": "宏盛华源", + "601098": "中南传媒", + "601099": "太平洋", + "601100": "恒立液压", + "601101": "昊华能源", + "601106": "中国一重", + "601107": "四川成渝", + "601108": "财通证券", + "601111": "中国国航", + "601113": "华鼎股份", + "601116": "三江购物", + "601117": "中国化学", + "601118": "海南橡胶", + "601121": "宝地矿业", + "601126": "四方股份", + "601127": "赛力斯", + "601128": "常熟银行", + "601133": "柏诚股份", + "601136": "首创证券", + "601137": "博威合金", + "601138": "工业富联", + "601139": "深圳燃气", + "601155": "新城控股", + "601156": "东航物流", + "601158": "重庆水务", + "601162": "天风证券", + "601163": "三角轮胎", + "601166": "兴业银行", + "601168": "西部矿业", + "601169": "北京银行", + "601177": "杭齿前进", + "601179": "中国西电", + "601186": "中国铁建", + "601187": "厦门银行", + "601188": "龙江交通", + "601198": "东兴证券", + "601199": "江南水务", + "601200": "上海环境", + "601208": "东材科技", + "601211": "国泰海通", + "601212": "白银有色", + "601216": "君正集团", + "601218": "吉鑫科技", + "601222": "林洋能源", + "601225": "陕西煤业", + "601226": "华电科工", + "601228": "广州港", + "601229": "上海银行", + "601231": "环旭电子", + "601233": "桐昆股份", + "601236": "红塔证券", + "601238": "广汽集团", + "601279": "英利汽车", + "601288": "农业银行", + "601298": "青岛港", + "601311": "骆驼股份", + "601318": "中国平安", + "601319": "中国人保", + "601326": "秦港股份", + "601328": "交通银行", + "601330": "绿色动力", + "601333": "广深铁路", + "601336": "新华保险", + "601339": "百隆东方", + "601360": "三六零", + "601366": "利群股份", + "601368": "绿城水务", + "601369": "陕鼓动力", + "601375": "中原证券", + "601377": "兴业证券", + "601388": "怡球资源", + "601390": "中国中铁", + "601398": "工商银行", + "601399": "国机重装", + "601456": "国联民生", + "601500": "通用股份", + "601512": "中新集团", + "601515": "衢州东峰", + "601518": "吉林高速", + "601519": "大智慧", + "601528": "瑞丰银行", + "601555": "东吴证券", + "601566": "九牧王", + "601567": "三星医疗", + "601568": "北元集团", + "601577": "长沙银行", + "601579": "会稽山", + "601588": "北辰实业", + "601595": "上海电影", + "601598": "中国外运", + "601599": "浙文影业", + "601600": "中国铝业", + "601601": "中国太保", + "601606": "长城军工", + "601607": "上海医药", + "601608": "中信重工", + "601609": "金田股份", + "601611": "中国核建", + "601615": "明阳智能", + "601616": "广电电气", + "601618": "中国中冶", + "601619": "嘉泽新能", + "601628": "中国人寿", + "601633": "长城汽车", + "601636": "旗滨集团", + "601658": "邮储银行", + "601665": "齐鲁银行", + "601666": "平煤股份", + "601668": "中国建筑", + "601669": "中国电建", + "601677": "明泰铝业", + "601678": "滨化股份", + "601686": "友发集团", + "601688": "华泰证券", + "601689": "拓普集团", + "601696": "中银证券", + "601698": "中国卫通", + "601699": "潞安环能", + "601700": "风范股份", + "601702": "华峰铝业", + "601717": "中创智领", + "601718": "际华集团", + "601727": "上海电气", + "601728": "中国电信", + "601766": "中国中车", + "601777": "千里科技", + "601778": "晶科科技", + "601788": "光大证券", + "601789": "宁波建工", + "601798": "蓝科高新", + "601799": "星宇股份", + "601800": "中国交建", + "601801": "皖新传媒", + "601808": "中海油服", + "601811": "新华文轩", + "601816": "京沪高铁", + "601818": "光大银行", + "601825": "沪农商行", + "601827": "三峰环境", + "601828": "美凯龙", + "601838": "成都银行", + "601857": "中国石油", + "601858": "中国科传", + "601860": "紫金银行", + "601865": "福莱特", + "601866": "中远海发", + "601868": "中国能建", + "601869": "长飞光纤", + "601872": "招商轮船", + "601877": "正泰电器", + "601878": "浙商证券", + "601880": "辽港股份", + "601881": "中国银河", + "601882": "海天精工", + "601886": "江河集团", + "601888": "中国中免", + "601890": "亚星锚链", + "601898": "中煤能源", + "601899": "紫金矿业", + "601900": "南方传媒", + "601901": "方正证券", + "601908": "京运通", + "601916": "浙商银行", + "601918": "新集能源", + "601919": "中远海控", + "601921": "浙版传媒", + "601928": "凤凰传媒", + "601929": "吉视传媒", + "601933": "永辉超市", + "601939": "建设银行", + "601949": "中国出版", + "601952": "苏垦农发", + "601956": "东贝集团", + "601958": "金钼股份", + "601963": "重庆银行", + "601965": "中国汽研", + "601966": "玲珑轮胎", + "601968": "宝钢包装", + "601969": "海南矿业", + "601975": "招商南油", + "601985": "中国核电", + "601988": "中国银行", + "601990": "南京证券", + "601991": "大唐发电", + "601992": "金隅集团", + "601995": "中金公司", + "601996": "丰林集团", + "601997": "贵阳银行", + "601998": "中信银行", + "601999": "出版传媒", + "603000": "人民网", + "603001": "奥康国际", + "603002": "宏昌电子", + "603004": "鼎龙科技", + "603005": "晶方科技", + "603006": "联明股份", + "603007": "*ST花王", + "603008": "喜临门", + "603009": "北特科技", + "603010": "万盛股份", + "603011": "合锻智能", + "603012": "创力集团", + "603013": "亚普股份", + "603014": "威高血净", + "603015": "弘讯科技", + "603016": "新宏泰", + "603017": "中衡设计", + "603018": "华设集团", + "603019": "中科曙光", + "603020": "爱普股份", + "603021": "ST华鹏", + "603022": "新通联", + "603023": "威帝股份", + "603025": "大豪科技", + "603026": "石大胜华", + "603027": "千禾味业", + "603028": "赛福天", + "603029": "天鹅股份", + "603030": "全筑股份", + "603031": "安孚科技", + "603032": "德新科技", + "603033": "三维股份", + "603035": "常熟汽饰", + "603036": "如通股份", + "603037": "凯众股份", + "603038": "华立股份", + "603039": "泛微网络", + "603040": "新坐标", + "603041": "美思德", + "603042": "华脉科技", + "603043": "广州酒家", + "603045": "福达合金", + "603048": "浙江黎明", + "603049": "中策橡胶", + "603050": "科林电气", + "603051": "鹿山新材", + "603052": "可川科技", + "603053": "成都燃气", + "603055": "台华新材", + "603056": "德邦股份", + "603057": "紫燕食品", + "603058": "永吉股份", + "603059": "倍加洁", + "603060": "国检集团", + "603061": "金海通", + "603062": "麦加芯彩", + "603063": "禾望电气", + "603065": "宿迁联盛", + "603066": "音飞储存", + "603067": "振华股份", + "603068": "博通集成", + "603069": "海汽集团", + "603070": "万控智造", + "603071": "物产环能", + "603072": "天和磁材", + "603073": "彩蝶实业", + "603075": "热威股份", + "603076": "乐惠国际", + "603077": "和邦生物", + "603078": "江化微", + "603079": "圣达生物", + "603080": "新疆火炬", + "603081": "大丰实业", + "603082": "北自科技", + "603083": "剑桥科技", + "603085": "天成自控", + "603086": "先达股份", + "603087": "甘李药业", + "603088": "宁波精达", + "603089": "正裕工业", + "603090": "宏盛股份", + "603091": "众鑫股份", + "603092": "德力佳", + "603093": "南华期货", + "603095": "越剑智能", + "603096": "新经典", + "603097": "江苏华辰", + "603098": "森特股份", + "603099": "长白山", + "603100": "川仪股份", + "603101": "汇嘉时代", + "603102": "百合股份", + "603103": "横店影视", + "603105": "芯能科技", + "603106": "恒银科技", + "603107": "上海汽配", + "603108": "润达医疗", + "603109": "神驰机电", + "603110": "东方材料", + "603111": "康尼机电", + "603112": "华翔股份", + "603113": "金能科技", + "603115": "海星股份", + "603116": "红蜻蜓", + "603117": "万林物流", + "603118": "共进股份", + "603119": "浙江荣泰", + "603120": "肯特催化", + "603121": "华培动力", + "603122": "合富中国", + "603123": "翠微股份", + "603124": "江南新材", + "603125": "常青科技", + "603126": "中材节能", + "603127": "昭衍新药", + "603128": "华贸物流", + "603129": "春风动力", + "603130": "云中马", + "603131": "上海沪工", + "603132": "金徽股份", + "603135": "中重科技", + "603136": "天目湖", + "603137": "恒尚节能", + "603138": "海量数据", + "603139": "康惠股份", + "603150": "万朗磁塑", + "603151": "邦基科技", + "603153": "上海建科", + "603155": "新亚强", + "603156": "养元饮品", + "603158": "腾龙股份", + "603159": "上海亚虹", + "603160": "汇顶科技", + "603161": "科华控股", + "603162": "海通发展", + "603163": "圣晖集成", + "603165": "荣晟环保", + "603166": "福达股份", + "603167": "渤海轮渡", + "603168": "莎普爱思", + "603169": "兰石重装", + "603170": "宝立食品", + "603171": "税友股份", + "603172": "万丰股份", + "603173": "福斯达", + "603175": "超颖电子", + "603176": "汇通集团", + "603177": "德创环保", + "603178": "圣龙股份", + "603179": "新泉股份", + "603180": "金牌家居", + "603181": "皇马科技", + "603182": "嘉华股份", + "603183": "建研院", + "603185": "弘元绿能", + "603186": "华正新材", + "603187": "海容冷链", + "603188": "亚邦股份", + "603189": "网达软件", + "603190": "亚通精工", + "603191": "望变电气", + "603192": "汇得科技", + "603193": "润本股份", + "603194": "中力股份", + "603195": "公牛集团", + "603196": "日播时尚", + "603197": "保隆科技", + "603198": "迎驾贡酒", + "603199": "九华旅游", + "603200": "上海洗霸", + "603201": "常润股份", + "603202": "天有为", + "603203": "快克智能", + "603205": "健尔康", + "603206": "嘉环科技", + "603207": "小方制药", + "603208": "江山欧派", + "603209": "兴通股份", + "603210": "泰鸿万立", + "603211": "晋拓股份", + "603212": "赛伍技术", + "603213": "镇洋发展", + "603214": "爱婴室", + "603215": "比依股份", + "603216": "梦天家居", + "603217": "元利科技", + "603218": "日月股份", + "603219": "富佳股份", + "603220": "中贝通信", + "603221": "爱丽家居", + "603222": "济民健康", + "603223": "恒通股份", + "603225": "新凤鸣", + "603226": "菲林格尔", + "603227": "雪峰科技", + "603228": "景旺电子", + "603229": "奥翔药业", + "603230": "内蒙新华", + "603231": "索宝蛋白", + "603232": "格尔软件", + "603233": "大参林", + "603235": "天新药业", + "603236": "移远通信", + "603237": "五芳斋", + "603238": "诺邦股份", + "603239": "浙江仙通", + "603248": "锡华科技", + "603255": "鼎际得", + "603256": "宏和科技", + "603257": "中国瑞林", + "603258": "电魂网络", + "603259": "药明康德", + "603260": "合盛硅业", + "603261": "*ST立航", + "603262": "技源集团", + "603266": "天龙股份", + "603267": "鸿远电子", + "603268": "*ST松发", + "603269": "海鸥股份", + "603270": "金帝股份", + "603271": "永杰新材", + "603272": "联翔股份", + "603273": "天元智能", + "603275": "众辰科技", + "603276": "恒兴新材", + "603277": "银都股份", + "603278": "大业股份", + "603279": "景津装备", + "603280": "南方路机", + "603281": "江瀚新材", + "603282": "亚光股份", + "603283": "赛腾股份", + "603285": "键邦股份", + "603286": "日盈电子", + "603288": "海天味业", + "603289": "泰瑞机器", + "603290": "斯达半导", + "603291": "联合水务", + "603296": "华勤技术", + "603297": "永新光学", + "603298": "杭叉集团", + "603299": "苏盐井神", + "603300": "海南华铁", + "603301": "振德医疗", + "603303": "得邦照明", + "603305": "旭升集团", + "603306": "华懋科技", + "603307": "扬州金泉", + "603308": "应流股份", + "603309": "维力医疗", + "603310": "巍华新材", + "603311": "金海高科", + "603312": "西典新能", + "603313": "梦百合", + "603315": "福鞍股份", + "603316": "诚邦股份", + "603317": "天味食品", + "603318": "水发燃气", + "603319": "美湖股份", + "603320": "迪贝电气", + "603321": "梅轮电梯", + "603322": "超讯通信", + "603323": "苏农银行", + "603324": "盛剑科技", + "603325": "博隆技术", + "603326": "我乐家居", + "603327": "福蓉科技", + "603328": "依顿电子", + "603329": "上海雅仕", + "603330": "天洋新材", + "603331": "百达精工", + "603332": "苏州龙杰", + "603333": "尚纬股份", + "603334": "丰倍生物", + "603335": "迪生力", + "603336": "宏辉果蔬", + "603337": "杰克科技", + "603338": "浙江鼎力", + "603339": "四方科技", + "603341": "龙旗科技", + "603344": "星德胜", + "603345": "安井食品", + "603348": "文灿股份", + "603350": "安乃达", + "603351": "威尔药业", + "603352": "至信股份", + "603353": "和顺石油", + "603355": "莱克电气", + "603356": "华菱精工", + "603357": "设计总院", + "603358": "华达科技", + "603359": "东珠生态", + "603360": "百傲化学", + "603363": "傲农生物", + "603365": "水星家纺", + "603366": "日出东方", + "603367": "辰欣药业", + "603368": "柳药集团", + "603369": "今世缘", + "603370": "华新精科", + "603373": "安邦护卫", + "603375": "盛景微", + "603376": "大明电子", + "603377": "ST东时", + "603378": "亚士创能", + "603379": "三美股份", + "603380": "易德龙", + "603381": "永臻股份", + "603382": "海阳科技", + "603383": "顶点软件", + "603385": "惠达卫浴", + "603386": "骏亚科技", + "603387": "基蛋生物", + "603389": "*ST亚振", + "603390": "通达电气", + "603391": "力聚热能", + "603392": "万泰生物", + "603393": "新天然气", + "603395": "红四方", + "603396": "金辰股份", + "603398": "*ST沐邦", + "603399": "永杉锂业", + "603400": "华之杰", + "603402": "陕西旅游", + "603406": "天富龙", + "603408": "建霖家居", + "603409": "汇通控股", + "603416": "信捷电气", + "603418": "友升股份", + "603421": "鼎信通讯", + "603429": "集友股份", + "603439": "三力制药", + "603444": "吉比特", + "603456": "九洲药业", + "603458": "勘设股份", + "603466": "风语筑", + "603477": "巨星农牧", + "603486": "科沃斯", + "603488": "展鹏科技", + "603489": "八方股份", + "603496": "恒为科技", + "603499": "翔港科技", + "603500": "祥和实业", + "603501": "豪威集团", + "603505": "金石资源", + "603506": "南都物业", + "603507": "振江股份", + "603508": "思维列控", + "603511": "爱慕股份", + "603515": "欧普照明", + "603516": "淳中科技", + "603517": "ST绝味", + "603518": "锦泓集团", + "603519": "立霸股份", + "603520": "司太立", + "603527": "众源新材", + "603528": "多伦科技", + "603529": "爱玛科技", + "603530": "神马电力", + "603533": "掌阅科技", + "603535": "嘉诚国际", + "603536": "惠发食品", + "603538": "美诺华", + "603551": "奥普科技", + "603556": "海兴电力", + "603557": "ST起步", + "603558": "健盛集团", + "603559": "ST通脉", + "603565": "中谷物流", + "603566": "普莱柯", + "603567": "珍宝岛", + "603568": "伟明环保", + "603569": "长久物流", + "603577": "汇金通", + "603578": "三星新材", + "603579": "荣泰健康", + "603580": "*ST艾艾", + "603583": "捷昌驱动", + "603585": "苏利股份", + "603586": "金麒麟", + "603587": "地素时尚", + "603588": "高能环境", + "603589": "口子窖", + "603590": "康辰药业", + "603595": "ST东尼", + "603596": "伯特利", + "603598": "引力传媒", + "603599": "广信股份", + "603600": "永艺股份", + "603601": "再升科技", + "603602": "纵横通信", + "603605": "珀莱雅", + "603606": "东方电缆", + "603607": "京华激光", + "603608": "天创时尚", + "603609": "禾丰股份", + "603610": "麒盛科技", + "603611": "诺力股份", + "603612": "索通发展", + "603613": "国联股份", + "603615": "茶花股份", + "603616": "韩建河山", + "603617": "君禾股份", + "603618": "杭电股份", + "603619": "中曼石油", + "603626": "科森科技", + "603628": "清源股份", + "603629": "利通电子", + "603630": "拉芳家化", + "603633": "徕木股份", + "603636": "南威软件", + "603637": "镇海股份", + "603638": "艾迪精密", + "603639": "海利尔", + "603648": "畅联股份", + "603650": "彤程新材", + "603655": "朗博科技", + "603656": "泰禾智能", + "603657": "春光科技", + "603658": "安图生物", + "603659": "璞泰来", + "603660": "苏州科达", + "603661": "恒林股份", + "603662": "柯力传感", + "603663": "三祥新材", + "603665": "康隆达", + "603666": "亿嘉和", + "603667": "五洲新春", + "603668": "天马科技", + "603669": "灵康药业", + "603676": "卫信康", + "603677": "奇精机械", + "603678": "火炬电子", + "603679": "华体科技", + "603680": "今创集团", + "603681": "永冠新材", + "603682": "锦和商管", + "603683": "晶华新材", + "603685": "晨丰科技", + "603686": "福龙马", + "603687": "大胜达", + "603688": "石英股份", + "603689": "皖天然气", + "603690": "至纯科技", + "603693": "江苏新能", + "603696": "安记食品", + "603697": "有友食品", + "603698": "航天工程", + "603699": "纽威股份", + "603700": "宁水集团", + "603701": "德宏股份", + "603703": "盛洋科技", + "603706": "东方环宇", + "603707": "健友股份", + "603708": "家家悦", + "603709": "中源家居", + "603711": "香飘飘", + "603712": "七一二", + "603713": "密尔克卫", + "603716": "塞力医疗", + "603717": "天域生物", + "603718": "海利生物", + "603719": "良品铺子", + "603721": "*ST天择", + "603722": "阿科力", + "603725": "天安新材", + "603726": "朗迪集团", + "603727": "博迈科", + "603728": "鸣志电器", + "603729": "龙韵股份", + "603730": "岱美股份", + "603733": "仙鹤股份", + "603737": "三棵树", + "603738": "泰晶科技", + "603739": "蔚蓝生物", + "603755": "日辰股份", + "603757": "大元泵业", + "603758": "秦安股份", + "603759": "海天股份", + "603766": "隆鑫通用", + "603767": "中马传动", + "603768": "常青股份", + "603773": "沃格光电", + "603776": "永安行", + "603777": "来伊份", + "603778": "国晟科技", + "603779": "威龙股份", + "603786": "科博达", + "603787": "新日股份", + "603788": "宁波高发", + "603789": "*ST星农", + "603790": "雅运股份", + "603797": "联泰环保", + "603798": "康普顿", + "603799": "华友钴业", + "603800": "洪田股份", + "603801": "志邦家居", + "603803": "瑞斯康达", + "603806": "福斯特", + "603808": "歌力思", + "603809": "豪能股份", + "603810": "丰山集团", + "603811": "诚意药业", + "603813": "*ST原尚", + "603815": "交建股份", + "603816": "顾家家居", + "603817": "海峡环保", + "603818": "曲美家居", + "603819": "神力股份", + "603822": "ST嘉澳", + "603823": "百合花", + "603825": "ST华扬", + "603826": "坤彩科技", + "603828": "ST柯利达", + "603829": "洛凯股份", + "603833": "欧派家居", + "603836": "海程邦达", + "603838": "*ST四通", + "603839": "安正时尚", + "603843": "*ST正平", + "603848": "好太太", + "603855": "华荣股份", + "603856": "东宏股份", + "603858": "步长制药", + "603859": "能科科技", + "603860": "中公高科", + "603861": "白云电器", + "603863": "松炀资源", + "603866": "桃李面包", + "603867": "新化股份", + "603868": "飞科电器", + "603869": "ST智知", + "603871": "嘉友国际", + "603876": "鼎胜新材", + "603877": "太平鸟", + "603878": "武进不锈", + "603879": "永悦科技", + "603880": "南卫股份", + "603881": "数据港", + "603882": "金域医学", + "603883": "老百姓", + "603885": "吉祥航空", + "603886": "元祖股份", + "603887": "城地香江", + "603888": "新华网", + "603889": "新澳股份", + "603890": "春秋电子", + "603893": "瑞芯微", + "603895": "天永智能", + "603896": "寿仙谷", + "603897": "长城科技", + "603898": "好莱客", + "603899": "晨光股份", + "603900": "莱绅通灵", + "603901": "永创智能", + "603903": "中持股份", + "603906": "龙蟠科技", + "603908": "牧高笛", + "603909": "建发合诚", + "603912": "佳力图", + "603915": "国茂股份", + "603916": "苏博特", + "603917": "合力科技", + "603918": "金桥信息", + "603919": "金徽酒", + "603920": "世运电路", + "603922": "金鸿顺", + "603926": "铁流股份", + "603927": "中科软", + "603928": "兴业股份", + "603929": "亚翔集成", + "603931": "格林达", + "603933": "睿能科技", + "603936": "博敏电子", + "603937": "丽岛新材", + "603938": "三孚股份", + "603939": "益丰药房", + "603948": "建业股份", + "603949": "雪龙集团", + "603950": "长源东谷", + "603955": "大千生态", + "603956": "威派格", + "603958": "哈森股份", + "603959": "百利科技", + "603960": "克来机电", + "603966": "法兰泰克", + "603967": "中创物流", + "603968": "醋化股份", + "603969": "银龙股份", + "603970": "中农立华", + "603976": "正川股份", + "603977": "国泰集团", + "603978": "深圳新星", + "603979": "金诚信", + "603980": "吉华集团", + "603982": "泉峰汽车", + "603983": "丸美生物", + "603985": "恒润股份", + "603986": "兆易创新", + "603987": "康德莱", + "603988": "中电电机", + "603989": "艾华集团", + "603990": "麦迪科技", + "603991": "至正股份", + "603992": "松霖科技", + "603993": "洛阳钼业", + "603995": "甬金股份", + "603997": "继峰股份", + "603998": "方盛制药", + "603999": "读者传媒", + "605001": "威奥股份", + "605003": "众望布艺", + "605005": "合兴股份", + "605006": "山东玻纤", + "605007": "五洲特纸", + "605008": "长鸿高科", + "605009": "豪悦护理", + "605011": "杭州热电", + "605016": "百龙创园", + "605018": "长华集团", + "605020": "永和股份", + "605028": "世茂能源", + "605033": "美邦股份", + "605050": "福然德", + "605055": "迎丰股份", + "605056": "咸亨国际", + "605058": "澳弘电子", + "605060": "联德股份", + "605066": "天正电气", + "605068": "明新旭腾", + "605069": "正和生态", + "605077": "华康股份", + "605080": "浙江自然", + "605081": "*ST太和", + "605086": "龙高股份", + "605088": "冠盛股份", + "605089": "味知香", + "605090": "九丰能源", + "605098": "行动教育", + "605099": "共创草坪", + "605100": "华丰股份", + "605108": "同庆楼", + "605111": "新洁能", + "605116": "奥锐特", + "605117": "德业股份", + "605118": "力鼎光电", + "605122": "四方新材", + "605123": "派克新材", + "605128": "上海沿浦", + "605133": "嵘泰股份", + "605136": "丽人丽妆", + "605138": "盛泰集团", + "605151": "西上海", + "605155": "西大门", + "605158": "华达新材", + "605162": "新中港", + "605166": "聚合顺", + "605167": "利柏特", + "605168": "三人行", + "605169": "洪通燃气", + "605177": "东亚药业", + "605178": "时空科技", + "605179": "一鸣食品", + "605180": "华生科技", + "605183": "确成股份", + "605186": "健麾信息", + "605188": "国光连锁", + "605189": "富春染织", + "605196": "华通线缆", + "605198": "安德利", + "605199": "ST葫芦娃", + "605208": "永茂泰", + "605218": "伟时电子", + "605222": "起帆电缆", + "605228": "神通科技", + "605255": "天普股份", + "605258": "协和电子", + "605259": "绿田机械", + "605266": "健之佳", + "605268": "王力安防", + "605277": "新亚电子", + "605286": "同力天启", + "605287": "德才股份", + "605288": "凯迪股份", + "605289": "罗曼股份", + "605296": "神农集团", + "605298": "必得科技", + "605299": "舒华体育", + "605300": "佳禾食品", + "605303": "园林股份", + "605305": "中际联合", + "605318": "法狮龙", + "605319": "无锡振华", + "605333": "沪光股份", + "605336": "帅丰电器", + "605337": "李子园", + "605338": "巴比食品", + "605339": "南侨食品", + "605358": "立昂微", + "605365": "立达信", + "605366": "宏柏新材", + "605368": "蓝天燃气", + "605369": "拱东医疗", + "605376": "博迁新材", + "605377": "华旺科技", + "605378": "野马电池", + "605388": "均瑶健康", + "605389": "长龄液压", + "605398": "新炬网络", + "605399": "晨光新材", + "605488": "福莱新材", + "605499": "东鹏饮料", + "605500": "森林包装", + "605507": "国邦医药", + "605555": "德昌股份", + "605566": "福莱蒽特", + "605567": "春雪食品", + "605577": "龙版传媒", + "605580": "恒盛能源", + "605588": "冠石科技", + "605589": "圣泉集团", + "605598": "上海港湾", + "605599": "菜百股份", + "688001": "华兴源创", + "688002": "睿创微纳", + "688003": "天准科技", + "688004": "博汇科技", + "688005": "容百科技", + "688006": "杭可科技", + "688007": "光峰科技", + "688008": "澜起科技", + "688009": "中国通号", + "688010": "福光股份", + "688011": "新光光电", + "688012": "中微公司", + "688013": "天臣医疗", + "688015": "交控科技", + "688016": "心脉医疗", + "688017": "绿的谐波", + "688018": "乐鑫科技", + "688019": "安集科技", + "688020": "方邦股份", + "688021": "奥福科技", + "688022": "瀚川智能", + "688023": "安恒信息", + "688025": "杰普特", + "688026": "洁特生物", + "688027": "国盾量子", + "688028": "沃尔德", + "688029": "南微医学", + "688030": "山石网科", + "688031": "星环科技", + "688032": "禾迈股份", + "688033": "天宜新材", + "688035": "德邦科技", + "688036": "传音控股", + "688037": "芯源微", + "688038": "中科通达", + "688039": "当虹科技", + "688041": "海光信息", + "688045": "必易微", + "688046": "药康生物", + "688047": "龙芯中科", + "688048": "长光华芯", + "688049": "炬芯科技", + "688050": "爱博医疗", + "688051": "佳华科技", + "688052": "纳芯微", + "688053": "ST思科瑞", + "688055": "龙腾光电", + "688056": "莱伯泰科", + "688057": "金达莱", + "688058": "宝兰德", + "688059": "华锐精密", + "688060": "云涌科技", + "688061": "灿瑞科技", + "688062": "迈威生物", + "688063": "派能科技", + "688065": "凯赛生物", + "688066": "航天宏图", + "688067": "爱威科技", + "688068": "热景生物", + "688069": "德林海", + "688070": "纵横股份", + "688071": "华依科技", + "688072": "拓荆科技", + "688073": "毕得医药", + "688075": "安旭生物", + "688076": "ST诺泰", + "688077": "大地熊", + "688078": "龙软科技", + "688079": "美迪凯", + "688080": "映翰通", + "688081": "兴图新科", + "688082": "盛美上海", + "688083": "中望软件", + "688084": "晶品特装", + "688085": "三友医疗", + "688087": "英科再生", + "688088": "虹软科技", + "688089": "嘉必优", + "688090": "瑞松科技", + "688091": "上海谊众", + "688092": "爱科科技", + "688093": "世华科技", + "688095": "福昕软件", + "688096": "京源环保", + "688097": "博众精工", + "688098": "申联生物", + "688099": "晶晨股份", + "688100": "威胜信息", + "688101": "三达膜", + "688102": "斯瑞新材", + "688103": "国力电子", + "688105": "诺唯赞", + "688106": "金宏气体", + "688107": "安路科技", + "688108": "赛诺医疗", + "688109": "品茗科技", + "688110": "东芯股份", + "688111": "金山办公", + "688112": "鼎阳科技", + "688113": "联测科技", + "688114": "华大智造", + "688115": "思林杰", + "688116": "天奈科技", + "688117": "圣诺生物", + "688118": "普元信息", + "688119": "中钢洛耐", + "688120": "华海清科", + "688121": "卓然股份", + "688122": "西部超导", + "688123": "聚辰股份", + "688125": "安达智能", + "688126": "沪硅产业", + "688127": "蓝特光学", + "688128": "中国电研", + "688129": "东来技术", + "688130": "晶华微", + "688131": "皓元医药", + "688132": "邦彦技术", + "688133": "泰坦科技", + "688135": "利扬芯片", + "688136": "科兴制药", + "688137": "近岸蛋白", + "688138": "清溢光电", + "688139": "海尔生物", + "688141": "杰华特", + "688143": "长盈通", + "688146": "中船特气", + "688147": "微导纳米", + "688148": "芳源股份", + "688150": "莱特光电", + "688151": "华强科技", + "688152": "麒麟信安", + "688153": "唯捷创芯", + "688155": "先惠技术", + "688156": "路德科技", + "688157": "松井股份", + "688158": "优刻得", + "688159": "有方科技", + "688160": "步科股份", + "688161": "威高骨科", + "688162": "巨一科技", + "688163": "赛伦生物", + "688165": "埃夫特", + "688166": "博瑞医药", + "688167": "炬光科技", + "688168": "安博通", + "688169": "石头科技", + "688170": "德龙激光", + "688171": "纬德信息", + "688172": "燕东微", + "688173": "希荻微", + "688175": "高凌信息", + "688176": "亚虹医药", + "688177": "百奥泰", + "688178": "万德斯", + "688179": "阿拉丁", + "688180": "君实生物", + "688181": "八亿时空", + "688182": "灿勤科技", + "688183": "生益电子", + "688184": "ST帕瓦", + "688185": "康希诺", + "688186": "广大特材", + "688187": "时代电气", + "688188": "柏楚电子", + "688189": "南新制药", + "688190": "云路股份", + "688191": "智洋创新", + "688192": "迪哲医药", + "688193": "仁度生物", + "688195": "腾景科技", + "688196": "卓越新能", + "688197": "首药控股", + "688198": "佰仁医疗", + "688199": "久日新材", + "688200": "华峰测控", + "688201": "信安世纪", + "688202": "美迪西", + "688203": "海正生材", + "688205": "德科立", + "688206": "概伦电子", + "688207": "格灵深瞳", + "688208": "道通科技", + "688209": "英集芯", + "688210": "统联精密", + "688211": "中科微至", + "688212": "澳华内镜", + "688213": "思特威", + "688215": "瑞晟智能", + "688216": "气派科技", + "688217": "睿昂基因", + "688218": "江苏北人", + "688219": "会通股份", + "688220": "翱捷科技", + "688221": "前沿生物", + "688222": "成都先导", + "688223": "晶科能源", + "688225": "亚信安全", + "688226": "威腾电气", + "688227": "品高股份", + "688228": "开普云", + "688229": "博睿数据", + "688230": "芯导科技", + "688231": "隆达股份", + "688232": "新点软件", + "688233": "神工股份", + "688234": "天岳先进", + "688235": "百济神州", + "688236": "春立医疗", + "688237": "超卓航科", + "688238": "和元生物", + "688239": "航宇科技", + "688244": "永信至诚", + "688246": "嘉和美康", + "688247": "宣泰医药", + "688248": "南网科技", + "688249": "晶合集成", + "688251": "井松智能", + "688252": "天德钰", + "688253": "英诺特", + "688255": "凯尔达", + "688256": "寒武纪", + "688257": "新锐股份", + "688258": "卓易信息", + "688259": "创耀科技", + "688260": "昀冢科技", + "688261": "东微半导", + "688262": "国芯科技", + "688265": "南模生物", + "688266": "泽璟制药", + "688267": "中触媒", + "688268": "华特气体", + "688269": "凯立新材", + "688270": "臻镭科技", + "688271": "联影医疗", + "688272": "富吉瑞", + "688273": "麦澜德", + "688275": "万润新能", + "688276": "百克生物", + "688277": "天智航", + "688278": "特宝生物", + "688279": "峰岹科技", + "688280": "精进电动", + "688281": "华秦科技", + "688282": "理工导航", + "688283": "坤恒顺维", + "688285": "高铁电气", + "688286": "敏芯股份", + "688287": "*ST观典", + "688288": "鸿泉技术", + "688289": "圣湘生物", + "688290": "景业智能", + "688291": "金橙子", + "688292": "浩瀚深度", + "688293": "奥浦迈", + "688295": "中复神鹰", + "688296": "和达科技", + "688297": "中无人机", + "688298": "东方生物", + "688299": "长阳科技", + "688300": "联瑞新材", + "688301": "奕瑞科技", + "688302": "海创药业", + "688303": "大全能源", + "688305": "科德数控", + "688306": "均普智能", + "688307": "中润光学", + "688308": "欧科亿", + "688309": "恒誉环保", + "688310": "迈得医疗", + "688311": "盟升电子", + "688312": "燕麦科技", + "688313": "仕佳光子", + "688314": "康拓医疗", + "688315": "诺禾致源", + "688316": "青云科技", + "688317": "之江生物", + "688318": "财富趋势", + "688319": "欧林生物", + "688320": "禾川科技", + "688321": "微芯生物", + "688322": "奥比中光", + "688323": "瑞华泰", + "688325": "赛微微电", + "688326": "经纬恒润", + "688327": "云从科技", + "688328": "深科达", + "688329": "艾隆科技", + "688330": "宏力达", + "688331": "荣昌生物", + "688332": "中科蓝讯", + "688333": "铂力特", + "688334": "西高院", + "688335": "复洁科技", + "688336": "三生国健", + "688337": "普源精电", + "688338": "赛科希德", + "688339": "亿华通", + "688343": "云天励飞", + "688345": "博力威", + "688347": "华虹公司", + "688348": "昱能科技", + "688349": "三一重能", + "688350": "富淼科技", + "688351": "微电生理", + "688352": "颀中科技", + "688353": "华盛锂电", + "688355": "明志科技", + "688356": "键凯科技", + "688357": "建龙微纳", + "688358": "祥生医疗", + "688359": "三孚新科", + "688360": "德马科技", + "688361": "中科飞测", + "688362": "甬矽电子", + "688363": "华熙生物", + "688365": "光云科技", + "688366": "昊海生科", + "688367": "工大高科", + "688368": "晶丰明源", + "688369": "致远互联", + "688370": "丛麟科技", + "688371": "菲沃泰", + "688372": "伟测科技", + "688373": "盟科药业", + "688375": "国博电子", + "688376": "美埃科技", + "688377": "迪威尔", + "688378": "奥来德", + "688379": "华光新材", + "688380": "中微半导", + "688381": "帝奥微", + "688382": "益方生物", + "688383": "新益昌", + "688385": "复旦微电", + "688386": "泛亚微透", + "688387": "信科移动", + "688388": "嘉元科技", + "688389": "普门科技", + "688390": "固德威", + "688391": "钜泉科技", + "688392": "骄成超声", + "688393": "安必平", + "688395": "正弦电气", + "688396": "华润微", + "688398": "赛特新材", + "688399": "硕世生物", + "688400": "凌云光", + "688401": "路维光电", + "688403": "汇成股份", + "688408": "中信博", + "688409": "富创精密", + "688410": "山外山", + "688411": "海博思创", + "688416": "恒烁股份", + "688418": "震有科技", + "688419": "耐科装备", + "688420": "美腾科技", + "688425": "铁建重工", + "688426": "康为世纪", + "688428": "诺诚健华", + "688429": "时创能源", + "688432": "有研硅", + "688433": "华曙高科", + "688435": "英方软件", + "688439": "振华风光", + "688443": "智翔金泰", + "688448": "磁谷科技", + "688449": "联芸科技", + "688450": "光格科技", + "688455": "科捷智能", + "688456": "有研粉材", + "688458": "美芯晟", + "688459": "哈铁科技", + "688466": "金科环境", + "688468": "科美诊断", + "688469": "芯联集成", + "688472": "阿特斯", + "688475": "萤石网络", + "688478": "晶升股份", + "688479": "友车科技", + "688480": "赛恩斯", + "688484": "南芯科技", + "688485": "九州一轨", + "688486": "龙迅股份", + "688488": "艾迪药业", + "688489": "三未信安", + "688496": "清越科技", + "688498": "源杰科技", + "688499": "利元亨", + "688500": "慧辰股份", + "688501": "青达环保", + "688502": "茂莱光学", + "688503": "聚和材料", + "688505": "复旦张江", + "688506": "百利天恒", + "688507": "索辰科技", + "688508": "芯朋微", + "688509": "正元地信", + "688510": "航亚科技", + "688511": "*ST天微", + "688512": "慧智微", + "688513": "苑东生物", + "688515": "裕太微", + "688516": "奥特维", + "688517": "金冠电气", + "688518": "联赢激光", + "688519": "南亚新材", + "688520": "神州细胞", + "688521": "芯原股份", + "688522": "纳睿雷达", + "688523": "航天环宇", + "688525": "佰维存储", + "688526": "科前生物", + "688528": "秦川物联", + "688529": "豪森智能", + "688530": "欧莱新材", + "688531": "日联科技", + "688533": "上声电子", + "688535": "华海诚科", + "688536": "思瑞浦", + "688538": "和辉光电", + "688539": "高华科技", + "688543": "国科军工", + "688545": "兴福电子", + "688548": "广钢气体", + "688549": "中巨芯", + "688550": "瑞联新材", + "688551": "科威尔", + "688552": "航天南湖", + "688553": "汇宇制药", + "688556": "高测股份", + "688557": "兰剑智能", + "688558": "国盛智科", + "688559": "海目星", + "688560": "明冠新材", + "688561": "奇安信", + "688562": "航天软件", + "688563": "航材股份", + "688565": "力源科技", + "688566": "吉贝尔", + "688567": "孚能科技", + "688568": "中科星图", + "688569": "铁科轨道", + "688570": "天玛智控", + "688571": "杭华股份", + "688573": "信宇人", + "688575": "亚辉龙", + "688576": "西山科技", + "688577": "浙海德曼", + "688578": "艾力斯", + "688579": "山大地纬", + "688580": "伟思医疗", + "688581": "安杰思", + "688582": "芯动联科", + "688583": "思看科技", + "688584": "上海合晶", + "688585": "上纬新材", + "688586": "江航装备", + "688588": "凌志软件", + "688589": "力合微", + "688590": "新致软件", + "688591": "泰凌微", + "688592": "司南导航", + "688593": "新相微", + "688595": "芯海科技", + "688596": "正帆科技", + "688597": "煜邦电力", + "688598": "金博股份", + "688599": "天合光能", + "688600": "皖仪科技", + "688601": "力芯微", + "688602": "康鹏科技", + "688603": "天承科技", + "688605": "先锋精科", + "688606": "奥泰生物", + "688607": "康众医疗", + "688608": "恒玄科技", + "688609": "九联科技", + "688610": "埃科光电", + "688611": "杭州柯林", + "688612": "威迈斯", + "688613": "奥精医疗", + "688615": "合合信息", + "688616": "西力科技", + "688617": "惠泰医疗", + "688618": "三旺通信", + "688619": "罗普特", + "688620": "安凯微", + "688621": "阳光诺和", + "688622": "禾信仪器", + "688623": "双元科技", + "688625": "呈和科技", + "688626": "翔宇医疗", + "688627": "精智达", + "688628": "优利德", + "688629": "华丰科技", + "688630": "芯碁微装", + "688631": "莱斯信息", + "688633": "星球石墨", + "688636": "智明达", + "688638": "誉辰智能", + "688639": "华恒生物", + "688646": "ST逸飞", + "688648": "中邮科技", + "688651": "盛邦安全", + "688652": "京仪装备", + "688653": "康希通信", + "688655": "迅捷兴", + "688656": "浩欧博", + "688657": "浩辰软件", + "688658": "悦康药业", + "688659": "元琛科技", + "688660": "电气风电", + "688661": "和林微纳", + "688662": "富信科技", + "688663": "新风光", + "688665": "四方光电", + "688667": "菱电电控", + "688668": "鼎通科技", + "688669": "聚石化学", + "688670": "金迪克", + "688671": "碧兴物联", + "688676": "金盘科技", + "688677": "海泰新光", + "688678": "福立旺", + "688679": "通源环境", + "688680": "海优新材", + "688681": "科汇股份", + "688682": "霍莱沃", + "688683": "莱尔科技", + "688685": "迈信林", + "688686": "奥普特", + "688687": "凯因科技", + "688689": "银河微电", + "688690": "纳微科技", + "688691": "灿芯股份", + "688692": "达梦数据", + "688693": "锴威特", + "688695": "中创股份", + "688696": "极米科技", + "688697": "纽威数控", + "688698": "伟创电气", + "688699": "明微电子", + "688700": "东威科技", + "688701": "卓锦股份", + "688702": "盛科通信", + "688707": "振华新材", + "688708": "佳驰科技", + "688709": "成都华微", + "688710": "益诺思", + "688711": "宏微科技", + "688716": "中研股份", + "688717": "艾罗能源", + "688718": "唯赛勃", + "688719": "爱科赛博", + "688720": "艾森股份", + "688721": "龙图光罩", + "688722": "同益中", + "688726": "拉普拉斯", + "688727": "恒坤新材", + "688728": "格科微", + "688729": "屹唐股份", + "688733": "壹石通", + "688737": "中自科技", + "688739": "成大生物", + "688750": "金天钛业", + "688755": "汉邦科技", + "688757": "胜科纳米", + "688758": "赛分科技", + "688759": "必贝特", + "688765": "禾元生物", + "688766": "普冉股份", + "688767": "博拓生物", + "688768": "容知日新", + "688772": "珠海冠宇", + "688775": "影石创新", + "688776": "国光电气", + "688777": "中控技术", + "688778": "厦钨新能", + "688779": "五矿新能", + "688783": "西安奕材", + "688786": "悦安新材", + "688787": "海天瑞声", + "688788": "科思科技", + "688789": "宏华数科", + "688790": "昂瑞微", + "688793": "倍轻松", + "688795": "摩尔线程", + "688796": "百奥赛图", + "688798": "艾为电子", + "688799": "华纳药厂", + "688800": "瑞可达", + "688802": "沐曦股份", + "688805": "健信超导", + "688807": "优迅股份", + "688809": "强一股份", + "688819": "天能股份", + "688981": "中芯国际", + "689009": "九号公司", + "920000": "安徽凤凰", + "920001": "纬达光电", + "920002": "万达轴承", + "920003": "中诚咨询", + "920005": "鼎佳精密", + "920006": "晟楠科技", + "920007": "酉立智能", + "920008": "成电光信", + "920009": "丹娜生物", + "920010": "凯添燃气", + "920014": "特瑞斯", + "920015": "锦华新材", + "920016": "中草香料", + "920017": "星昊医药", + "920018": "宏远股份", + "920019": "铜冠矿建", + "920020": "泰凯英", + "920021": "流金科技", + "920022": "世昌股份", + "920023": "田野股份", + "920026": "卓兆点胶", + "920027": "交大铁发", + "920029": "开发科技", + "920030": "德众汽车", + "920033": "康普化学", + "920035": "精创电气", + "920037": "广信科技", + "920039": "国义招标", + "920045": "蘅东光", + "920046": "亿能电力", + "920047": "诺思兰德", + "920050": "爱舍伦", + "920056": "能之光", + "920057": "百甲科技", + "920058": "华洋赛车", + "920060": "万源通", + "920061": "西磁科技", + "920062": "科润智控", + "920066": "科拜尔", + "920068": "天工股份", + "920075": "柏星龙", + "920076": "N国亮", + "920077": "吉林碳谷", + "920080": "奥美森", + "920082": "方正阀门", + "920086": "科马材料", + "920087": "秋乐种业", + "920088": "科力股份", + "920089": "禾昌聚合", + "920090": "同辉信息", + "920091": "大鹏工业", + "920092": "汉鑫科技", + "920098": "科隆新材", + "920099": "瑞华技术", + "920100": "三协电机", + "920101": "志高机械", + "920106": "林泰新材", + "920108": "宏海科技", + "920110": "雷特科技", + "920111": "聚星科技", + "920112": "巴兰仕", + "920116": "星图测控", + "920118": "太湖远大", + "920121": "江天科技", + "920122": "中纺标", + "920123": "芭薇股份", + "920124": "南特科技", + "920128": "胜业电气", + "920130": "立方控股", + "920132": "泰鹏智能", + "920139": "华岭股份", + "920145": "恒合股份", + "920146": "华阳变速", + "920149": "旭杰科技", + "920152": "昆工科技", + "920158": "长江能科", + "920160": "北矿检测", + "920163": "方大新材", + "920167": "同享科技", + "920169": "七丰精工", + "920171": "志晟信息", + "920174": "五新隧装", + "920175": "东方碳素", + "920179": "凯德石英", + "920184": "国源科技", + "920185": "贝特瑞", + "920190": "雷神科技", + "920195": "三祥科技", + "920198": "微创光电", + "920199": "倍益康", + "920204": "沪江材料", + "920207": "众诚科技", + "920208": "青矩技术", + "920212": "智新电子", + "920221": "易实精密", + "920223": "荣亿精密", + "920225": "利通科技", + "920227": "美登科技", + "920230": "欧康医药", + "920237": "力佳科技", + "920239": "长虹能源", + "920242": "建邦科技", + "920245": "威博液压", + "920247": "华密新材", + "920249": "利尔达", + "920252": "天宏锂电", + "920260": "中寰股份", + "920261": "一诺威", + "920262": "太湖雪", + "920263": "中航泰达", + "920266": "生物谷", + "920267": "鑫汇科", + "920270": "天铭科技", + "920271": "邦德股份", + "920273": "一致魔芋", + "920274": "宏裕包材", + "920275": "驱动力", + "920278": "鹿得医疗", + "920284": "灵鸽科技", + "920299": "灿能电力", + "920300": "辰光医疗", + "920304": "迪尔化工", + "920305": "*ST云创", + "920339": "恒太照明", + "920344": "三元基因", + "920346": "威贸电子", + "920351": "华光源海", + "920357": "雅葆轩", + "920363": "莱赛激光", + "920367": "新赣江", + "920368": "连城数控", + "920370": "新安洁", + "920371": "欧福蛋业", + "920374": "云里物里", + "920375": "派诺科技", + "920378": "泰德股份", + "920392": "佳合科技", + "920394": "民士达", + "920395": "朗鸿科技", + "920396": "常辅股份", + "920402": "硅烷科技", + "920403": "康农种业", + "920405": "海希通讯", + "920407": "驰诚股份", + "920414": "欧普泰", + "920415": "恒拓开源", + "920418": "苏轴股份", + "920419": "路斯股份", + "920422": "润普食品", + "920425": "乐创技术", + "920427": "华维设计", + "920429": "康比特", + "920433": "大唐药业", + "920436": "大地电气", + "920438": "戈碧迦", + "920445": "龙竹科技", + "920454": "同心传动", + "920455": "汇隆活塞", + "920469": "富恒新材", + "920471": "美邦科技", + "920475": "三友科技", + "920476": "海能技术", + "920478": "峆一药业", + "920489": "佳先股份", + "920491": "奥迪威", + "920493": "并行科技", + "920496": "许昌智能", + "920504": "博迅生物", + "920505": "九菱科技", + "920508": "殷图网联", + "920509": "同惠电子", + "920510": "丰光精密", + "920519": "万德股份", + "920522": "纳科诺尔", + "920523": "德瑞锂电", + "920526": "凯华材料", + "920527": "夜光明", + "920533": "骏创科技", + "920541": "铁大科技", + "920547": "无锡晶海", + "920553": "凯腾精工", + "920556": "雅达股份", + "920564": "天润科技", + "920566": "梓橦宫", + "920570": "坤博精工", + "920571": "国航远洋", + "920575": "康乐卫士", + "920576": "天力复合", + "920578": "巨能股份", + "920579": "机科股份", + "920580": "科创新材", + "920592": "华信永道", + "920593": "鼎智科技", + "920599": "同力股份", + "920608": "丰安股份", + "920627": "力王股份", + "920634": "新威凌", + "920639": "晨光电缆", + "920640": "富士达", + "920641": "格利尔", + "920642": "通易航天", + "920651": "天罡股份", + "920656": "海昇药业", + "920662": "方盛股份", + "920663": "明阳科技", + "920665": "科强股份", + "920670": "数字人", + "920675": "秉扬科技", + "920679": "前进科技", + "920682": "球冠电缆", + "920685": "新芝生物", + "920689": "克莱特", + "920690": "捷众科技", + "920693": "阿为特", + "920694": "中裕科技", + "920699": "海达尔", + "920701": "豪声电子", + "920703": "广厦环能", + "920706": "铁拓机械", + "920717": "瑞星股份", + "920718": "合肥高科", + "920719": "宁新新材", + "920720": "吉冈精密", + "920725": "惠丰钻石", + "920726": "朱老六", + "920729": "永顺生物", + "920735": "德源药业", + "920748": "路桥信息", + "920751": "惠同新材", + "920753": "天纺标", + "920765": "美之高", + "920768": "拾比佰", + "920770": "艾能聚", + "920779": "武汉蓝电", + "920781": "瑞奇智造", + "920786": "骑士乳业", + "920790": "联迪信息", + "920792": "东和新材", + "920799": "艾融软件", + "920802": "保丽洁", + "920806": "云星宇", + "920807": "奔朗新材", + "920808": "曙光数创", + "920809": "安达科技", + "920810": "春光智能", + "920819": "颖泰生物", + "920821": "则成电子", + "920826": "盖世食品", + "920832": "齐鲁华信", + "920833": "美心翼申", + "920834": "三维装备", + "920837": "华原股份", + "920839": "万通液压", + "920855": "浙江大农", + "920856": "浩淼科技", + "920857": "泓禧科技", + "920866": "绿亨科技", + "920870": "恒进感应", + "920871": "派特尔", + "920873": "中设咨询", + "920876": "慧为智能", + "920879": "基康技术", + "920885": "星辰科技", + "920892": "广咨国际", + "920895": "花溪科技", + "920896": "旺成科技", + "920906": "舜宇精工", + "920914": "远航精密", + "920924": "广脉科技", + "920925": "锦好医疗", + "920926": "鸿智科技", + "920931": "无锡鼎邦", + "920932": "科达自控", + "920942": "恒立钻具", + "920943": "优机股份", + "920946": "森萱医药", + "920950": "迅安科技", + "920953": "国子软件", + "920957": "汉维科技", + "920961": "创远信科", + "920964": "润农节水", + "920970": "大禹生物", + "920971": "天马新材", + "920974": "凯大催化", + "920976": "视声智能", + "920978": "开特股份", + "920981": "晶赛科技", + "920982": "锦波生物", + "920985": "海泰新能", + "920992": "中科美菱" +} \ No newline at end of file diff --git a/stock-html/sync_fund_flow.py b/stock-html/sync_fund_flow.py new file mode 100755 index 0000000..65e8195 --- /dev/null +++ b/stock-html/sync_fund_flow.py @@ -0,0 +1,382 @@ +#!/usr/bin/env python3 +""" +资金流向数据每日采集脚本 + +功能:从5分钟K线数据自行计算资金流向,存入 stock_fund_flow_history 和 stock_fund_flow_today 表。 +数据源:stock_kline_5min 表(自有数据,无需外部API) + +算法: + - 根据5分钟K线的 close vs open 判断买卖方向 + - 根据成交额(amount)分类:超大单(≥100万), 大单(20~100万), 中单(4~20万), 小单(<4万) + - 主力 = 超大单 + 大单 + +用法: + # 计算今日资金流向 + ./venv/bin/python sync_fund_flow.py + + # 补算历史(有5分钟K线但尚无资金流向的日期,最多30天) + ./venv/bin/python sync_fund_flow.py --backfill + +建议定时任务: + 10 15 * * 1-5 /opt/stock-app/venv/bin/python /opt/stock-app/sync_fund_flow.py >> /opt/stock-app/sync_fund_flow.log 2>&1 + 30 15 * * 1-5 /opt/stock-app/venv/bin/python /opt/stock-app/sync_fund_flow.py --backfill >> /opt/stock-app/sync_fund_flow.log 2>&1 +""" +import sys +import os +import time +import argparse +import fcntl +import atexit +from datetime import datetime, date +from collections import defaultdict + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import psycopg2 +from psycopg2.extras import execute_values +from config import Config + +LOCK_FILE = '/tmp/sync_fund_flow.lock' +_lock_fd = None + + +def acquire_lock(): + """获取进程锁,防止多实例同时运行""" + global _lock_fd + _lock_fd = open(LOCK_FILE, 'w') + try: + fcntl.flock(_lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + _lock_fd.write(str(os.getpid())) + _lock_fd.flush() + atexit.register(release_lock) + return True + except IOError: + try: + with open(LOCK_FILE, 'r') as f: + old_pid = f.read().strip() + print(f"⚠️ 另一个实例正在运行 (PID: {old_pid}),退出", flush=True) + except Exception: + print(f"⚠️ 另一个实例正在运行,退出", flush=True) + _lock_fd.close() + _lock_fd = None + return False + + +def release_lock(): + """释放进程锁""" + global _lock_fd + if _lock_fd: + try: + fcntl.flock(_lock_fd, fcntl.LOCK_UN) + _lock_fd.close() + except Exception: + pass + _lock_fd = None + try: + os.remove(LOCK_FILE) + except Exception: + pass + + +def get_db_conn(): + return psycopg2.connect( + host=Config.DB_HOST, port=Config.DB_PORT, + dbname=Config.DB_NAME, user=Config.DB_USER, password=Config.DB_PASSWORD, + ) + + +def calc_fund_flow_from_5min(conn, target_date): + """ + 从5分钟K线数据计算某日资金流向 + + 算法: + 1. 每根5分钟K线根据 close vs open 判断方向(买入/卖出) + 2. 根据成交额(amount)分类: + - 超大单: amount >= 100万 + - 大单: 20万 <= amount < 100万 + - 中单: 4万 <= amount < 20万 + - 小单: amount < 4万 + 3. 主力 = 超大单 + 大单 + """ + cur = conn.cursor() + cur.execute(""" + SELECT code, open, close, volume, amount + FROM stock_kline_5min + WHERE dt::date = %s AND amount > 0 + ORDER BY code, dt + """, (target_date,)) + rows = cur.fetchall() + + if not rows: + return {} + + stock_flows = defaultdict(lambda: { + 'super_buy': 0, 'super_sell': 0, + 'big_buy': 0, 'big_sell': 0, + 'mid_buy': 0, 'mid_sell': 0, + 'small_buy': 0, 'small_sell': 0, + 'total_amount': 0 + }) + + for code, open_p, close_p, volume, amount in rows: + if not amount or float(amount) <= 0: + continue + + amt = float(amount) + sf = stock_flows[code] + sf['total_amount'] += amt + + is_buy = float(close_p) >= float(open_p) if close_p and open_p else True + + if amt >= 1000000: # 超大单 >= 100万 + cat = 'super' + elif amt >= 200000: # 大单 >= 20万 + cat = 'big' + elif amt >= 40000: # 中单 >= 4万 + cat = 'mid' + else: # 小单 + cat = 'small' + + if is_buy: + sf[f'{cat}_buy'] += amt + else: + sf[f'{cat}_sell'] += amt + + results = {} + for code, sf in stock_flows.items(): + total = sf['total_amount'] + if total <= 0: + continue + + super_net = sf['super_buy'] - sf['super_sell'] + big_net = sf['big_buy'] - sf['big_sell'] + mid_net = sf['mid_buy'] - sf['mid_sell'] + small_net = sf['small_buy'] - sf['small_sell'] + main_net = super_net + big_net + + results[code] = { + 'main_net_inflow': round(main_net, 2), + 'main_net_inflow_pct': round(main_net / total * 100, 4) if total > 0 else 0, + 'super_net_inflow': round(super_net, 2), + 'super_net_inflow_pct': round(super_net / total * 100, 4) if total > 0 else 0, + 'big_net_inflow': round(big_net, 2), + 'big_net_inflow_pct': round(big_net / total * 100, 4) if total > 0 else 0, + 'mid_net_inflow': round(mid_net, 2), + 'mid_net_inflow_pct': round(mid_net / total * 100, 4) if total > 0 else 0, + 'small_net_inflow': round(small_net, 2), + 'small_net_inflow_pct': round(small_net / total * 100, 4) if total > 0 else 0, + } + + return results + + +def update_today_flow(conn): + """更新今日资金流向到 stock_fund_flow_today""" + today = date.today() + flows = calc_fund_flow_from_5min(conn, today) + + if not flows: + print(f" 今日({today})无5分钟K线数据,跳过", flush=True) + return 0 + + cur = conn.cursor() + codes = list(flows.keys()) + cur.execute(""" + SELECT code, name, price, change_pct + FROM stock_realtime_price WHERE code = ANY(%s) + """, (codes,)) + price_map = {r[0]: {'name': r[1], 'price': float(r[2] or 0), 'change_pct': float(r[3] or 0)} + for r in cur.fetchall()} + + records = [] + for code, f in flows.items(): + info = price_map.get(code, {}) + records.append(( + code, info.get('name', ''), + f['main_net_inflow'], f['main_net_inflow_pct'], + f['super_net_inflow'], f['super_net_inflow_pct'], + f['big_net_inflow'], f['big_net_inflow_pct'], + f['mid_net_inflow'], f['mid_net_inflow_pct'], + f['small_net_inflow'], f['small_net_inflow_pct'], + info.get('price', 0), info.get('change_pct', 0), + )) + + execute_values(cur, """ + INSERT INTO stock_fund_flow_today + (code, name, main_net_inflow, main_net_inflow_pct, + super_net_inflow, super_net_inflow_pct, + big_net_inflow, big_net_inflow_pct, + mid_net_inflow, mid_net_inflow_pct, + small_net_inflow, small_net_inflow_pct, + price, change_pct, updated_at) + VALUES %s + ON CONFLICT (code) DO UPDATE SET + name = EXCLUDED.name, + main_net_inflow = EXCLUDED.main_net_inflow, + main_net_inflow_pct = EXCLUDED.main_net_inflow_pct, + super_net_inflow = EXCLUDED.super_net_inflow, + super_net_inflow_pct = EXCLUDED.super_net_inflow_pct, + big_net_inflow = EXCLUDED.big_net_inflow, + big_net_inflow_pct = EXCLUDED.big_net_inflow_pct, + mid_net_inflow = EXCLUDED.mid_net_inflow, + mid_net_inflow_pct = EXCLUDED.mid_net_inflow_pct, + small_net_inflow = EXCLUDED.small_net_inflow, + small_net_inflow_pct = EXCLUDED.small_net_inflow_pct, + price = EXCLUDED.price, + change_pct = EXCLUDED.change_pct, + updated_at = NOW() + """, records, + template="(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())") + + conn.commit() + print(f" ✅ 今日资金流向: {len(records)} 只股票", flush=True) + return len(records) + + +def backfill_history(conn, max_days=30): + """补算历史资金流向: 有5分钟K线但尚无 fund_flow_history 的日期""" + cur = conn.cursor() + cur.execute(""" + SELECT DISTINCT dt::date as d + FROM stock_kline_5min + WHERE dt::date NOT IN ( + SELECT DISTINCT trade_date FROM stock_fund_flow_history + ) + AND dt::date < CURRENT_DATE + ORDER BY d DESC + LIMIT %s + """, (max_days,)) + missing_dates = [row[0] for row in cur.fetchall()] + + if not missing_dates: + print(" ✅ 历史资金流向已完整,无需补算", flush=True) + return 0 + + print(f" 需补算 {len(missing_dates)} 天的历史资金流向", flush=True) + total_records = 0 + + for d in missing_dates: + flows = calc_fund_flow_from_5min(conn, d) + if not flows: + continue + + # 获取当天收盘价 + cur.execute(""" + SELECT code, close, change_pct + FROM stock_kline_daily + WHERE trade_date = %s AND code = ANY(%s) + """, (d, list(flows.keys()))) + price_map = {r[0]: {'close': float(r[1] or 0), 'change_pct': float(r[2] or 0)} + for r in cur.fetchall()} + + records = [] + for code, f in flows.items(): + info = price_map.get(code, {}) + records.append(( + code, d, + info.get('close', 0), info.get('change_pct', 0), + f['main_net_inflow'], f['main_net_inflow_pct'], + f['super_net_inflow'], f['super_net_inflow_pct'], + f['big_net_inflow'], f['big_net_inflow_pct'], + f['mid_net_inflow'], f['mid_net_inflow_pct'], + f['small_net_inflow'], f['small_net_inflow_pct'], + )) + + if records: + execute_values(cur, """ + INSERT INTO stock_fund_flow_history + (code, trade_date, close_price, change_pct, + main_net_inflow, main_net_inflow_pct, + super_net_inflow, super_net_inflow_pct, + big_net_inflow, big_net_inflow_pct, + mid_net_inflow, mid_net_inflow_pct, + small_net_inflow, small_net_inflow_pct, + updated_at) + VALUES %s + ON CONFLICT (code, trade_date) DO UPDATE SET + close_price = EXCLUDED.close_price, + change_pct = EXCLUDED.change_pct, + main_net_inflow = EXCLUDED.main_net_inflow, + main_net_inflow_pct = EXCLUDED.main_net_inflow_pct, + super_net_inflow = EXCLUDED.super_net_inflow, + super_net_inflow_pct = EXCLUDED.super_net_inflow_pct, + big_net_inflow = EXCLUDED.big_net_inflow, + big_net_inflow_pct = EXCLUDED.big_net_inflow_pct, + mid_net_inflow = EXCLUDED.mid_net_inflow, + mid_net_inflow_pct = EXCLUDED.mid_net_inflow_pct, + small_net_inflow = EXCLUDED.small_net_inflow, + small_net_inflow_pct = EXCLUDED.small_net_inflow_pct, + updated_at = NOW() + """, records, + template="(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())") + conn.commit() + total_records += len(records) + print(f" {d}: {len(records)} 只股票", flush=True) + + print(f" ✅ 历史补算完成: {total_records} 条记录, {len(missing_dates)} 天", flush=True) + return total_records + + +def main(): + if not acquire_lock(): + sys.exit(1) + + parser = argparse.ArgumentParser(description='资金流向计算(从5分钟K线数据)') + parser.add_argument('--backfill', action='store_true', + help='补算历史资金流向(有5分钟K线但尚无资金流向的日期)') + parser.add_argument('--max-days', type=int, default=30, + help='历史补算最大天数(默认30)') + args = parser.parse_args() + + today = date.today() + print(f"{'='*60}", flush=True) + print(f"💰 资金流向计算(来源: 5分钟K线数据)", flush=True) + print(f"📅 日期: {today}", flush=True) + print(f"{'='*60}", flush=True) + + conn = get_db_conn() + start_time = time.time() + + try: + # 始终计算今日 + print("\n📊 计算今日资金流向...", flush=True) + today_count = update_today_flow(conn) + + # 如果指定了 --backfill,补算历史 + if args.backfill: + print(f"\n📜 补算历史资金流向(最多{args.max_days}天)...", flush=True) + hist_count = backfill_history(conn, args.max_days) + else: + hist_count = 0 + + elapsed = time.time() - start_time + + # 显示数据库统计 + cur = conn.cursor() + cur.execute(""" + SELECT count(*), count(DISTINCT code), + min(trade_date), max(trade_date), + count(DISTINCT trade_date) + FROM stock_fund_flow_history + """) + cnt, codes, min_d, max_d, days = cur.fetchone() + + print(f"\n{'='*60}", flush=True) + print(f"✅ 完成! 耗时: {elapsed:.1f}秒", flush=True) + print(f" 今日: {today_count} 条 | 历史补算: {hist_count} 条", flush=True) + print(f"\n💰 stock_fund_flow_history 统计:", flush=True) + print(f" 总记录: {cnt:,} 条 | {codes:,} 只股票 | {days} 个交易日", flush=True) + if min_d: + print(f" 日期范围: {min_d} ~ {max_d}", flush=True) + print(f"{'='*60}", flush=True) + + except Exception as e: + print(f"❌ 错误: {e}", flush=True) + import traceback + traceback.print_exc() + finally: + conn.close() + + +if __name__ == '__main__': + main() diff --git a/stock-html/sync_kline.py b/stock-html/sync_kline.py new file mode 100644 index 0000000..a57c62b --- /dev/null +++ b/stock-html/sync_kline.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +""" +K线数据本地同步脚本 +支持四种数据源(按优先级):阿里云API → 新浪API → 麦蕊API → AKShare +北交所(8XX/9XX)股票专用新浪API +同步到本地PostgreSQL数据库 + +用法: + python sync_kline.py # 增量同步(默认,只同步最近5天) + python sync_kline.py --full # 全量同步(180天历史数据) + python sync_kline.py --days 30 # 同步最近30天 +""" + +import sys +import os +import time +import argparse +import signal as sig_module +from datetime import datetime, date, timedelta +from concurrent.futures import ThreadPoolExecutor, as_completed + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import psycopg2 +from psycopg2.extras import execute_values + +from config import Config +from services.stock_algorithms import fetch_kline_rows + +# ============ 配置 ============ +WORKERS = 10 # 并发线程数 +BATCH_SAVE_SIZE = 50 # 每批保存到DB的股票数 +FULL_SYNC_DAYS = 180 # 全量同步天数 +INCREMENTAL_DAYS = 5 # 增量同步天数(多取几天防遗漏) + +_shutdown = False + + +def signal_handler(signum, frame): + global _shutdown + print("\n⚠️ 收到中断信号,正在优雅退出...") + _shutdown = True + + +sig_module.signal(sig_module.SIGINT, signal_handler) +sig_module.signal(sig_module.SIGTERM, signal_handler) + + +def get_db_conn(): + return psycopg2.connect( + host=Config.DB_HOST, port=Config.DB_PORT, + dbname=Config.DB_NAME, user=Config.DB_USER, password=Config.DB_PASSWORD, + ) + + +def get_all_stock_codes(conn): + """获取所有股票代码""" + with conn.cursor() as cur: + cur.execute("SELECT code, name FROM stock_realtime_price ORDER BY code") + return cur.fetchall() + + +def fetch_kline_for_stock(code, days): + """获取K线数据 — 委托给 services.stock_algorithms.fetch_kline_rows""" + return fetch_kline_rows(code, days) + + +def save_kline_batch(conn, all_rows): + """批量保存K线数据到数据库(UPSERT)""" + if not all_rows: + return 0 + + with conn.cursor() as cur: + execute_values( + cur, + """ + INSERT INTO stock_kline_daily (code, trade_date, open, high, low, close, volume, amount) + VALUES %s + ON CONFLICT (code, trade_date) DO UPDATE SET + open = EXCLUDED.open, + high = EXCLUDED.high, + low = EXCLUDED.low, + close = EXCLUDED.close, + volume = EXCLUDED.volume, + amount = EXCLUDED.amount, + updated_at = CURRENT_TIMESTAMP + """, + all_rows, + page_size=1000, + ) + conn.commit() + return len(all_rows) + + +def main(): + global _shutdown + + parser = argparse.ArgumentParser(description='K线数据本地同步') + parser.add_argument('--full', action='store_true', help='全量同步(180天历史)') + parser.add_argument('--days', type=int, default=None, help='同步天数') + args = parser.parse_args() + + if args.full: + sync_days = FULL_SYNC_DAYS + mode = '全量同步' + elif args.days: + sync_days = args.days + mode = f'自定义同步({sync_days}天)' + else: + sync_days = INCREMENTAL_DAYS + mode = '增量同步' + + print(f"{'='*60}", flush=True) + print(f"📊 K线数据本地同步", flush=True) + print(f"📅 日期: {date.today()}", flush=True) + print(f"🔄 模式: {mode}({sync_days}天)", flush=True) + print(f"⚙️ 并发数: {WORKERS}", flush=True) + print(f"{'='*60}", flush=True) + + conn = get_db_conn() + all_stocks = get_all_stock_codes(conn) + total = len(all_stocks) + print(f"📈 股票总数: {total}", flush=True) + + start_time = time.time() + done_count = 0 + success_count = 0 + error_count = 0 + total_rows = 0 + save_buffer = [] + last_report_time = time.time() + + print(f"\n🚀 开始同步...", flush=True) + print(f"-" * 60, flush=True) + + with ThreadPoolExecutor(max_workers=WORKERS) as executor: + futures = {} + for code, name in all_stocks: + if _shutdown: + break + futures[executor.submit(fetch_kline_for_stock, code, sync_days)] = (code, name) + + for future in as_completed(futures): + if _shutdown: + print("⏹️ 用户中断,正在保存当前数据...", flush=True) + break + + code, name = futures[future] + done_count += 1 + + try: + rows = future.result() + except Exception: + rows = None + + if rows: + save_buffer.extend(rows) + success_count += 1 + else: + error_count += 1 + + # 攒够一批就保存 + if len(save_buffer) >= BATCH_SAVE_SIZE * 80: # 约50股 × 80条/股 + saved = save_kline_batch(conn, save_buffer) + total_rows += saved + save_buffer = [] + + # 每3秒报告进度 + now = time.time() + if now - last_report_time >= 3: + elapsed = now - start_time + speed = done_count / elapsed if elapsed > 0 else 0 + remaining = (total - done_count) / speed if speed > 0 else 0 + pct = done_count / total * 100 + print(f" [{pct:5.1f}%] {done_count}/{total} " + f"| 速度: {speed:.1f}只/秒 | 剩余: {remaining/60:.1f}分钟 " + f"| 成功: {success_count} | 失败: {error_count} " + f"| 已保存: {total_rows}条", flush=True) + last_report_time = now + + # 保存剩余数据 + if save_buffer: + saved = save_kline_batch(conn, save_buffer) + total_rows += saved + + elapsed = time.time() - start_time + speed = done_count / elapsed if elapsed > 0 else 0 + + print(f"\n{'='*60}", flush=True) + print(f"✅ 同步{'中断' if _shutdown else '完成'}!", flush=True) + print(f" 处理: {done_count} 只 | 成功: {success_count} | 失败: {error_count}", flush=True) + print(f" 保存K线: {total_rows} 条 | 耗时: {elapsed/60:.1f}分钟", flush=True) + print(f" 平均速度: {speed:.1f} 只/秒", flush=True) + + # 显示数据库统计 + with conn.cursor() as cur: + cur.execute("SELECT count(*), count(DISTINCT code), min(trade_date), max(trade_date) FROM stock_kline_daily") + cnt, codes, min_date, max_date = cur.fetchone() + print(f"\n📊 数据库K线统计:", flush=True) + print(f" 总记录: {cnt:,} 条 | 覆盖股票: {codes} 只", flush=True) + print(f" 日期范围: {min_date} ~ {max_date}", flush=True) + print(f"{'='*60}", flush=True) + + conn.close() + + +if __name__ == '__main__': + main() diff --git a/stock-html/sync_kline_5min.py b/stock-html/sync_kline_5min.py new file mode 100755 index 0000000..90a40ae --- /dev/null +++ b/stock-html/sync_kline_5min.py @@ -0,0 +1,715 @@ +#!/usr/bin/env python3 +""" +5分钟K线数据每日采集脚本 + +功能:每日收盘后自动采集全市场A股的5分钟K线数据,存入 stock_kline_5min 表。 +数据源:akshare stock_zh_a_minute(新浪财经,免费,腾讯云可用) + +特点: + - 增量采集:只采集当日新增数据 + - 断点续传:记录已采集的股票,中断后可继续 + - 频率控制:自动限速避免触发API封禁 + - 回填模式:可手动回填最近N天的历史分钟数据 + +用法: + # 每日采集(推荐在 17:00 后运行,收盘后数据完整) + ./venv/bin/python sync_kline_5min.py + + # 回填最近5天 + ./venv/bin/python sync_kline_5min.py --backfill 5 + + # 只采集指定股票 + ./venv/bin/python sync_kline_5min.py --codes 300720,000001 + + # 快速测试(只采集前10只) + ./venv/bin/python sync_kline_5min.py --limit 10 + +建议定时任务: + 30 17 * * 1-5 /opt/stock-app/venv/bin/python /opt/stock-app/sync_kline_5min.py >> /opt/stock-app/sync_kline_5min.log 2>&1 +""" +import sys +import os +import time +import argparse +import signal as sig_module +import fcntl +import atexit +import threading +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime, date, timedelta + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import psycopg2 +from psycopg2.extras import execute_values +from config import Config + +# ============ 配置 ============ +API_DELAY = 0.8 # 每次API调用间隔(秒),东财限流严格需更保守 +BATCH_SAVE_SIZE = 500 # 每批保存行数 +PERIOD = '5' # K线级别:'1','5','15','30','60' +MAX_RETRIES = 2 # 单只股票API重试次数 +RETRY_DELAY = 3 # 重试等待时间(秒) +EM_CIRCUIT_BREAKER = 3 # 东财API连续失败N次后暂停使用(快速熔断) +LOCK_FILE = '/tmp/sync_kline_5min.lock' +DEFAULT_WORKERS = 3 # 默认并发数(东财API限流严格,不宜过高) + +_shutdown = False +_em_consecutive_errors = 0 # 东财API连续错误计数 +_em_disabled = False # 东财API是否被暂停 +_em_lock = threading.Lock() # 东财API状态锁 +_lock_fd = None # 进程锁文件描述符 + + +def _clean_stale_lock(): + """清理残留的锁文件(进程已不存在或权限不对时)""" + if not os.path.exists(LOCK_FILE): + return + try: + with open(LOCK_FILE, 'r') as f: + old_pid = f.read().strip() + if old_pid and old_pid.isdigit(): + try: + os.kill(int(old_pid), 0) # 检查进程是否存在 + return # 进程仍在运行,不清理 + except ProcessLookupError: + pass # 进程已不存在,清理 + except PermissionError: + return # 进程存在但无权限检查,不清理 + except (IOError, PermissionError): + pass # 无法读取锁文件,尝试删除 + try: + os.remove(LOCK_FILE) + print(f"🧹 已清理残留锁文件 (旧PID: {old_pid if 'old_pid' in dir() else '未知'})", flush=True) + except Exception: + pass + + +def acquire_lock(): + """获取进程锁,防止多实例同时运行""" + global _lock_fd + # 先尝试清理残留的锁文件 + _clean_stale_lock() + try: + _lock_fd = open(LOCK_FILE, 'w') + except PermissionError: + # 锁文件权限不对,尝试删除后重建 + try: + os.remove(LOCK_FILE) + _lock_fd = open(LOCK_FILE, 'w') + except Exception as e: + print(f"⚠️ 无法创建锁文件 {LOCK_FILE}: {e}", flush=True) + return False + try: + fcntl.flock(_lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + _lock_fd.write(str(os.getpid())) + _lock_fd.flush() + atexit.register(release_lock) + return True + except IOError: + # 另一个实例正在运行,读取其PID + try: + with open(LOCK_FILE, 'r') as f: + old_pid = f.read().strip() + print(f"⚠️ 另一个实例正在运行 (PID: {old_pid}),退出", flush=True) + except Exception: + print(f"⚠️ 另一个实例正在运行,退出", flush=True) + _lock_fd.close() + _lock_fd = None + return False + + +def release_lock(): + """释放进程锁""" + global _lock_fd + if _lock_fd: + try: + fcntl.flock(_lock_fd, fcntl.LOCK_UN) + _lock_fd.close() + except Exception: + pass + _lock_fd = None + try: + os.remove(LOCK_FILE) + except Exception: + pass + + +def signal_handler(signum, frame): + global _shutdown + print("\n⚠️ 收到中断信号,正在优雅退出...", flush=True) + _shutdown = True + + +sig_module.signal(sig_module.SIGINT, signal_handler) +sig_module.signal(sig_module.SIGTERM, signal_handler) + + +def get_db_conn(): + return psycopg2.connect( + host=Config.DB_HOST, port=Config.DB_PORT, + dbname=Config.DB_NAME, user=Config.DB_USER, password=Config.DB_PASSWORD, + ) + + +def ensure_table(conn): + """确保 stock_kline_5min 表存在""" + with conn.cursor() as cur: + cur.execute(""" + CREATE TABLE IF NOT EXISTS stock_kline_5min ( + code VARCHAR(10) NOT NULL, + dt TIMESTAMP NOT NULL, + open DECIMAL(12, 4), + high DECIMAL(12, 4), + low DECIMAL(12, 4), + close DECIMAL(12, 4), + volume BIGINT, + amount DECIMAL(20, 2), + change_pct DECIMAL(8, 4), + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (code, dt) + ) + """) + cur.execute("CREATE INDEX IF NOT EXISTS idx_kline_5min_dt ON stock_kline_5min(dt)") + conn.commit() + + +def get_stock_codes(conn, only_codes=None): + """获取需要采集的股票列表""" + with conn.cursor() as cur: + if only_codes: + placeholders = ','.join(['%s'] * len(only_codes)) + cur.execute(f"SELECT code, name FROM stock_realtime_price WHERE code IN ({placeholders}) ORDER BY code", + only_codes) + else: + # 获取所有有效股票(价格>0的) + cur.execute(""" + SELECT code, name FROM stock_realtime_price + WHERE price > 0 AND code NOT LIKE 'BJ%%' + ORDER BY code + """) + return cur.fetchall() + + +def get_already_synced_codes(conn, target_date): + """获取今天已经同步过的股票(用于断点续传)""" + with conn.cursor() as cur: + cur.execute(""" + SELECT DISTINCT code FROM stock_kline_5min + WHERE dt::date = %s + """, (target_date,)) + return {r[0] for r in cur.fetchall()} + + +def _code_to_sina_symbol(code): + """股票代码转新浪格式:000001 → sz000001, 600519 → sh600519""" + if code.startswith(('0', '3')): + return f'sz{code}' + elif code.startswith(('6', '5')): + return f'sh{code}' + elif code.startswith(('8', '9', '4')): + return f'bj{code}' + return f'sz{code}' + + +def fetch_5min_kline_sina(code): + """ + 数据源1:新浪API(akshare stock_zh_a_minute) + 优点:稳定、不易被封、回溯约2个月 + 返回: list of tuple (code, dt, open, high, low, close, volume, amount, change_pct) + 返回 None 表示无数据(非错误) + 返回 'error' 字符串表示API错误 + """ + import akshare as ak + + for attempt in range(MAX_RETRIES + 1): + try: + symbol = _code_to_sina_symbol(code) + df = ak.stock_zh_a_minute(symbol=symbol, period=PERIOD) + + if df is None or df.empty: + return None # 无数据,非错误 + + rows = [] + for _, row in df.iterrows(): + dt_str = str(row.get('day', '')) + try: + dt = datetime.strptime(dt_str, '%Y-%m-%d %H:%M:%S') + except ValueError: + continue + + o = float(row.get('open', 0)) + h = float(row.get('high', 0)) + l = float(row.get('low', 0)) + c = float(row.get('close', 0)) + v = int(float(row.get('volume', 0))) + + rows.append((code, dt, o, h, l, c, v, 0.0, 0.0)) + return rows if rows else None + + except (IndexError, KeyError, ValueError): + # list index out of range / KeyError / ValueError + # 这些是"该股票无5分钟数据"的表现,不是API故障 + return None # 无数据,非错误 + + except Exception as e: + err_str = str(e) + if attempt < MAX_RETRIES: + wait = RETRY_DELAY * (attempt + 1) + print(f" ⚠️ {code}(新浪) 第{attempt+1}次失败: {err_str[:60]}, {wait}s后重试", flush=True) + time.sleep(wait) + continue + return 'error' # 真正的API错误 + + return 'error' + + +def fetch_5min_kline_em(code, start_date=None, end_date=None): + """ + 数据源2:东方财富API(akshare stock_zh_a_hist_min_em) + 优点:有成交额和涨跌幅,回溯约2个月 + 缺点:容易被限流 + 返回: list of tuple 或 None(无数据) 或 'error'(API错误) + """ + import akshare as ak + + for attempt in range(MAX_RETRIES + 1): + try: + kwargs = {'symbol': code, 'period': PERIOD, 'adjust': ''} + if start_date: + kwargs['start_date'] = start_date + if end_date: + kwargs['end_date'] = end_date + + df = ak.stock_zh_a_hist_min_em(**kwargs) + if df is None or df.empty: + return None + + rows = [] + for _, row in df.iterrows(): + dt_str = str(row['时间']) + try: + dt = datetime.strptime(dt_str, '%Y-%m-%d %H:%M:%S') + except ValueError: + continue + + rows.append(( + code, dt, + float(row.get('开盘', 0)), + float(row.get('最高', 0)), + float(row.get('最低', 0)), + float(row.get('收盘', 0)), + int(float(row.get('成交量', 0))), + float(row.get('成交额', 0)), + float(row.get('涨跌幅', 0)), + )) + return rows if rows else None + + except (IndexError, KeyError, ValueError): + return None # 无数据,非错误 + + except Exception as e: + err_str = str(e) + # 连接被断开、限流等属于真正的API错误 + if attempt < MAX_RETRIES: + wait = RETRY_DELAY * (attempt + 1) * 2 # 东财限流严重,加长等待 + print(f" ⚠️ {code}(东财) 第{attempt+1}次失败: {err_str[:60]}, {wait}s后重试", flush=True) + time.sleep(wait) + continue + return 'error' + + return 'error' + + +def fetch_5min_kline_tencent(code): + """ + 数据源3:腾讯财经1分钟数据 → 聚合为5分钟K线 + 优点:腾讯云服务器永不被封 + 缺点:只有当天数据 + 返回: list of tuple 或 None 或 'error' + """ + import requests as _requests + import json as _json + + try: + symbol = _code_to_sina_symbol(code) # sh/sz 格式通用 + url = f"https://web.ifzq.gtimg.cn/appstock/app/minute/query?code={symbol}" + r = _requests.get(url, timeout=15, headers={ + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Referer": "https://stockapp.finance.qq.com", + }) + if r.status_code != 200: + return 'error' + + text = r.text + start_idx = text.find("=") + 1 + end_idx = text.rfind("}") + 1 + data = _json.loads(text[start_idx:end_idx]) + records = data.get("data", {}).get(symbol, {}).get("data", {}).get("data", []) + if not records: + return None + + today = date.today() + + # 解析1分钟数据: "0930 1466.99 153 22444946.66" + # 格式: HHMM price volume amount(累积) + min_data = [] + for rec in records: + parts = rec.split() + if len(parts) < 4: + continue + hhmm = parts[0] + price = float(parts[1]) + vol = int(parts[2]) + try: + h, m = int(hhmm[:2]), int(hhmm[2:]) + dt = datetime(today.year, today.month, today.day, h, m, 0) + except (ValueError, IndexError): + continue + min_data.append((dt, price, vol)) + + if not min_data: + return None + + # 聚合为5分钟K线 + # 5分钟窗口: 09:30-09:35, 09:35-09:40, ... + from collections import defaultdict + bars = defaultdict(list) + for dt, price, vol in min_data: + # 5分钟窗口起始时间 + minute = dt.minute + bar_min = (minute // 5) * 5 + bar_dt = dt.replace(minute=bar_min, second=0) + bars[bar_dt].append((price, vol)) + + rows = [] + prev_vol = 0 + for bar_dt in sorted(bars.keys()): + ticks = bars[bar_dt] + o = ticks[0][0] # 第一个价格 + c = ticks[-1][0] # 最后一个价格 + h = max(p for p, _ in ticks) + l = min(p for p, _ in ticks) + # 腾讯的volume是累积值,取窗口最后的 - 窗口最前的之前 + last_vol = ticks[-1][1] + bar_vol = last_vol - prev_vol if prev_vol > 0 else ticks[-1][1] + prev_vol = last_vol + rows.append((code, bar_dt, o, h, l, c, max(0, bar_vol), 0.0, 0.0)) + + return rows if rows else None + + except (IndexError, KeyError, ValueError): + return None + except Exception: + return 'error' + + +def fetch_5min_kline(code, start_date=None, end_date=None): + """ + 主入口:优先东财API → 新浪API → 腾讯聚合(仅当天) + 返回: + - list of tuple: 成功获取数据 + - None: 该股票无5分钟数据(停牌、退市等,非错误) + - 'error': API故障/限流 + """ + global _em_consecutive_errors, _em_disabled + + # 1) 优先使用东财API(数据最全) + with _em_lock: + em_ok = not _em_disabled + + if em_ok: + result = fetch_5min_kline_em(code, start_date, end_date) + if result == 'error': + with _em_lock: + _em_consecutive_errors += 1 + if _em_consecutive_errors >= EM_CIRCUIT_BREAKER: + _em_disabled = True + print(f" ⚠️ 东财API连续失败{EM_CIRCUIT_BREAKER}次,尝试备用源", flush=True) + time.sleep(2) + else: + with _em_lock: + _em_consecutive_errors = 0 + return result + + # 2) 备用:新浪API + result = fetch_5min_kline_sina(code) + if result is not None and result != 'error': + return result + + # 3) 终极备用:腾讯1分钟聚合(仅当天数据,但永不被封) + return fetch_5min_kline_tencent(code) + + +def save_batch(conn, all_rows): + """批量保存5分钟K线数据(UPSERT)""" + if not all_rows: + return 0 + + with conn.cursor() as cur: + execute_values( + cur, + """ + INSERT INTO stock_kline_5min (code, dt, open, high, low, close, volume, amount, change_pct) + VALUES %s + ON CONFLICT (code, dt) DO UPDATE SET + open = EXCLUDED.open, + high = EXCLUDED.high, + low = EXCLUDED.low, + close = EXCLUDED.close, + volume = EXCLUDED.volume, + amount = EXCLUDED.amount, + change_pct = EXCLUDED.change_pct, + updated_at = CURRENT_TIMESTAMP + """, + all_rows, + page_size=1000, + ) + conn.commit() + return len(all_rows) + + +def filter_rows_by_date(rows, target_date): + """只保留目标日期的行""" + if not rows: + return None + filtered = [r for r in rows if r[1].date() == target_date] + return filtered if filtered else None + + +def _probe_apis(): + """启动时探测各API是否可用,提前设置熔断状态""" + global _em_disabled, _em_consecutive_errors + print("🔍 探测数据源可用性...", flush=True) + + # 测试东财API(用最活跃的股票) + em_ok = False + try: + import akshare as ak + df = ak.stock_zh_a_hist_min_em(symbol='600519', period='5', adjust='') + if df is not None and not df.empty: + em_ok = True + print(" ✅ 东财API: 可用", flush=True) + else: + print(" ❌ 东财API: 返回空数据", flush=True) + except Exception as e: + print(f" ❌ 东财API: {str(e)[:60]}", flush=True) + + if not em_ok: + with _em_lock: + _em_disabled = True + _em_consecutive_errors = EM_CIRCUIT_BREAKER + print(" → 东财API已禁用,将使用备用源", flush=True) + + # 测试新浪API + sina_ok = False + try: + result = fetch_5min_kline_sina('600519') + if result is not None and result != 'error': + sina_ok = True + print(" ✅ 新浪API: 可用", flush=True) + else: + print(" ❌ 新浪API: 不可用", flush=True) + except Exception: + print(" ❌ 新浪API: 异常", flush=True) + + # 腾讯API(聚合方式)总是可用 + print(" ✅ 腾讯API: 始终可用(聚合1分钟→5分钟)", flush=True) + + source = "东财" if em_ok else ("新浪" if sina_ok else "腾讯(聚合)") + print(f" 📡 主数据源: {source}", flush=True) + return em_ok, sina_ok + + +def main(): + global _shutdown + + # 进程锁 — 防止多实例同时运行 + if not acquire_lock(): + sys.exit(1) + + parser = argparse.ArgumentParser(description='5分钟K线数据采集') + parser.add_argument('--backfill', type=int, default=0, metavar='DAYS', + help='回填最近N天的数据(默认0=只采集当天)') + parser.add_argument('--codes', type=str, default=None, + help='只采集指定股票(逗号分隔,如 300720,000001)') + parser.add_argument('--limit', type=int, default=0, + help='限制采集股票数量(0=全部,用于测试)') + parser.add_argument('--delay', type=float, default=API_DELAY, + help=f'API调用间隔秒数(默认{API_DELAY})') + parser.add_argument('--resume', action='store_true', + help='跳过今天已采集的股票(断点续传)') + parser.add_argument('--workers', type=int, default=DEFAULT_WORKERS, + help=f'并发线程数(默认{DEFAULT_WORKERS})') + args = parser.parse_args() + + today = date.today() + target_dates = [today] + if args.backfill > 0: + for i in range(1, args.backfill + 1): + d = today - timedelta(days=i) + if d.weekday() < 5: # 跳过周末 + target_dates.append(d) + target_dates.sort() + + only_codes = args.codes.split(',') if args.codes else None + + print(f"{'='*70}", flush=True) + print(f"📊 5分钟K线数据采集(并发模式)", flush=True) + print(f"📅 目标日期: {', '.join(str(d) for d in target_dates)}", flush=True) + print(f"⏱️ API间隔: {args.delay}s | 并发: {args.workers} 线程", flush=True) + if only_codes: + print(f"🎯 指定股票: {only_codes}", flush=True) + print(f"{'='*70}", flush=True) + + # 启动时探测API可用性,提前熔断不可用的源 + em_ok, sina_ok = _probe_apis() + # 如果主源不可用,腾讯聚合模式可以用更多并发(不限流) + if not em_ok and not sina_ok: + if args.workers < 5: + args.workers = 5 + print(f" 📡 腾讯模式:提升并发到 {args.workers} 线程", flush=True) + if args.delay > 0.3: + args.delay = 0.3 + print(f" 📡 腾讯模式:降低延迟到 {args.delay}s", flush=True) + + conn = get_db_conn() + ensure_table(conn) + + all_stocks = get_stock_codes(conn, only_codes) + if args.limit > 0: + all_stocks = all_stocks[:args.limit] + total = len(all_stocks) + print(f"📈 待采集股票: {total} 只", flush=True) + + if args.resume: + synced = get_already_synced_codes(conn, today) + before = len(all_stocks) + all_stocks = [(c, n) for c, n in all_stocks if c not in synced] + print(f"🔄 断点续传: 跳过 {before - len(all_stocks)} 只已同步, 剩余 {len(all_stocks)} 只", flush=True) + total = len(all_stocks) + + start_time = time.time() + done_count = 0 + success_count = 0 + error_count = 0 + skip_count = 0 + total_rows = 0 + save_buffer = [] + last_report_time = time.time() + + # 线程安全锁 + _stats_lock = threading.Lock() + _buffer_lock = threading.Lock() + + def _fetch_one(code_name): + """单只股票采集任务(在工作线程中运行)""" + code, name = code_name + if _shutdown: + return None + # 线程内延迟,分散API请求 + time.sleep(args.delay) + result = fetch_5min_kline(code) + return (code, name, result) + + print(f"\n🚀 开始采集({args.workers}线程并发)...", flush=True) + print(f"-" * 70, flush=True) + + with ThreadPoolExecutor(max_workers=args.workers) as executor: + futures = {executor.submit(_fetch_one, item): item for item in all_stocks} + + for future in as_completed(futures): + if _shutdown: + break + + ret = future.result() + if ret is None: + continue + + code, name, result = ret + + with _stats_lock: + done_count += 1 + + if isinstance(result, list): + rows = result + if args.backfill == 0: + rows = filter_rows_by_date(rows, today) + + if rows: + with _buffer_lock: + save_buffer.extend(rows) + success_count += 1 + else: + skip_count += 1 + elif result == 'error': + error_count += 1 + else: + skip_count += 1 + + # 攒够一批就保存 + with _buffer_lock: + if len(save_buffer) >= BATCH_SAVE_SIZE: + saved = save_batch(conn, save_buffer) + total_rows += saved + save_buffer = [] + + # 进度报告 + now = time.time() + if now - last_report_time >= 5: + elapsed = now - start_time + speed = done_count / elapsed if elapsed > 0 else 0 + remaining = (total - done_count) / speed if speed > 0 else 0 + pct = done_count / total * 100 if total > 0 else 100 + print(f" [{pct:5.1f}%] {done_count}/{total} " + f"| {speed:.1f}只/秒 | 剩余 {remaining/60:.1f}分钟 " + f"| ✅{success_count} ❌{error_count} ⏭️{skip_count} " + f"| 已保存 {total_rows}条", flush=True) + last_report_time = now + + # 保存剩余数据 + if save_buffer: + saved = save_batch(conn, save_buffer) + total_rows += saved + + elapsed = time.time() - start_time + speed = done_count / elapsed if elapsed > 0 else 0 + + print(f"\n{'='*70}", flush=True) + print(f"{'⏹️ 中断' if _shutdown else '✅ 完成'}!", flush=True) + print(f" 处理: {done_count}/{total} 只", flush=True) + print(f" 成功: {success_count} | 失败: {error_count} | 跳过: {skip_count}", flush=True) + print(f" 保存: {total_rows} 条 5分钟K线", flush=True) + print(f" 耗时: {elapsed/60:.1f} 分钟 ({speed:.1f} 只/秒)", flush=True) + + # 显示数据库统计 + with conn.cursor() as cur: + cur.execute(""" + SELECT count(*), count(DISTINCT code), + min(dt::date), max(dt::date), + count(DISTINCT dt::date) + FROM stock_kline_5min + """) + cnt, codes, min_d, max_d, days = cur.fetchone() + print(f"\n📊 stock_kline_5min 数据库统计:", flush=True) + print(f" 总记录: {cnt:,} 条 | 覆盖: {codes} 只股票 | {days} 天", flush=True) + print(f" 日期: {min_d} ~ {max_d}", flush=True) + + # 按日期统计 + cur.execute(""" + SELECT dt::date AS trade_date, count(*), count(DISTINCT code) + FROM stock_kline_5min + GROUP BY dt::date + ORDER BY dt::date DESC + LIMIT 5 + """) + print(f" 最近5天:", flush=True) + for d, cnt, codes in cur.fetchall(): + print(f" {d}: {cnt:>8,} 条 ({codes} 只股票)", flush=True) + + print(f"{'='*70}", flush=True) + conn.close() + + +if __name__ == '__main__': + main() diff --git a/stock-html/templates/admin.html b/stock-html/templates/admin.html new file mode 100644 index 0000000..322312e --- /dev/null +++ b/stock-html/templates/admin.html @@ -0,0 +1,1389 @@ + + + + + + 管理后台 - 股票投资系统 + + + + + +{% raw %} +
    +
    {{ toast.msg }}
    + +
    +
    +

    + + 管理后台 +

    + + + 返回 + +
    + + + + +
    +
    +
    +
    +
    {{ dash.user_count || 0 }}
    注册用户
    +
    +
    +
    +
    {{ dash.stock_count || 0 }}
    股票总数
    +
    +
    +
    +
    {{ dash.signal_stock_count || 0 }}
    有信号股票
    +
    +
    +
    +
    {{ dash.scan_count || 0 }}
    扫描记录
    +
    +
    +
    +
    {{ dash.trade_count || 0 }}
    交易记录
    +
    +
    +
    +
    {{ dash.watchlist_count || 0 }}
    关注记录
    +
    +
    +
    +
    {{ dash.sim_trade_count || 0 }}
    模拟交易
    +
    +
    +
    +
    {{ dash.fundamental_count || 0 }}
    基本面缓存
    +
    +
    +
    +
    + + 服务器信息 +
    +
    + 🌐 外网IP + {{ dash.server_info.external_ip }} +
    +
    + 🏠 内网IP + {{ dash.server_info.internal_ip }} +
    +
    + 🔌 应用端口 + {{ dash.server_info.app_port }} +
    +
    + 🌍 访问域名 + {{ dash.server_info.scheme }}://{{ dash.server_info.domain }} +
    +
    + 🖥️ 主机名 + {{ dash.server_info.hostname }} +
    +
    + 💻 系统 + {{ dash.server_info.os_info }} +
    +
    + 🐍 Python + {{ dash.server_info.python_version }} +
    +
    +
    +
    + + + 最近扫描日期 + + {{ dash.last_scan_date || '-' }} +
    +
    +
    + + +
    +
    +
    + + + 用户列表 ({{ users.length }}) + + + + + + +
    +
    +
    +
    + {{ u.email }} + {{ u.is_admin ? '管理员' : '用户' }} +
    +
    +
    {{ u.watchlist_count }}
    关注
    +
    {{ u.trade_count }}
    交易
    +
    {{ u.sim_trade_count }}
    模拟
    +
    +
    {{ u.win_rate || 0 }}%
    +
    胜率
    +
    +
    +
    ID: {{ u.id }} · 盈亏: {{ (u.total_profit||0) >= 0 ? '+' : '' }}{{ (u.total_profit||0).toFixed(2) }} · 注册: {{ formatDate(u.created_at) }}
    +
    + + + +
    +
    +
    +
    +
    + + +
    +
    +
    + + 数据表统计 +
    +
    +
    +
    {{ t.label }}
    +
    {{ t.table }}
    +
    +
    {{ t.count.toLocaleString() }}
    +
    +
    +
    +
    + + 更新日志 +
    + +
    暂无日志
    +
    +
    + + +
    +
    +
    + + + 定时任务 + + +
    + +
    +
    +
    + + {{ t.name }} +
    + {{ taskStatusText(t.is_running ? 'running' : t.status) }} + + + + +
    +
    + + + {{ t.schedule }} + + 上次: {{ formatTaskTime(t.last_run) }} +
    + + + + + + + +
    {{ t.error_msg }}
    +
    +
    加载中...
    +
    + + +
    +
    + + 扫描历史 ({{ scanHistory.length }}) + +
    + +
    + + +
    +
    + + Crontab 配置 + +
    +
    +
    {{ crontabContent }}
    +
    +
    +
    + + + + + + + + + + + + +
    +
    +{% endraw %} + + + + diff --git a/stock-html/templates/index.html b/stock-html/templates/index.html new file mode 100644 index 0000000..a61d9f7 --- /dev/null +++ b/stock-html/templates/index.html @@ -0,0 +1,1949 @@ + + + + + + 股票投资 + + + + + + + + + + + + + + + + + + + + + + + + + +{% raw %} +
    + + +
    + + {{ toastType === 'success' ? '✓' : toastType === 'error' ? '✕' : toastType === 'warning' ? '⚠' : 'ℹ' }} + + {{ toastMessage }} +
    +
    + + + +
    +
    +
    {{ confirmMessage }}
    +
    + + +
    +
    +
    +
    + + + + + + + + + + + +
    + + + + +
    +

    正在加载...

    +
    + + + + + + + +
    +
    + +{% endraw %} + + + diff --git a/stock-html/update_cache.py b/stock-html/update_cache.py new file mode 100644 index 0000000..84d5d64 --- /dev/null +++ b/stock-html/update_cache.py @@ -0,0 +1,63 @@ +""" +更新股票缓存数据(从上年1月1日起) +""" +import os +import json +import glob +from datetime import datetime, timedelta +from services.stock_service import get_stock_fund_flow, save_cached_data + +def update_all_caches(): + cache_dir = 'stock_data_cache' + if not os.path.exists(cache_dir): + print("缓存目录不存在") + return + + cache_files = glob.glob(f'{cache_dir}/*.json') + print(f"找到 {len(cache_files)} 个缓存文件") + + # 日期范围:从上年1月1日到今天 + end_date = datetime.now().strftime('%Y-%m-%d') + start_date = f'{datetime.now().year - 1}-01-01' + print(f"更新日期范围: {start_date} ~ {end_date}") + + updated = 0 + failed = 0 + + for i, cache_file in enumerate(cache_files): + stock_code = os.path.basename(cache_file).replace('.json', '') + print(f"[{i+1}/{len(cache_files)}] 更新 {stock_code}...", end=" ", flush=True) + + try: + # 检查现有缓存数据范围 + with open(cache_file, 'r', encoding='utf-8') as f: + data = json.load(f) + + records = data.get('records', []) + if records: + first_date = records[0].get('日期', '') + # 如果已经有上年1月的数据,跳过 + if first_date and first_date <= start_date: + print(f"已有上年数据({first_date}),跳过") + continue + + # 需要更新 + df, name, error = get_stock_fund_flow(stock_code, start_date, end_date) + if error: + print(f"错误: {error}") + failed += 1 + elif df is not None and len(df) > 0: + save_cached_data(stock_code, df, name) + print(f"成功 ({len(df)}条)") + updated += 1 + else: + print("无数据") + failed += 1 + except Exception as e: + print(f"异常: {e}") + failed += 1 + + print(f"\n完成! 更新: {updated}, 失败: {failed}") + +if __name__ == '__main__': + update_all_caches() diff --git a/stock-html/update_fundamental.py b/stock-html/update_fundamental.py new file mode 100644 index 0000000..b86a219 --- /dev/null +++ b/stock-html/update_fundamental.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +""" +批量更新数据库中缺少财务指标的基本面数据 +""" +import akshare as ak +import pandas as pd +import psycopg2 +from datetime import date, datetime +import time + +# 数据库配置 +DB_CONFIG = { + 'host': 'localhost', + 'port': 5432, + 'dbname': 'stock_app', + 'user': 'postgres', + 'password': 'xypg5432' +} + +def get_db(): + """获取数据库连接""" + try: + conn = psycopg2.connect(**DB_CONFIG) + return conn + except Exception as e: + print(f"数据库连接失败: {e}") + return None + +def get_stocks_need_update(): + """获取需要更新财务指标的股票列表""" + conn = get_db() + if not conn: + return [] + + try: + cur = conn.cursor() + cur.execute(""" + SELECT code, name + FROM stock_fundamental + WHERE roe IS NULL OR eps IS NULL + """) + return cur.fetchall() + finally: + conn.close() + +def fetch_financial_indicators(stock_code): + """从akshare获取股票财务指标""" + try: + df = ak.stock_financial_analysis_indicator(symbol=stock_code, start_year='2024') + if df is None or df.empty: + return None + + latest = df.iloc[-1] + + def safe_float(val): + if pd.isna(val): + return None + try: + return float(val) + except: + return None + + return { + 'eps': safe_float(latest.get('摊薄每股收益(元)')), + 'bps': safe_float(latest.get('每股净资产_调整后(元)')), + 'roe': safe_float(latest.get('净资产收益率(%)')), + 'gross_margin': safe_float(latest.get('销售毛利率(%)')), + 'net_margin': safe_float(latest.get('销售净利率(%)')), + 'revenue_yoy': safe_float(latest.get('主营业务收入增长率(%)')), + 'profit_yoy': safe_float(latest.get('净利润增长率(%)')), + } + except Exception as e: + print(f" 获取 {stock_code} 财务指标失败: {e}") + return None + +def update_stock_fundamental(stock_code, data): + """更新股票基本面数据""" + conn = get_db() + if not conn: + return False + + try: + cur = conn.cursor() + cur.execute(""" + UPDATE stock_fundamental + SET roe = %s, eps = %s, bps = %s, + revenue_yoy = %s, profit_yoy = %s, + gross_margin = %s, net_margin = %s, + updated_at = NOW() + WHERE code = %s + """, ( + data.get('roe'), + data.get('eps'), + data.get('bps'), + data.get('revenue_yoy'), + data.get('profit_yoy'), + data.get('gross_margin'), + data.get('net_margin'), + stock_code + )) + conn.commit() + return True + except Exception as e: + conn.rollback() + print(f" 更新 {stock_code} 失败: {e}") + return False + finally: + conn.close() + +def main(): + print("=" * 50) + print("批量更新基本面财务指标") + print("=" * 50) + + # 获取需要更新的股票 + stocks = get_stocks_need_update() + print(f"\n需要更新的股票数量: {len(stocks)}") + + if not stocks: + print("所有股票财务指标已完整,无需更新") + return + + success_count = 0 + fail_count = 0 + + for i, (code, name) in enumerate(stocks, 1): + print(f"\n[{i}/{len(stocks)}] 正在更新 {code} {name}...") + + # 获取财务指标 + data = fetch_financial_indicators(code) + + if data: + # 更新数据库 + if update_stock_fundamental(code, data): + print(f" ✓ 更新成功: EPS={data.get('eps')}, ROE={data.get('roe')}%") + success_count += 1 + else: + fail_count += 1 + else: + print(f" ✗ 无法获取财务指标") + fail_count += 1 + + # 避免请求过快 + time.sleep(0.5) + + print("\n" + "=" * 50) + print(f"更新完成! 成功: {success_count}, 失败: {fail_count}") + print("=" * 50) + +if __name__ == '__main__': + main() diff --git a/stock-html/utils/__init__.py b/stock-html/utils/__init__.py new file mode 100644 index 0000000..db3e327 --- /dev/null +++ b/stock-html/utils/__init__.py @@ -0,0 +1 @@ +# utils package diff --git a/stock-html/utils/data_fetcher.py b/stock-html/utils/data_fetcher.py new file mode 100644 index 0000000..2609f43 --- /dev/null +++ b/stock-html/utils/data_fetcher.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +""" +统一数据获取模块 - 自动降级数据源 +当东方财富API被封(腾讯云等环境)时,自动切换腾讯财经/新浪等备用数据源 + +使用方法(替代 akshare 直接调用): + from utils.data_fetcher import fetch_stock_hist + df = fetch_stock_hist('000001', period='daily', start_date='20250101', end_date='20260226', adjust='qfq') +""" +import logging +import requests +import pandas as pd +from datetime import datetime, timedelta + +logger = logging.getLogger(__name__) + +# 数据源状态追踪 (避免反复尝试已知失败的数据源) +_source_status = { + 'eastmoney': True, # 是否可用 + 'tencent': True, + 'sina': True, +} +_source_fail_count = { + 'eastmoney': 0, + 'tencent': 0, + 'sina': 0, +} +_MAX_FAIL_BEFORE_SKIP = 3 # 连续失败N次后暂时跳过 + + +def _mark_source_failed(source): + """标记数据源失败""" + _source_fail_count[source] = _source_fail_count.get(source, 0) + 1 + if _source_fail_count[source] >= _MAX_FAIL_BEFORE_SKIP: + _source_status[source] = False + logger.warning(f"[数据源] {source} 连续失败 {_source_fail_count[source]} 次,暂时禁用") + + +def _mark_source_ok(source): + """标记数据源成功""" + _source_fail_count[source] = 0 + _source_status[source] = True + + +def _to_tencent_symbol(stock_code): + """转为腾讯API格式: sz000001, sh600519""" + code = str(stock_code).strip() + if code.startswith('6'): + return f'sh{code}' + elif code.startswith('0') or code.startswith('3'): + return f'sz{code}' + elif code.startswith('8') or code.startswith('4'): + return f'bj{code}' + return f'sz{code}' + + +def _fetch_hist_from_tencent(stock_code, start_date, end_date, adjust='qfq'): + """ + 从腾讯财经获取历史日K线 + API: http://web.ifzq.gtimg.cn/appstock/app/fqkline/get + 返回格式: [date, open, close, high, low, volume] + 注意: 腾讯返回的是 [open, close],akshare返回的是 [开盘, 收盘] + """ + symbol = _to_tencent_symbol(stock_code) + + # 腾讯最多返回约640条日线(约2.5年) + # 格式化日期 + start_fmt = f'{start_date[:4]}-{start_date[4:6]}-{start_date[6:8]}' if len(start_date) == 8 else start_date + end_fmt = f'{end_date[:4]}-{end_date[4:6]}-{end_date[6:8]}' if len(end_date) == 8 else end_date + + # 计算请求的天数 + try: + d1 = datetime.strptime(start_date[:8], '%Y%m%d') + d2 = datetime.strptime(end_date[:8], '%Y%m%d') + num_bars = (d2 - d1).days + 50 # 多请求一些,因为有非交易日 + num_bars = min(max(num_bars, 60), 640) + except: + num_bars = 320 + + # 前复权: qfqday, 不复权: day + adj_key = 'qfqday' if adjust == 'qfq' else 'day' + adj_param = 'qfq' if adjust == 'qfq' else '' + + url = f'http://web.ifzq.gtimg.cn/appstock/app/fqkline/get' + params = f'{symbol},day,{start_fmt},{end_fmt},{num_bars},{adj_param}' + + r = requests.get(url, params={'param': params}, timeout=15) + if r.status_code != 200: + raise Exception(f'腾讯API返回 {r.status_code}') + + data = r.json() + stock_key = symbol # e.g. 'sz000001' + klines = data.get('data', {}).get(stock_key, {}).get(adj_key, []) + + if not klines: + # 尝试不复权 + klines = data.get('data', {}).get(stock_key, {}).get('day', []) + + if not klines: + return pd.DataFrame() + + # 构造与 akshare stock_zh_a_hist 兼容的 DataFrame + # 腾讯格式: [date, open, close, high, low, volume] + rows = [] + prev_close = None + for k in klines: + if len(k) < 6: + continue + date_str = k[0] + open_price = float(k[1]) + close_price = float(k[2]) + high_price = float(k[3]) + low_price = float(k[4]) + volume = float(k[5]) + + # 计算衍生字段 + change_amount = close_price - prev_close if prev_close else 0 + change_pct = (change_amount / prev_close * 100) if prev_close and prev_close > 0 else 0 + amplitude = ((high_price - low_price) / prev_close * 100) if prev_close and prev_close > 0 else 0 + + rows.append({ + '日期': date_str, + '开盘': open_price, + '收盘': close_price, + '最高': high_price, + '最低': low_price, + '成交量': int(volume), + '成交额': 0, # 腾讯不提供成交额 + '振幅': round(amplitude, 2), + '涨跌幅': round(change_pct, 2), + '涨跌额': round(change_amount, 2), + '换手率': 0, # 腾讯不提供换手率 + }) + prev_close = close_price + + df = pd.DataFrame(rows) + + # 过滤日期范围 + if not df.empty: + df['日期'] = pd.to_datetime(df['日期']) + start_dt = pd.to_datetime(start_fmt) + end_dt = pd.to_datetime(end_fmt) + df = df[(df['日期'] >= start_dt) & (df['日期'] <= end_dt)] + df = df.sort_values('日期').reset_index(drop=True) + + return df + + +def fetch_stock_hist(stock_code, period='daily', start_date='20200101', + end_date=None, adjust='qfq'): + """ + 获取股票历史K线数据(腾讯财经为主数据源) + + 参数与 akshare.stock_zh_a_hist 完全兼容: + stock_code: 股票代码 (纯数字,如 '000001') + period: 'daily', 'weekly', 'monthly' + start_date: 开始日期 'YYYYMMDD' + end_date: 结束日期 'YYYYMMDD' + adjust: 'qfq'(前复权) / 'hfq'(后复权) / ''(不复权) + + 返回: pandas DataFrame, 与 akshare 格式兼容 + """ + if end_date is None: + end_date = datetime.now().strftime('%Y%m%d') + + # 数据源: 腾讯财经(日K线,腾讯云最快最稳) + if period == 'daily': + try: + df = _fetch_hist_from_tencent(stock_code, start_date, end_date, adjust) + if df is not None and not df.empty: + return df + except Exception as e: + logger.warning(f"[数据源] 腾讯财经获取失败({stock_code}): {str(e)[:100]}") + + logger.error(f"[数据源] 数据源获取失败: {stock_code}") + return pd.DataFrame() + + +def fetch_stock_codes(): + """ + 获取全部A股股票代码和名称(从数据库获取) + 返回: DataFrame with columns ['code', 'name'] + """ + # 从数据库获取(最可靠,不依赖外部API) + logger.info("[数据源] 从数据库获取股票列表") + return pd.DataFrame() + + +def reset_source_status(): + """重置所有数据源状态(用于定时任务开始时)""" + global _source_status, _source_fail_count + _source_status = {'eastmoney': True, 'tencent': True, 'sina': True} + _source_fail_count = {'eastmoney': 0, 'tencent': 0, 'sina': 0} + logger.info("[数据源] 所有数据源状态已重置") diff --git a/suanfa.md b/suanfa.md new file mode 100644 index 0000000..5e3b5a2 --- /dev/null +++ b/suanfa.md @@ -0,0 +1,34 @@ +# 交易信号实战体系整理 +## 一、交易信号胜率排行(由高至低) +| 排名 | 信号名称 | 核心参数/特征 | 胜率 | 核心含义 | +| :--- | :--- | :--- | :--- | :--- | +| 1 | ★主升浪 | MACD零上金叉 | 85%(最高) | 趋势走好,进入加速拉升阶段 | +| 2 | 日线底背离 | 20日新低版 | 80% | 真正跌透,迎来大级别反转 | +| 3 | 龙抬头 | SKDJ超跌+不漂移 | 75% | 短线起爆点,反弹稳定性强 | +| 4 | 真龙 | 趋势启动 | 70% | 中期趋势刚刚启动 | +| 5 | 短底背离 | 10/30版 | 65% | 小级别反弹,灵敏度高但力度偏弱 | +| 6 | 老鼠仓 | - | 60% | 主力偷偷吸筹,上涨不具备即时性 | +| 7 | 反弹 | EMA3上穿EMA21 | 55%(最低) | 普通均线金叉,震荡市适用、熊市易现假反弹 | + +## 二、标准牛股启动信号先后顺序(底部→拉升流程) +1. **先出**:日线底背离 / 短底背离(跌到底部,停止下跌) +2. **再出**:龙抬头(资金进场,短线起爆) +3. **接着出**:真龙(趋势正式确立) +4. **然后出**:★主升浪(进入加速段,利润兑现最快) +5. **最后出**:反弹(中途回调后的补涨信号) + +**补充说明**:老鼠仓可在底部任意位置提前出现,属于提前埋伏类信号。 + +## 三、体系最强战法(胜率最高组合) +按以下步骤操作,实现最稳健交易: +1. 日线底背离出现 → 纳入关注范围 +2. 龙抬头出现 → 执行买入操作 +3. 真龙 / ★主升浪出现 → 持有仓位,并进行加仓 +4. 不见主升浪 → 坚决不出场 + +## 四、核心信号一句话总结 +- ★主升浪:最稳、最猛(利润核心阶段) +- 日线底背离:最安全抄底(反转前置信号) +- 龙抬头:最佳入场点(实操核心买点) +- 真龙:趋势确认(中期行情定局信号) +- 短底背离、反弹:仅作为辅助参考(不单独作为核心决策依据) \ No newline at end of file diff --git a/用户使用说明.md b/用户使用说明.md new file mode 100644 index 0000000..62fe51a --- /dev/null +++ b/用户使用说明.md @@ -0,0 +1,268 @@ +# 股票投资系统 - 用户使用说明 + +## 一、系统概述 + +本系统是基于**交易信号实战体系**的股票投资辅助工具,围绕 7 种技术交易信号进行全市场扫描、个股分析和交易管理。 + +**核心理念**:通过 MACD、SKDJ、EMA 等技术指标自动检测 7 种交易信号,帮助用户发现潜在的买入/卖出机会。 + +**访问方式**: +- 浏览器访问服务器地址(支持手机和电脑):http://8.146.207.22:3333/ +- 支持 PWA,可添加到手机主屏幕作为独立应用使用 + +--- + +## 二、登录与账户 + +### 注册 +1. 打开系统,点击「注册」标签 +2. 输入邮箱地址和密码(至少 6 位) +3. 点击「注册」按钮完成 + +### 登录 +1. 输入已注册的邮箱和密码 +2. 点击「登录」进入系统 + +### 修改密码 +- 点击页面右上角的用户名,在弹窗中修改密码 + +--- + +## 三、主页顶部栏 + +| 元素 | 说明 | +|------|------| +| **日期** | 显示当前日期 | +| **开市/闭市** | 自动判断市场状态(交易日 9:30-11:30、13:00-15:00 为开市),每 30 秒自动刷新 | +| **策略标识** | 显示当前使用的策略体系名称 | +| **用户名** | 点击可修改密码 | +| **退出** | 退出登录 | + +--- + +## 四、功能模块详解 + +系统分为 4 个主标签页:**提醒**、**交易**、**分析**、**模型** + +--- + +### 4.1 提醒(首页) + +提醒页面是日常使用的核心页面,展示关注股票的实时信号状态。 + +#### 顶部信号汇总 +- **持有**:当前持仓股票数量 +- **关注**:关注列表中的股票数量 +- **建议买入**:有买入信号的股票数量 +- **建议卖出**:有卖出信号的股票数量 +- **刷新按钮**:重新检测所有关注/持有股票的最新信号 + +#### 股票分类 +- **持有**:已买入持仓的股票,点击可切换查看 +- **关注**:加入关注但未持仓的股票 +- **+ 按钮**:手动添加股票到关注列表 + +#### 信号卡片 +每只关注/持有的股票会显示一张卡片,包含: +- 股票代码和名称 +- 实时价格和涨跌幅 +- 触发的交易信号标签(如"日线底背离"、"龙抬头"等) +- 操作建议(买入/卖出/持有观望) + +#### 点击卡片 +点击任意股票卡片会打开**基本面弹窗**,显示: +- **交易信号状态**:7 种信号的触发情况(触发的为红色高亮) + - 多信号共振(≥3 个)时会有特别提示 +- **估值指标**:市盈率、市净率、ROE +- **市值规模**:总市值、行业分类 +- **成长性**:营收同比、净利润同比 +- **每股指标**:每股收益、每股净资产、净利率 +- **近 3 日资金流向**:超大单、主力资金净流入 +- **K 线走势图**:支持周/月/季/年切换 + +#### 基本面弹窗操作 +- **添加关注 / 取消关注**:管理股票关注状态 +- **AI 分析**:调用 AI 对该股票进行深度分析(基于交易信号体系) + +--- + +### 4.2 交易 + +交易页面记录和管理所有买入/卖出操作。 + +#### 交易统计 +- 总交易次数、买入/卖出次数 +- 盈利/亏损/持平次数 +- 胜率统计 + +#### 交易记录列表 +- 按时间倒序显示所有交易记录 +- 每条记录包含:股票代码、买入/卖出方向、价格、数量、日期 + +#### 新增交易 +- 通过提醒页面的"买入"/"卖出"按钮触发 +- 录入股票代码、交易方向、价格、数量等信息 + +--- + +### 4.3 分析 + +分析页面包含三个子标签:**全景扫描**、**模拟交易**、**AI 分析** + +#### 4.3.1 全景扫描 + +对全市场约 5800 只 A 股进行 7 种信号检测,发现有信号触发的股票。 + +**扫描机制**: +- 系统每个交易日 **凌晨 01:00 自动执行**全量扫描(当日 scan_date,早上打开即可直接查看,无需重扫) +- 用户无需手动触发扫描,打开即可查看最新结果 + +**查看结果**: +1. 点击「全景扫描」按钮,直接展示最近一次的扫描结果 +2. 顶部显示扫描概要:已扫描股票数 / 总股票数、有信号的股票数 +3. 信号分布统计(可折叠):各信号类型的触发数量 + +**筛选功能**: +- **标签筛选**:选择一个或多个信号标签,筛选同时触发这些信号的股票(AND 关系) +- **全部/有信号**:切换显示全部股票或仅有信号的股票 + +**股票卡片信息**: +- 股票代码和名称 +- ★ 图标 + 金色左边框:表示该股票已在关注列表中 +- 操作建议标签(买入/关注/观望) +- 价格和涨跌幅 +- 触发的信号数量和具体信号名称 + +**点击卡片**:展开该股票的详细信号检测结果 +- 各信号的触发状态和详细描述 +- 技术指标数值(MACD、SKDJ、EMA 等) +- 可直接添加关注或打开基本面 + +#### 4.3.2 单只股票检测 + +在全景扫描页面顶部的搜索框中: +1. 输入股票代码(如 300498) +2. 点击「检测信号」 +3. 查看该股票的 7 种信号检测结果 + +**批量扫描关注**:一键扫描所有关注列表中的股票信号 + +#### 4.3.3 策略建议 + +系统根据关注/持有股票的信号状态,自动生成操作建议列表。 + +#### 4.3.4 模拟交易 + +提供虚拟资金的模拟交易功能,用于验证交易信号体系的有效性。 +- 初始资金:100 万 +- 支持买入/卖出操作 +- 跟踪模拟交易的盈亏情况 + +#### 4.3.5 AI 分析 + +独立的 AI 分析页面,输入股票代码获取 AI 深度分析报告。 + +**AI 分析内容**(基于交易信号实战体系): +1. **交易信号分析**:7 种信号的当前状态和解读 +2. **基本面分析**:估值、成长性、资金流向 +3. **操作建议**:基于信号体系的具体买入/卖出建议 +4. **风险提示**:需关注的风险因素 + +--- + +### 4.4 模型 + +模型页面展示交易信号体系的完整说明,作为交易决策的参考指南。 + +#### 信号胜率排行 +按胜率从高到低排列 7 种交易信号: + +| 排名 | 信号名称 | 胜率 | 核心含义 | +|:---:|:---:|:---:|:---| +| 1 | ★主升浪 | 85% | 趋势走好,进入加速拉升阶段 | +| 2 | 日线底背离 | 80% | 真正跌透,迎来大级别反转 | +| 3 | 龙抬头 | 75% | 短线起爆点,反弹稳定性强 | +| 4 | 真龙 | 70% | 中期趋势刚刚启动 | +| 5 | 短底背离 | 65% | 小级别反弹,灵敏度高 | +| 6 | 老鼠仓 | 60% | 主力偷偷吸筹 | +| 7 | 反弹 | 55% | 普通均线金叉 | + +#### 标准牛股启动顺序 +底背离 → 龙抬头 → 真龙 → ★主升浪 → 反弹 + +#### 体系最强战法 +1. 日线底背离出现 → **关注** +2. 龙抬头出现 → **买入** +3. 真龙/★主升浪出现 → **加仓** +4. 不见主升浪 → **坚决不出场** + +--- + +## 五、典型操作流程 + +### 日常使用(推荐) + +``` +每日开盘前(或前一天晚上): +1. 打开「提醒」页面 → 查看关注/持有股票的最新信号 +2. 点击「刷新」更新信号状态 +3. 根据信号提示执行买入/卖出操作 + +发现新股票: +1. 打开「分析」→「全景扫描」→ 查看扫描结果 +2. 筛选感兴趣的信号组合 +3. 点击股票卡片查看详情 +4. 添加到关注列表 +``` + +### 体系最强战法实操 + +``` +第一步:发现机会 +- 全景扫描中筛选"日线底背离"信号的股票 +- 结合基本面和资金流向筛选优质标的 +- 添加到关注列表 + +第二步:等待买点 +- 每日查看提醒页面 +- 当关注股票出现"龙抬头"信号时 → 执行买入 + +第三步:持仓管理 +- 出现"真龙"或"★主升浪"→ 加仓 +- 未出现主升浪 → 继续持有,不要出场 +- 出现卖出信号(如 MACD 死叉+主升浪消失)→ 卖出 +``` + +--- + +## 六、自动扫描说明 + +系统已配置每个交易日(周一至周五)**凌晨 01:00** 自动执行全市场扫描: +- 扫描范围:全部 A 股(约 5800 只) +- 扫描内容:7 种技术交易信号检测 +- 数据来源:当日收盘的日 K 线数据 +- 结果存储:数据库持久化,随时查看 + +用户无需手动触发扫描,每天打开「全景扫描」即可查看最新结果。 + +--- + +## 七、常见问题 + +**Q:为什么全景扫描显示的是昨天的数据?** +A:全景扫描基于收盘价计算,每个交易日凌晨 01:00 自动更新。早上打开即可查看当日扫描结果,无需重复扫描。 + +**Q:信号标签的多选筛选是什么逻辑?** +A:多选标签为 AND(并集)关系。例如同时选中"日线底背离"和"龙抬头",显示的是同时触发这两个信号的股票。 + +**Q:基本面弹窗中的"交易信号"区域显示什么?** +A:显示该股票当天的 7 种信号触发状态。红色标签为已触发,灰色为未触发。同时显示触发数量(如"触发 3/7")和智能解读。 + +**Q:AI 分析的结果可靠吗?** +A:AI 分析基于交易信号体系的规则进行分析和建议,可作为决策参考。最终投资决策应结合个人判断,股市有风险,投资需谨慎。 + +**Q:全景扫描中 ★ 标记的股票是什么意思?** +A:带有金色 ★ 图标和左侧金色边框的股票表示已在您的关注列表中,方便您快速识别。 + +**Q:如何将系统添加到手机主屏幕?** +A:在手机浏览器中打开系统地址,使用浏览器的"添加到主屏幕"功能即可(Safari 点击分享按钮,Chrome 点击菜单中的安装选项)。 diff --git a/验算.md b/验算.md new file mode 100644 index 0000000..dac5e7e --- /dev/null +++ b/验算.md @@ -0,0 +1,54 @@ +想法理解 +起点:2026 年第一个交易日 10:00,按本应用推荐算法选股并模拟买入 1000 股。 +之后每个交易日 15:00:按推荐算法决定「加仓 1000 股」或「清仓」。 +终点:跑到现在(最近一个交易日),看整体盈亏。 + +我的看法(想法 + 注意点) +优点:用同一套推荐逻辑做「历史回放」,能检验「体系最强战法」在 2026 年至今这段区间的表现,且规则清晰(每日一次决策:加仓 or 清仓)。 + +需要事先约定: +10:00 的“推荐”用哪天的数据:当前应用是「收盘后全景扫描」,没有 10:00 的实时扫描。回测里通常用前一交易日收盘后的扫描结果当作「当日 10:00 的推荐」是合理做法。 +合理 + +15:00 的加仓/清仓用哪天的推荐:同样用前一交易日收盘后的扫描即可,和 10:00 一致,避免未来数据。 +用当天中午的全景扫描数据 + +价格:10:00 买入用当日开盘价近似,15:00 加仓/清仓用当日收盘价(没有分钟数据时的常规做法)。 +用当时的实时价格 + +选哪只股买入:若当日多只「买入」,需要规则(例如只买一只:选推荐分最高或触发信号最多的;或按资金等权多只各 1000 股)。建议先做单只(例如全市场只选一只最强的「买入」),再扩展。 +先做单只 + +实现方案(分步) +第一步:数据与时间范围 +交易日历:2026 年第一个 A 股交易日(大概率是 2026-01-02)到「最近一个交易日」,只算交易日。 +K 线:依赖现有 stock_kline_daily 或能拉到的历史日 K,保证「每个交易日 T」都有 T-1 及之前 的日 K(用于 T 日 10:00/15:00 的推荐都用 T-1 收盘后的逻辑)。 +价格: +当日 10:00 买入 → 用该日 open; +当日 15:00 加仓/清仓 → 用该日 close。 +第二步:每日逻辑(与现有推荐一致) +10:00(模拟) +用 T-1 收盘 的 K 线跑 detect_all_signals → 得到 signal_status / indicators。 +对全市场(或当前关注的股票池)用 compute_recommend(..., is_holding=False),筛出 display_text == '买入'。 +若当前无持仓:按约定规则选 1 只(或 N 只)买入,记录以 T 日 open 价买入 1000 股。 +若已有持仓:可约定 10:00 不再新开仓,只做 15:00 的加仓/清仓(或也允许 10:00 新开,需你定)。 +15:00(模拟) +仍用 T-1 收盘 的扫描结果,对当前持仓用 compute_recommend(..., is_holding=True)。 +若得到 加仓 → 按 T 日 close 加仓 1000 股。 +若得到 卖出 → 按 T 日 close 全部清仓(只清该只,若多只则逐只按推荐处理)。 +其他(持有/观望等)→ 不加仓、不清仓。 +这样「推荐」和现有应用完全一致,只是按日回放。 +第三步:实现形式建议 +脚本:单独回测脚本(如 backtest_recommend_2026.py),不改现有交易/扫描逻辑,只读历史 K 线、按日调用 detect_all_signals + compute_recommend,并维护「持仓、成本、每次买卖价格与股数」。 +输出: +每日:日期、持仓、当日 10:00 操作(若有)、15:00 操作(加仓/清仓/无)、所用价格、累计盈亏。 +最后:总收益、总收益率、最大回撤、交易次数等(可先做总收益和收益率)。 +第四步:需要你拍板的点 +选股:多只「买入」时是「只买一只(哪只?)」还是「多只各 1000 股」? +资金:是否要设总资金上限(例如只允许同时持有 1 只,或最多 N 只)? +清仓后:下一日 10:00 是否立刻可以再按推荐新买 1000 股,还是要有冷却/限制? +历史 K 线:stock_kline_daily 是否已有 2026 年至今的数据;若没有,是否允许用 AKShare/麦蕊等按需拉取并落库后再回测? +小结 +想法:用 2026 首个交易日 10:00 起、每日 15:00 按本应用推荐做「加仓 or 清仓」的回测,是可行且和现有算法一致的。 +方案:用「前一日收盘扫描 + 当日 open/close」做价格与推荐,写独立回测脚本,逐日调用现有 detect_all_signals 与 compute_recommend,统计盈亏。 +下一步:你确认上面四个「选股/资金/清仓后/历史 K 线」的偏好后,我可以按这个方案写出具体回测脚本设计(含目录、入参、输出格式和伪代码),再落到你项目里的具体文件和调用方式。