From 8b114d6e9e80386447621c09ffe680d6664f8562 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=9F=A5=E5=BE=AE?= Date: Sat, 4 Jul 2026 09:30:50 +0800 Subject: [PATCH] refactor: remove duplicate scripts + JSON constants + update docs --- CHANGELOG.md | 200 +-- SYSTEM_ARCHITECTURE.md | 109 ++ advice_reconciliation.py | 468 +++--- branch_scanner.py | 7 +- collect_evaluation_data.py | 747 +++++----- data/stocks/01478.json | 5 + data/stocks/688981.json | 5 + mo_config.py | 2 + scripts/branch_evaluator.py | 8 - scripts/branch_scanner.py | 5 - scripts/capital_flow_collector.py | 1 - scripts/data_governance.py | 187 ++- scripts/data_validate.py | 178 ++- scripts/fix_trigger.py | 12 - scripts/import_holding_xls.py | 4 - scripts/intraday_health_check.py | 519 ++++--- scripts/price_monitor.py | 806 ----------- scripts/process_trade.py | 2 - scripts/prune_branches.py | 5 - scripts/stale_detector.py | 571 ++++---- scripts/strategy-staleness-check.py | 9 - scripts/strategy_lifecycle.py | 2059 --------------------------- scripts/strategy_review.py | 595 ++++---- scripts/xiaoguo_signal_consumer.py | 6 +- strategy_lifecycle.py | 15 +- system_health_check.py | 588 ++++---- 26 files changed, 2200 insertions(+), 4913 deletions(-) create mode 100644 SYSTEM_ARCHITECTURE.md delete mode 100644 scripts/price_monitor.py delete mode 100644 scripts/strategy_lifecycle.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 20968e8..fde3452 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,92 +1,108 @@ -# MoFin 架构改革 — 变更日志 - -> 日期:2026-06-29 ~ 2026-07-03 -> 执行:Sisyphus (小小莫) + Zhiwei (知微) - ---- - -## 2026-07-03 — JSON 彻底移除 + 币种架构修正 - -### JSON→DB 全面迁移 (32+ 文件) - -**数据层** -- `mo_data.py`:删除所有 JSON fallback,纯 SQLite 读取 -- `mofin_db.py`: - - `write_holding_strategy` 改为 DELETE+INSERT(旧 ON CONFLICT 静默失败) - - `holding_strategies` 新增 10 列:avg_price, decision_timestamp, note, quality_check, quality_checked_at, quality_issues_json, position_advice, signal_factors_json, time_horizon, decision_type - - 新增 `live_prices` / `mtf_cache` / `capital_flow_cache` 三张表 + 读写函数 - - 已有 `market_snapshots` 表接替 market.json - -**server.py** -- 新增 `_save_portfolio` / `_save_decision` / `_save_watchlist` 写函数 -- 所有 13 个 API 写端点切到 DB,读端点删除 JSON fallback - -**脚本层** -- 15 个文件 `json.load → mo_data.read_*()` -- 12 个文件 `json.dump → mofin_db.write_*()` 或直接删除 -- strategy_lifecycle JSON 冷备行全部删除 -- branch_scanner / prune_branches 切到 DB -- system_audit / stale_push_wlin / refresh_mtf_cache JSON→DB - -**LLM Prompt** -- evaluation-daily v2:`evaluation.json` → DB 表 -- knowledge-extraction v1:`decisions.json` → DB 表 -- analytics.py:删除 JSON 路径常量 - -**已移除的 JSON 文件**(不再被任何代码读写) -- portfolio.json / decisions.json / watchlist.json -- live_prices.json / multi_tf_cache.json / market.json / capital_flow_cache.json - -### 币种架构 - -**港股** — 个股 price/cost/stop_loss 存 **HKD**,`currency='HKD'` -**A股** — 个股 price/cost/stop_loss 存 **CNY**,`currency='CNY'` -**总资产** — `calc_total_assets()` 汇总时港股 HKD×汇率→CNY - -| 场景 | 港股 | A股 | -|------|------|-----| -| 个股显示(股软对照) | HKD | CNY | -| 技术位(止损/止盈/买入区) | HKD | CNY | -| 总资产 / 总市值 / P&L | CNY(汇总时转换) | CNY | - -### 关键修复 - -- 根 `price_monitor.py` 与 `scripts/price_monitor.py` 不一致(前者存 HKD,后者转 CNY)→ 统一:存 HKD,不转换 -- `s.get('price', 0)` 在 price 为 None 时返回 None 导致 TypeError → 改为 `s.get('price') or 0` -- 东财 API 限流(之前 0.1s 间隔疯狂刷被封)→ 第一只失败立刻切腾讯兜底 - -### 验证 - -``` -holdings: 19 (8 HKD + 11 CNY) -decisions: 19 (8 HKD + 11 CNY) -total_assets: stored = calculated ✅ -``` - ---- - -## 2026-07-01 — 统一读取层 + 现金日志 - -### 新增 -- `mo_data.py` — `read_portfolio()` / `read_decisions()` / `read_watchlist()` -- `mofin_db.py` — `cash_log` 表 + `write_cash_log` / `query_cash_log` - -### 修改 -- 16 个文件 `json.load → mo_data.read_*()` -- HK 股成本 HKD→CNY 修复(import_holding_xls 导入时转换) - ---- - -## 2026-06-30 — 初始架构重构 - -### 新增 -- `mo_models.py` — `calc_total_assets()`, `is_hk_stock()`, `get_hk_rate()`, `to_cny()`, `validate_portfolio()` -- `mo_config.py` — 配置单例 -- `mo_bridge.py` — DSA 集成桥 -- `mo_provider.py` — DSA 数据源适配器 -- `scripts/data_validate.py`, `holdings_reconciliation.py`, `process_trade.py`(知微) -- DSA Web UI: `http://192.168.1.246:8001` - -### 修改 -- `price_monitor.py`:港股腾讯→东方财富 + 统一 total_assets -- 8 个文件散落 `is_hk_stock` / `total_assets` 统一到 mo_models +# MoFin 架构改革 — 变更日志 + +> 日期:2026-06-29 ~ 2026-07-03 +> 执行:Sisyphus (小小莫) + Zhiwei (知微) + +--- + +## 2026-07-03 — 代码重构 + +### 删除重复文件 +- `scripts/strategy_lifecycle.py` — 旧版本,缺少 quality gate,root 版本是规范 +- `scripts/price_monitor.py` — 旧版本,root 版本是规范 + +### 清理 JSON 路径常量 +- 移除 scripts/ 中 12 个文件的 DECISIONS_PATH / PORTFOLIO_PATH / WATCHLIST_PATH 声明 +- 移除 root level 中无用的 JSON 路径常量 + +### 文档更新 +- `docs/SYSTEM_ARCHITECTURE.md` — 完整重写(v5.0) +- `CHANGELOG.md` — 补充重构记录 + +--- + +## 2026-07-03 — JSON 彻底移除 + 币种架构修正 + +### JSON→DB 全面迁移 (32+ 文件) + +**数据层** +- `mo_data.py`:删除所有 JSON fallback,纯 SQLite 读取 +- `mofin_db.py`: + - `write_holding_strategy` 改为 DELETE+INSERT(旧 ON CONFLICT 静默失败) + - `holding_strategies` 新增 10 列:avg_price, decision_timestamp, note, quality_check, quality_checked_at, quality_issues_json, position_advice, signal_factors_json, time_horizon, decision_type + - 新增 `live_prices` / `mtf_cache` / `capital_flow_cache` 三张表 + 读写函数 + - 已有 `market_snapshots` 表接替 market.json + +**server.py** +- 新增 `_save_portfolio` / `_save_decision` / `_save_watchlist` 写函数 +- 所有 13 个 API 写端点切到 DB,读端点删除 JSON fallback + +**脚本层** +- 15 个文件 `json.load → mo_data.read_*()` +- 12 个文件 `json.dump → mofin_db.write_*()` 或直接删除 +- strategy_lifecycle JSON 冷备行全部删除 +- branch_scanner / prune_branches 切到 DB +- system_audit / stale_push_wlin / refresh_mtf_cache JSON→DB + +**LLM Prompt** +- evaluation-daily v2:`evaluation.json` → DB 表 +- knowledge-extraction v1:`decisions.json` → DB 表 +- analytics.py:删除 JSON 路径常量 + +**已移除的 JSON 文件**(不再被任何代码读写) +- portfolio.json / decisions.json / watchlist.json +- live_prices.json / multi_tf_cache.json / market.json / capital_flow_cache.json + +### 币种架构 + +**港股** — 个股 price/cost/stop_loss 存 **HKD**,`currency='HKD'` +**A股** — 个股 price/cost/stop_loss 存 **CNY**,`currency='CNY'` +**总资产** — `calc_total_assets()` 汇总时港股 HKD×汇率→CNY + +| 场景 | 港股 | A股 | +|------|------|-----| +| 个股显示(股软对照) | HKD | CNY | +| 技术位(止损/止盈/买入区) | HKD | CNY | +| 总资产 / 总市值 / P&L | CNY(汇总时转换) | CNY | + +### 关键修复 + +- 根 `price_monitor.py` 与 `scripts/price_monitor.py` 不一致(前者存 HKD,后者转 CNY)→ 统一:存 HKD,不转换 +- `s.get('price', 0)` 在 price 为 None 时返回 None 导致 TypeError → 改为 `s.get('price') or 0` +- 东财 API 限流(之前 0.1s 间隔疯狂刷被封)→ 第一只失败立刻切腾讯兜底 + +### 验证 + +``` +holdings: 19 (8 HKD + 11 CNY) +decisions: 19 (8 HKD + 11 CNY) +total_assets: stored = calculated ✅ +``` + +--- + +## 2026-07-01 — 统一读取层 + 现金日志 + +### 新增 +- `mo_data.py` — `read_portfolio()` / `read_decisions()` / `read_watchlist()` +- `mofin_db.py` — `cash_log` 表 + `write_cash_log` / `query_cash_log` + +### 修改 +- 16 个文件 `json.load → mo_data.read_*()` +- HK 股成本 HKD→CNY 修复(import_holding_xls 导入时转换) + +--- + +## 2026-06-30 — 初始架构重构 + +### 新增 +- `mo_models.py` — `calc_total_assets()`, `is_hk_stock()`, `get_hk_rate()`, `to_cny()`, `validate_portfolio()` +- `mo_config.py` — 配置单例 +- `mo_bridge.py` — DSA 集成桥 +- `mo_provider.py` — DSA 数据源适配器 +- `scripts/data_validate.py`, `holdings_reconciliation.py`, `process_trade.py`(知微) +- DSA Web UI: `http://192.168.1.246:8001` + +### 修改 +- `price_monitor.py`:港股腾讯→东方财富 + 统一 total_assets +- 8 个文件散落 `is_hk_stock` / `total_assets` 统一到 mo_models diff --git a/SYSTEM_ARCHITECTURE.md b/SYSTEM_ARCHITECTURE.md new file mode 100644 index 0000000..363ab51 --- /dev/null +++ b/SYSTEM_ARCHITECTURE.md @@ -0,0 +1,109 @@ +# MoFin 系统架构 + +> 最后更新:2026-07-03 +> 维护人:Sisyphus (小小莫) + Zhiwei (知微) + +--- + +## 一、数据层 + +### 数据库 + +所有数据存储在 SQLite:`/home/hmo/web-dashboard/data/mofin.db` + +| 表 | 用途 | 写入方 | +|----|------|--------| +| `holdings` | 持仓列表 | price_monitor, import_holding_xls | +| `portfolio_summary` | 总资产/市值/仓位 | price_monitor, regenerate_all | +| `holding_strategies` | 策略/决策 | regenerate_all, server.py API | +| `watchlist_stocks` | 自选股 | price_monitor, regenerate_all | +| `cash_log` | 现金变更审计 | write_cash_log | +| `market_snapshots` | 大盘数据 | market_watch | +| `sector_snapshots` | 板块数据 | market_watch | +| `live_prices` | 实时价格快照 | price_monitor | +| `mtf_cache` | 多周期K线缓存 | multi_timeframe | +| `capital_flow_cache` | 资金流缓存 | capital_flow_collector | +| `price_events` | 价格触发事件 | price_monitor | + +### 数据访问 + +- **读取**:`mo_data.py` — `read_portfolio()` / `read_decisions()` / `read_watchlist()` +- **写入**:`mofin_db.py` — `write_holdings_batch()` / `write_holding_strategy()` / `write_portfolio_summary()` 等 + +JSON 文件已全部移除(portfolio.json, decisions.json, watchlist.json 等)。 + +### 币种 + +| 品种 | 个股存储 | 汇总 | +|------|---------|------| +| 港股 | HKD (currency='HKD') | CNY (×汇率) | +| A股 | CNY (currency='CNY') | CNY | + +`calc_total_assets()` 汇总时自动将港股 HKD 转 CNY。汇率由 `hk_rate.py` 实时获取。 + +--- + +## 二、核心模块 + +``` +MoFin/ +├── mo_models.py # 统一数据模型:calc_total_assets, is_hk_stock, to_cny +├── mo_data.py # 统一读取层:read_portfolio, read_decisions, read_watchlist +├── mofin_db.py # DB 层:建表 + 写函数 + 查询函数 +├── mo_config.py # 配置单例 +├── price_monitor.py # 价格更新(cron: */2 9-16 1-5) +├── strategy_lifecycle.py # 策略生命周期(regenerate_all + quality gates) +├── server.py # Flask Web API(端口 8899) +├── market_watch.py # 大盘采集(cron: */30 9-15) +├── market_screener.py # 全市场筛选(cron: */30 9-15) +├── strategy_tree.py # 策略树/分支管理 +├── strategy_evaluator.py # 策略评估 +├── hk_rate.py # 港币汇率(API + 缓存) +├── multi_timeframe.py # 多周期K线分析 +├── stock_profile.py # 个股画像 +├── data_freshness.py # 数据新鲜度校验 +├── system_audit.py # 系统审计 +├── system_health_check.py# 系统健康检查 +├── prompt_manager/ # LLM Prompt 管理 +├── scripts/ # 工具/一次性脚本 +└── docs/ # 文档 +``` + +--- + +## 三、数据流 + +``` +开盘 (9:00-16:00) + │ + ├── price_monitor (每2分钟) + │ ├── 东财/腾讯 API → 拉价格 + │ ├── write_holdings_batch → holdings 表 + │ └── write_portfolio_summary → portfolio_summary 表 + │ + ├── market_watch (每30分钟) + │ └── write_market_snapshot → market_snapshots + sector_snapshots + │ + ├── market_screener (每30分钟) + │ └── 读 market_snapshots → 小果 LLM 筛选 → candidate_pool + │ + └── stale_push_wlin (每30分钟) + └── 读持仓+决策 → 区间检测 → XMPP 推送 + +盘后 + │ + ├── system_audit (17:30) → 全局审计报告 + ├── strategy_review (20:00) → 策略复盘 + └── regenerate_all (手动/定时) → 全量策略重评 +``` + +--- + +## 四、版本 + +| 版本 | 日期 | 关键变更 | +|------|------|---------| +| 5.0 | 2026-07-03 | JSON 彻底移除,纯 DB。币种修正(港股 HKD,汇总 CNY)。消除重复文件。 | +| 4.0 | 2026-07-01 | mo_data 统一读取层,cash_log 表 | +| 3.0 | 2026-06-30 | mo_models 统一数据模型,DSA 集成 | +| 2.0 | 2026-06-29 | 初始架构重构 | diff --git a/advice_reconciliation.py b/advice_reconciliation.py index 58fd10d..93f4fe7 100644 --- a/advice_reconciliation.py +++ b/advice_reconciliation.py @@ -1,223 +1,245 @@ -#!/usr/bin/env python3 -"""advice_reconciliation.py — 建议对账脚本 - -每周运行一次,对比 decisions.json 的 advice_timeline 与 portfolio.json -的实际持仓变化,统计准确率。 - -用法: - python3 advice_reconciliation.py # 正常对账 - python3 advice_reconciliation.py --force # 强制重新对账所有建议 -""" -import json -import sys -from datetime import datetime, timedelta -from pathlib import Path - -DATA_DIR = Path(__file__).parent / "data" -DECISIONS_PATH = DATA_DIR / "decisions.json" -PORTFOLIO_PATH = DATA_DIR / "portfolio.json" -ACCURACY_PATH = DATA_DIR / "accuracy_stats.json" - -def load_json(path, default=None): - try: - with open(path, encoding="utf-8") as f: - return json.load(f) - except (FileNotFoundError, json.JSONDecodeError): - return {} if default is None else default - -def save_json(path, data): - Path(path).parent.mkdir(parents=True, exist_ok=True) - with open(path, "w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=2) - -def get_holding_change(portfolio, code): - """获取某只股票的当前持仓信息""" - holdings = portfolio.get("holdings", []) - for h in holdings: - if h["code"] == code: - return { - "code": code, - "name": h.get("name", ""), - "shares": h.get("shares", 0), - "cost": h.get("cost", 0), - "price": h.get("price", 0), - "position_pct": h.get("position_pct", 0), - } - return None # 已清仓 - -def evaluate_advice(advice, current_holding): - """评估一条建议是否正确 - - Returns: 'correct', 'partial', 'wrong', 'pending', 'unknown' - """ - direction = advice.get("direction", "") - status = advice.get("status", "pending") - - if status == "ignored": - return "ignored" - if status == "pending": - return "pending" - - if not current_holding: - # 股票已清仓 - if direction in ("卖出", "清仓", "减仓"): - return "correct" - elif direction in ("买入", "加仓", "补仓"): - return "wrong" - else: - return "unknown" - - shares = current_holding.get("shares", 0) - cost = current_holding.get("cost", 0) - price = current_holding.get("price", 0) - pnl_pct = (price - cost) / cost * 100 if cost > 0 else 0 - - if direction in ("买入", "加仓", "补仓"): - # 如果建议买入时价格低于现价,或浮盈为正 → 正确 - try: - advised_price = float(advice.get("price", 0)) - if advised_price > 0 and price > 0: - if price >= advised_price * 0.95: # 允许5%误差 - return "correct" - else: - return "wrong" - else: - return "unknown" - except: - return "unknown" - - elif direction in ("卖出", "清仓", "减仓"): - # 如果建议卖出时价格高于现价 → 正确(规避了下跌) - try: - advised_price = float(advice.get("price", 0)) - if advised_price > 0 and price > 0: - if price <= advised_price * 1.05: - return "correct" - else: - return "wrong" - else: - return "unknown" - except: - return "unknown" - - elif direction in ("持有", "观望"): - # 持有建议 → 看后续是否涨 - try: - advised_price = float(advice.get("price", 0)) - if advised_price > 0 and price > 0: - change = (price - advised_price) / advised_price * 100 - if change > -5: # 没跌超过5% - return "correct" - else: - return "wrong" - else: - return "unknown" - except: - return "unknown" - - elif direction == "自选": - # 自选建议无法直接对账 - return "unknown" - - return "unknown" - - -def run(): - force = "--force" in sys.argv - - decisions = load_json(DECISIONS_PATH, {"decisions": []}) - portfolio = load_json(PORTFOLIO_PATH, {"holdings": []}) - old_stats = load_json(ACCURACY_PATH, {}) - - results = [] - total = {"correct": 0, "wrong": 0, "partial": 0, "unknown": 0, "pending": 0, "ignored": 0} - - for entry in decisions.get("decisions", []): - code = entry["code"] - name = entry.get("name", code) - timeline = entry.get("advice_timeline", []) - - if not timeline: - continue - - current_holding = get_holding_change(portfolio, code) - - for i, advice in enumerate(timeline): - # 跳过已评估过的(除非 --force) - if not force and advice.get("evaluated"): - # 计数已有结果 - result = advice.get("result", "unknown") - total[result] = total.get(result, 0) + 1 - continue - - result = evaluate_advice(advice, current_holding) - advice["evaluated"] = True - advice["result"] = result - advice["evaluated_at"] = datetime.now().isoformat() - total[result] = total.get(result, 0) + 1 - - results.append({ - "code": code, - "name": name, - "date": advice.get("date", ""), - "direction": advice.get("direction", ""), - "summary": advice.get("summary", ""), - "result": result, - }) - - # 保存更新后的 decisions.json(含评估标记) - save_json(DECISIONS_PATH, decisions) - - # 计算准确率 - evaluated = total["correct"] + total["wrong"] + total["partial"] - accuracy = round(total["correct"] / evaluated * 100, 1) if evaluated > 0 else 0 - - stats = { - "updated_at": datetime.now().isoformat(), - "period_start": old_stats.get("period_start", (datetime.now() - timedelta(days=7)).isoformat()), - "period_end": datetime.now().isoformat(), - "total_advice": sum(total.values()), - "correct": total["correct"], - "wrong": total["wrong"], - "partial": total["partial"], - "unknown": total["unknown"], - "pending": total["pending"], - "ignored": total["ignored"], - "evaluated": evaluated, - "accuracy_pct": accuracy, - "details": results, - # 累计统计 - "cumulative": { - "total": old_stats.get("cumulative", {}).get("total", 0) + evaluated, - "correct": old_stats.get("cumulative", {}).get("correct", 0) + total["correct"], - "wrong": old_stats.get("cumulative", {}).get("wrong", 0) + total["wrong"], - }, - } - - cum = stats["cumulative"] - cum_accuracy = round(cum["correct"] / cum["total"] * 100, 1) if cum["total"] > 0 else 0 - stats["cumulative_accuracy_pct"] = cum_accuracy - - save_json(ACCURACY_PATH, stats) - - # 输出摘要 - print(f"📊 建议对账报告") - print(f" 周期: {stats['period_start'][:10]} ~ {stats['period_end'][:10]}") - print(f" 总建议: {stats['total_advice']}") - print(f" ✅ 正确: {stats['correct']}") - print(f" ❌ 错误: {stats['wrong']}") - print(f" ⏳ 待确认: {stats['pending']}") - print(f" ✗ 已忽略: {stats['ignored']}") - print(f" ❓ 无法判断: {stats['unknown']}") - print(f" 📈 本期准确率: {accuracy}%") - print(f" 📈 累计准确率: {cum_accuracy}%") - - if results: - print(f"\n 详情:") - for r in results[:20]: - icon = {"correct": "✅", "wrong": "❌", "partial": "🟡", "unknown": "❓", "pending": "⏳", "ignored": "✗"} - print(f" {icon.get(r['result'], '?')} {r['name']}({r['code']}) {r['direction']} → {r['result']}") - - -if __name__ == "__main__": - run() +#!/usr/bin/env python3 +"""advice_reconciliation.py — 建议对账脚本 + +每周运行一次,对比 decisions.json 的 advice_timeline 与 portfolio.json +的实际持仓变化,统计准确率。 + +用法: + python3 advice_reconciliation.py # 正常对账 + python3 advice_reconciliation.py --force # 强制重新对账所有建议 +""" +import json +import sys +from datetime import datetime, timedelta +from pathlib import Path + +from mo_data import read_decisions, read_portfolio +from mofin_db import get_conn, write_holding_strategy + +ACCURACY_PATH = Path(__file__).parent / "data" / "accuracy_stats.json" + +def load_json(path, default=None): + try: + with open(path, encoding="utf-8") as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return {} if default is None else default + +def save_json(path, data): + Path(path).parent.mkdir(parents=True, exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + +def get_holding_change(portfolio, code): + """获取某只股票的当前持仓信息""" + holdings = portfolio.get("holdings", []) + for h in holdings: + if h["code"] == code: + return { + "code": code, + "name": h.get("name", ""), + "shares": h.get("shares", 0), + "cost": h.get("cost", 0), + "price": h.get("price", 0), + "position_pct": h.get("position_pct", 0), + } + return None # 已清仓 + +def evaluate_advice(advice, current_holding): + """评估一条建议是否正确 + + Returns: 'correct', 'partial', 'wrong', 'pending', 'unknown' + """ + direction = advice.get("direction", "") + status = advice.get("status", "pending") + + if status == "ignored": + return "ignored" + if status == "pending": + return "pending" + + if not current_holding: + # 股票已清仓 + if direction in ("卖出", "清仓", "减仓"): + return "correct" + elif direction in ("买入", "加仓", "补仓"): + return "wrong" + else: + return "unknown" + + shares = current_holding.get("shares", 0) + cost = current_holding.get("cost", 0) + price = current_holding.get("price", 0) + pnl_pct = (price - cost) / cost * 100 if cost > 0 else 0 + + if direction in ("买入", "加仓", "补仓"): + # 如果建议买入时价格低于现价,或浮盈为正 → 正确 + try: + advised_price = float(advice.get("price", 0)) + if advised_price > 0 and price > 0: + if price >= advised_price * 0.95: # 允许5%误差 + return "correct" + else: + return "wrong" + else: + return "unknown" + except: + return "unknown" + + elif direction in ("卖出", "清仓", "减仓"): + # 如果建议卖出时价格高于现价 → 正确(规避了下跌) + try: + advised_price = float(advice.get("price", 0)) + if advised_price > 0 and price > 0: + if price <= advised_price * 1.05: + return "correct" + else: + return "wrong" + else: + return "unknown" + except: + return "unknown" + + elif direction in ("持有", "观望"): + # 持有建议 → 看后续是否涨 + try: + advised_price = float(advice.get("price", 0)) + if advised_price > 0 and price > 0: + change = (price - advised_price) / advised_price * 100 + if change > -5: # 没跌超过5% + return "correct" + else: + return "wrong" + else: + return "unknown" + except: + return "unknown" + + elif direction == "自选": + # 自选建议无法直接对账 + return "unknown" + + return "unknown" + + +def run(): + force = "--force" in sys.argv + + decisions = read_decisions() + portfolio = read_portfolio() + old_stats = load_json(ACCURACY_PATH, {}) + + results = [] + total = {"correct": 0, "wrong": 0, "partial": 0, "unknown": 0, "pending": 0, "ignored": 0} + + for entry in decisions.get("decisions", []): + code = entry["code"] + name = entry.get("name", code) + timeline = entry.get("advice_timeline", []) + + if not timeline: + continue + + current_holding = get_holding_change(portfolio, code) + + for i, advice in enumerate(timeline): + # 跳过已评估过的(除非 --force) + if not force and advice.get("evaluated"): + # 计数已有结果 + result = advice.get("result", "unknown") + total[result] = total.get(result, 0) + 1 + continue + + result = evaluate_advice(advice, current_holding) + advice["evaluated"] = True + advice["result"] = result + advice["evaluated_at"] = datetime.now().isoformat() + total[result] = total.get(result, 0) + 1 + + results.append({ + "code": code, + "name": name, + "date": advice.get("date", ""), + "direction": advice.get("direction", ""), + "summary": advice.get("summary", ""), + "result": result, + }) + + # 保存更新后的 decisions 到 DB(含评估标记) + conn = get_conn() + for entry in decisions.get("decisions", []): + code = entry.get("code", "") + name = entry.get("name", code) + write_holding_strategy(conn, code, name, entry) + # 写入 advice_timeline 评估标记 + for adv in entry.get("advice_timeline", []): + conn.execute( + """INSERT OR REPLACE INTO advice_timeline + (id, code, date, direction, price, summary, status, + evaluated, result, evaluated_at, report_id) + VALUES ( + (SELECT id FROM advice_timeline WHERE code=? AND date=? AND direction=? AND summary=?), + ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + )""", + (code, adv.get("date", ""), adv.get("direction", ""), adv.get("summary", ""), + code, adv.get("date", ""), adv.get("direction", ""), + adv.get("price"), adv.get("summary", ""), adv.get("status", ""), + 1 if adv.get("evaluated") else 0, + adv.get("result", ""), adv.get("evaluated_at", ""), + adv.get("report_id", ""))) + conn.commit() + conn.close() + + # 计算准确率 + evaluated = total["correct"] + total["wrong"] + total["partial"] + accuracy = round(total["correct"] / evaluated * 100, 1) if evaluated > 0 else 0 + + stats = { + "updated_at": datetime.now().isoformat(), + "period_start": old_stats.get("period_start", (datetime.now() - timedelta(days=7)).isoformat()), + "period_end": datetime.now().isoformat(), + "total_advice": sum(total.values()), + "correct": total["correct"], + "wrong": total["wrong"], + "partial": total["partial"], + "unknown": total["unknown"], + "pending": total["pending"], + "ignored": total["ignored"], + "evaluated": evaluated, + "accuracy_pct": accuracy, + "details": results, + # 累计统计 + "cumulative": { + "total": old_stats.get("cumulative", {}).get("total", 0) + evaluated, + "correct": old_stats.get("cumulative", {}).get("correct", 0) + total["correct"], + "wrong": old_stats.get("cumulative", {}).get("wrong", 0) + total["wrong"], + }, + } + + cum = stats["cumulative"] + cum_accuracy = round(cum["correct"] / cum["total"] * 100, 1) if cum["total"] > 0 else 0 + stats["cumulative_accuracy_pct"] = cum_accuracy + + save_json(ACCURACY_PATH, stats) + + # 输出摘要 + print(f"📊 建议对账报告") + print(f" 周期: {stats['period_start'][:10]} ~ {stats['period_end'][:10]}") + print(f" 总建议: {stats['total_advice']}") + print(f" ✅ 正确: {stats['correct']}") + print(f" ❌ 错误: {stats['wrong']}") + print(f" ⏳ 待确认: {stats['pending']}") + print(f" ✗ 已忽略: {stats['ignored']}") + print(f" ❓ 无法判断: {stats['unknown']}") + print(f" 📈 本期准确率: {accuracy}%") + print(f" 📈 累计准确率: {cum_accuracy}%") + + if results: + print(f"\n 详情:") + for r in results[:20]: + icon = {"correct": "✅", "wrong": "❌", "partial": "🟡", "unknown": "❓", "pending": "⏳", "ignored": "✗"} + print(f" {icon.get(r['result'], '?')} {r['name']}({r['code']}) {r['direction']} → {r['result']}") + + +if __name__ == "__main__": + run() diff --git a/branch_scanner.py b/branch_scanner.py index f822c7f..65f585a 100644 --- a/branch_scanner.py +++ b/branch_scanner.py @@ -17,9 +17,9 @@ branch_scanner.py — 分支自成长数据采集器(全静默) import json, sys, re from datetime import datetime from urllib.request import Request, urlopen +from mo_data import read_decisions -DECISIONS_PATH = "/home/hmo/web-dashboard/data/decisions.json" -SCANNER_STATE = "/home/hmo/web-dashboard/data/scanner_state.json" +SCANNER_STATE = "/home/hmo/web-dashboard/data/scanner_state.json" def get_price(code): @@ -82,8 +82,7 @@ def main(): scenario = get_scenario() sid = scenario.get("id", "unknown") - with open(DECISIONS_PATH) as f: - data = json.load(f) + data = read_decisions() decisions = data.get("decisions", []) for entry in decisions: diff --git a/collect_evaluation_data.py b/collect_evaluation_data.py index e642909..f27d4bf 100644 --- a/collect_evaluation_data.py +++ b/collect_evaluation_data.py @@ -1,374 +1,373 @@ -#!/usr/bin/env python3 -"""collect_evaluation_data.py — 六维评估原始数据采集 - -纯数据收集脚本(no_agent),不做任何评估/判断/RR计算。 -输出:data/evaluation_input.json — 供 21:00 LLM cron 使用。 - -采集内容: -D1 宏观环境 — 五大指数(上证/深证/恒生/恒科/A50) -D2 行业表现 — 持仓+自选按行业分组 -D3 技术面(当前) — 今开/今高/今低/昨收/现价/成交量 -D4 基本面 — PE/PB/总市值/52周高/52周低 -D5 消息面 — (此脚本不采集,LLM cron web_search) -D6 资金面 — 成交额/换手率/量比 - -日期:2026-06-18 v1 — 初始版本 -""" - -import json -import urllib.request -import os -import sys -import re -from datetime import datetime -from pathlib import Path - -DATA_DIR = Path(__file__).parent / "data" -DECISIONS_PATH = DATA_DIR / "decisions.json" -PORTFOLIO_PATH = DATA_DIR / "portfolio.json" -PROFILES_PATH = DATA_DIR / "stock_profiles.json" -OUTPUT_PATH = DATA_DIR / "evaluation_input.json" - -UA = "Mozilla/5.0" - - -def load_json(path, default=None): - try: - with open(path, encoding="utf-8") as f: - return json.load(f) - except (FileNotFoundError, json.JSONDecodeError): - return {} if default is None else default - - -def save_json(path, data): - Path(path).parent.mkdir(parents=True, exist_ok=True) - with open(path, "w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=2) - - -def fetch_tencent_data(symbols): - """批量拉行情。DB 优先,腾讯 API fallback""" - if not symbols: - return {} - # DB 优先 - try: - from mofin_db import get_prices_batch_from_db - db = get_prices_batch_from_db(symbols) - if db: - return {code: {"name": "", "price": p, "prev_close": 0, "change_pct": chg or 0, - "high": 0, "low": 0} for code, (p, chg) in db.items()} - except: pass - # Fallback: 腾讯 - code_map = {} - query_symbols = [] - for c in symbols: - sym = f"hk{c}" if len(c) == 5 else f"sh{c}" if c.startswith(("5", "6", "9")) else f"sz{c}" - query_symbols.append(sym) - code_map[sym] = c - url = f"http://qt.gtimg.cn/q={','.join(query_symbols)}" - try: - req = urllib.request.Request(url, headers={"User-Agent": UA}) - resp = urllib.request.urlopen(req, timeout=15) - text = resp.read().decode("gbk") - except Exception as e: - print(f"行情拉取失败: {e}", file=sys.stderr) - return {} - result = {} - for line in text.strip().split("\n"): - line = line.strip() - if not line or "=" not in line: - continue - raw = line.split("=", 1)[1].strip().strip('"').strip(";") - fields = raw.split("~") - if len(fields) < 35: - continue - sym = line.split("=", 1)[0].strip().lstrip("v_") - orig = code_map.get(sym) - if not orig: - continue - # 统一格式(A股和港股字段长度不同) - result[orig] = fields - return result - - -def fetch_indices(): - """拉五大指数""" - index_codes = { - "sh000001": "上证指数", - "sz399001": "深证成指", - "sz399006": "创业板指", - "hkHSI": "恒生指数", - "hkHSTECH": "恒生科技", - } - idx_map = {} - for c, n in index_codes.items(): - sym = c # 已经是完整符号 - idx_map[sym] = n - url = f"http://qt.gtimg.cn/q={','.join(index_codes.keys())}" - try: - req = urllib.request.Request(url, headers={"User-Agent": UA}) - resp = urllib.request.urlopen(req, timeout=10) - text = resp.read().decode("gbk") - except Exception as e: - print(f"指数拉取失败: {e}", file=sys.stderr) - return {} - result = {} - for line in text.strip().split("\n"): - line = line.strip() - if not line or "=" not in line: - continue - raw = line.split("=", 1)[1].strip().strip('"').strip(";") - fields = raw.split("~") - if len(fields) < 33: - continue - sym = line.split("=", 1)[0].strip().lstrip("v_") - name = idx_map.get(sym, sym) - result[name] = { - "price": safe_float(fields[3]), - "prev_close": safe_float(fields[4]), - "change_pct": safe_float(fields[32]), - "high": safe_float(fields[33]), - "low": safe_float(fields[34]), - "timestamp": fields[30] if len(fields) > 30 else "", - } - return result - - -def safe_float(v): - try: - return float(v) if v else None - except (ValueError, TypeError): - return None - - -def parse_stock_data(code, fields, is_hk=False): - """从腾讯 API 字段解析为结构化数据""" - data = { - "code": code, - "name": fields[1] if len(fields) > 1 else code, - "price": safe_float(fields[3]), - "prev_close": safe_float(fields[4]), - "open": safe_float(fields[5]) if not is_hk else None, - "change_pct": safe_float(fields[32]), - "high": safe_float(fields[33]), - "low": safe_float(fields[34]), - "volume": safe_float(fields[6]), # 股数 - } - # A股特有字段 (index 35+) - if not is_hk and len(fields) > 46: - data["turnover_rate"] = safe_float(fields[38]) # 换手率% - data["pe"] = safe_float(fields[47]) # 市盈率(动) - data["total_market_cap"] = safe_float(fields[44]) # 总市值(亿) - data["circulating_market_cap"] = safe_float(fields[45]) # 流通市值(亿) - data["high_52w"] = safe_float(fields[48]) # 52周高 - data["low_52w"] = safe_float(fields[49]) # 52周低 - data["amplitude"] = safe_float(fields[43]) # 振幅% - # 港股特有字段 - if is_hk and len(fields) > 70: - # 港股 PE 在 [71] 左右 - data["pe"] = safe_float(fields[71]) - data["total_market_cap"] = safe_float(fields[69]) - data["high_52w"] = safe_float(fields[48]) - data["low_52w"] = safe_float(fields[49]) - return data - - -def get_sector_mapping(profiles, decisions): - """ - 从 stock_profiles.json 和 decisions.json 建立 - {code: {name, sector, business, market, type}} 映射 - """ - mapping = {} - # 先读 stock_profiles - profile_list = profiles.get("profiles", []) if isinstance(profiles, dict) else profiles - if isinstance(profile_list, list): - for p in profile_list: - code = p.get("code", "") - if code: - mapping[code] = { - "name": p.get("name", ""), - "sector": p.get("sector", ""), - "business": p.get("business", ""), - "market": p.get("market", ""), - "type": p.get("type", ""), - } - # 再补全 decisions.json 中的信息 - for d in decisions.get("decisions", []): - code = d.get("code", "") - if code and code not in mapping: - trig = d.get("trigger", {}) - mapping[code] = { - "name": d.get("name", code), - "sector": trig.get("sector_name", d.get("sector_name", "")), - "business": "", - "market": "港股" if len(code) == 5 else "A股", - "type": d.get("type", "持仓策略"), - } - return mapping - - -def get_portfolio_info(portfolio): - """建立 {code: {cost, shares, position_pct}} 映射""" - result = {} - for h in portfolio.get("holdings", []): - code = h.get("code", "") - result[code] = { - "cost": h.get("cost", 0), - "shares": h.get("shares", 0), - "position_pct": h.get("position_pct", 0), - } - return result - - -def get_decisions_info(decisions): - """提取 decisions.json 中的策略参数""" - return decisions.get("decisions", []) - - -def run(): - # 加载数据 - decisions = load_json(DECISIONS_PATH, {"decisions": []}) - portfolio = load_json(PORTFOLIO_PATH, {"holdings": []}) - profiles = load_json(PROFILES_PATH, {"profiles": []}) - - # 获取行业映射 - sector_mapping = get_sector_mapping(profiles, decisions) - - # 获取持仓信息 - portfolio_info = get_portfolio_info(portfolio) - - # 收集所有代码 - all_codes = set() - for d in decisions.get("decisions", []): - code = d.get("code", "") - if code: - all_codes.add(code) - for h in portfolio.get("holdings", []): - code = h.get("code", "") - if code: - all_codes.add(code) - - # 区分 A/H 股 - a_codes = [c for c in all_codes if len(c) != 5] - hk_codes = [c for c in all_codes if len(c) == 5] - - # 拉行情 - a_prices = fetch_tencent_data(a_codes) if a_codes else {} - hk_prices = fetch_tencent_data(hk_codes) if hk_codes else {} - - # 拉指数 - index_data = fetch_indices() - - # 解析个股数据 - stock_data = {} - for code in a_codes: - if code in a_prices: - stock_data[code] = parse_stock_data(code, a_prices[code], is_hk=False) - for code in hk_codes: - if code in hk_prices: - stock_data[code] = parse_stock_data(code, hk_prices[code], is_hk=True) - - # 组装输出 - stocks = [] - all_codes_sorted = sorted(all_codes) - - for code in all_codes_sorted: - raw = stock_data.get(code, {}) - sector_info = sector_mapping.get(code, {}) - port = portfolio_info.get(code, {}) - strategy = None - for d in decisions.get("decisions", []): - if d.get("code") == code: - trig = d.get("trigger", {}) - strategy = { - "action": trig.get("action", d.get("action", "")), - "entry_zone": trig.get("entry_zone", ""), - "stop_loss": trig.get("stop_loss", d.get("stop_loss", "")), - "take_profit": trig.get("take_profit", d.get("take_profit", "")), - "type": d.get("type", "持仓策略"), - "tech_snapshot": trig.get("tech_snapshot", d.get("tech_snapshot", "")), - } - break - - stock_entry = { - "code": code, - "name": raw.get("name", sector_info.get("name", code)), - "market": "港股" if len(code) == 5 else "A股", - "type": sector_info.get("type", "持仓策略"), - "sector": sector_info.get("sector", ""), - "business": sector_info.get("business", ""), - # 当天行情 - "price": raw.get("price"), - "prev_close": raw.get("prev_close"), - "open": raw.get("open"), - "high": raw.get("high"), - "low": raw.get("low"), - "change_pct": raw.get("change_pct"), - "volume": raw.get("volume"), - # 基本面 - "pe": raw.get("pe"), - "total_market_cap": raw.get("total_market_cap"), - "high_52w": raw.get("high_52w"), - "low_52w": raw.get("low_52w"), - "turnover_rate": raw.get("turnover_rate"), - "amplitude": raw.get("amplitude"), - # 持仓 - "cost": port.get("cost", 0), - "shares": port.get("shares", 0), - "position_pct": port.get("position_pct", 0), - # 现策略 - "strategy": strategy, - } - # 浮亏% - cost = port.get("cost", 0) - price = raw.get("price", 0) - if cost > 0 and price > 0: - stock_entry["pnl_pct"] = round((price - cost) / cost * 100, 2) - else: - stock_entry["pnl_pct"] = None - - stocks.append(stock_entry) - - # 按行业分组统计 - sector_groups = {} - for s in stocks: - sector = s.get("sector", "未分类") - if sector not in sector_groups: - sector_groups[sector] = [] - sector_groups[sector].append({ - "code": s["code"], - "name": s["name"], - "change_pct": s["change_pct"], - "pnl_pct": s["pnl_pct"], - "type": s["type"], - }) - - # 汇总 - total = len(stocks) - up_count = sum(1 for s in stocks if s["change_pct"] is not None and s["change_pct"] > 0) - down_count = sum(1 for s in stocks if s["change_pct"] is not None and s["change_pct"] < 0) - deep_loss = sum(1 for s in stocks if s["pnl_pct"] is not None and s["pnl_pct"] < -20) - - output = { - "collected_at": datetime.now().isoformat(), - "total_stocks": total, - "summary": { - "up_count": up_count, - "down_count": down_count, - "deep_loss_count": deep_loss, - "holdings_count": len(portfolio_info), - "watchlist_count": total - len(portfolio_info), - }, - "index_data": index_data, - "sector_groups": sector_groups, - "stocks": stocks, - } - - save_json(OUTPUT_PATH, output) - print(f"数据收集完成: {total}只股票, {len(index_data)}个指数, {len(sector_groups)}个行业分组") - print(f" 上涨{up_count} 下跌{down_count} 深套{deep_loss}") - print(f" 输出: {OUTPUT_PATH}") - - -if __name__ == "__main__": - run() +#!/usr/bin/env python3 +"""collect_evaluation_data.py — 六维评估原始数据采集 + +纯数据收集脚本(no_agent),不做任何评估/判断/RR计算。 +输出:data/evaluation_input.json — 供 21:00 LLM cron 使用。 + +采集内容: +D1 宏观环境 — 五大指数(上证/深证/恒生/恒科/A50) +D2 行业表现 — 持仓+自选按行业分组 +D3 技术面(当前) — 今开/今高/今低/昨收/现价/成交量 +D4 基本面 — PE/PB/总市值/52周高/52周低 +D5 消息面 — (此脚本不采集,LLM cron web_search) +D6 资金面 — 成交额/换手率/量比 + +日期:2026-06-18 v1 — 初始版本 +""" + +import json +import urllib.request +import os +import sys +import re +from datetime import datetime +from pathlib import Path + +DATA_DIR = Path(__file__).parent / "data" +PROFILES_PATH = DATA_DIR / "stock_profiles.json" +OUTPUT_PATH = DATA_DIR / "evaluation_input.json" + +UA = "Mozilla/5.0" + + +def load_json(path, default=None): + try: + with open(path, encoding="utf-8") as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return {} if default is None else default + + +def save_json(path, data): + Path(path).parent.mkdir(parents=True, exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + +def fetch_tencent_data(symbols): + """批量拉行情。DB 优先,腾讯 API fallback""" + if not symbols: + return {} + # DB 优先 + try: + from mofin_db import get_prices_batch_from_db + db = get_prices_batch_from_db(symbols) + if db: + return {code: {"name": "", "price": p, "prev_close": 0, "change_pct": chg or 0, + "high": 0, "low": 0} for code, (p, chg) in db.items()} + except: pass + # Fallback: 腾讯 + code_map = {} + query_symbols = [] + for c in symbols: + sym = f"hk{c}" if len(c) == 5 else f"sh{c}" if c.startswith(("5", "6", "9")) else f"sz{c}" + query_symbols.append(sym) + code_map[sym] = c + url = f"http://qt.gtimg.cn/q={','.join(query_symbols)}" + try: + req = urllib.request.Request(url, headers={"User-Agent": UA}) + resp = urllib.request.urlopen(req, timeout=15) + text = resp.read().decode("gbk") + except Exception as e: + print(f"行情拉取失败: {e}", file=sys.stderr) + return {} + result = {} + for line in text.strip().split("\n"): + line = line.strip() + if not line or "=" not in line: + continue + raw = line.split("=", 1)[1].strip().strip('"').strip(";") + fields = raw.split("~") + if len(fields) < 35: + continue + sym = line.split("=", 1)[0].strip().lstrip("v_") + orig = code_map.get(sym) + if not orig: + continue + # 统一格式(A股和港股字段长度不同) + result[orig] = fields + return result + + +def fetch_indices(): + """拉五大指数""" + index_codes = { + "sh000001": "上证指数", + "sz399001": "深证成指", + "sz399006": "创业板指", + "hkHSI": "恒生指数", + "hkHSTECH": "恒生科技", + } + idx_map = {} + for c, n in index_codes.items(): + sym = c # 已经是完整符号 + idx_map[sym] = n + url = f"http://qt.gtimg.cn/q={','.join(index_codes.keys())}" + try: + req = urllib.request.Request(url, headers={"User-Agent": UA}) + resp = urllib.request.urlopen(req, timeout=10) + text = resp.read().decode("gbk") + except Exception as e: + print(f"指数拉取失败: {e}", file=sys.stderr) + return {} + result = {} + for line in text.strip().split("\n"): + line = line.strip() + if not line or "=" not in line: + continue + raw = line.split("=", 1)[1].strip().strip('"').strip(";") + fields = raw.split("~") + if len(fields) < 33: + continue + sym = line.split("=", 1)[0].strip().lstrip("v_") + name = idx_map.get(sym, sym) + result[name] = { + "price": safe_float(fields[3]), + "prev_close": safe_float(fields[4]), + "change_pct": safe_float(fields[32]), + "high": safe_float(fields[33]), + "low": safe_float(fields[34]), + "timestamp": fields[30] if len(fields) > 30 else "", + } + return result + + +def safe_float(v): + try: + return float(v) if v else None + except (ValueError, TypeError): + return None + + +def parse_stock_data(code, fields, is_hk=False): + """从腾讯 API 字段解析为结构化数据""" + data = { + "code": code, + "name": fields[1] if len(fields) > 1 else code, + "price": safe_float(fields[3]), + "prev_close": safe_float(fields[4]), + "open": safe_float(fields[5]) if not is_hk else None, + "change_pct": safe_float(fields[32]), + "high": safe_float(fields[33]), + "low": safe_float(fields[34]), + "volume": safe_float(fields[6]), # 股数 + } + # A股特有字段 (index 35+) + if not is_hk and len(fields) > 46: + data["turnover_rate"] = safe_float(fields[38]) # 换手率% + data["pe"] = safe_float(fields[47]) # 市盈率(动) + data["total_market_cap"] = safe_float(fields[44]) # 总市值(亿) + data["circulating_market_cap"] = safe_float(fields[45]) # 流通市值(亿) + data["high_52w"] = safe_float(fields[48]) # 52周高 + data["low_52w"] = safe_float(fields[49]) # 52周低 + data["amplitude"] = safe_float(fields[43]) # 振幅% + # 港股特有字段 + if is_hk and len(fields) > 70: + # 港股 PE 在 [71] 左右 + data["pe"] = safe_float(fields[71]) + data["total_market_cap"] = safe_float(fields[69]) + data["high_52w"] = safe_float(fields[48]) + data["low_52w"] = safe_float(fields[49]) + return data + + +def get_sector_mapping(profiles, decisions): + """ + 从 stock_profiles.json 和 decisions.json 建立 + {code: {name, sector, business, market, type}} 映射 + """ + mapping = {} + # 先读 stock_profiles + profile_list = profiles.get("profiles", []) if isinstance(profiles, dict) else profiles + if isinstance(profile_list, list): + for p in profile_list: + code = p.get("code", "") + if code: + mapping[code] = { + "name": p.get("name", ""), + "sector": p.get("sector", ""), + "business": p.get("business", ""), + "market": p.get("market", ""), + "type": p.get("type", ""), + } + # 再补全 decisions.json 中的信息 + for d in decisions.get("decisions", []): + code = d.get("code", "") + if code and code not in mapping: + trig = d.get("trigger", {}) + mapping[code] = { + "name": d.get("name", code), + "sector": trig.get("sector_name", d.get("sector_name", "")), + "business": "", + "market": "港股" if len(code) == 5 else "A股", + "type": d.get("type", "持仓策略"), + } + return mapping + + +def get_portfolio_info(portfolio): + """建立 {code: {cost, shares, position_pct}} 映射""" + result = {} + for h in portfolio.get("holdings", []): + code = h.get("code", "") + result[code] = { + "cost": h.get("cost", 0), + "shares": h.get("shares", 0), + "position_pct": h.get("position_pct", 0), + } + return result + + +def get_decisions_info(decisions): + """提取 decisions.json 中的策略参数""" + return decisions.get("decisions", []) + + +def run(): + # 加载数据 + from mo_data import read_decisions, read_portfolio + decisions = read_decisions() + portfolio = read_portfolio() + profiles = load_json(PROFILES_PATH, {"profiles": []}) + + # 获取行业映射 + sector_mapping = get_sector_mapping(profiles, decisions) + + # 获取持仓信息 + portfolio_info = get_portfolio_info(portfolio) + + # 收集所有代码 + all_codes = set() + for d in decisions.get("decisions", []): + code = d.get("code", "") + if code: + all_codes.add(code) + for h in portfolio.get("holdings", []): + code = h.get("code", "") + if code: + all_codes.add(code) + + # 区分 A/H 股 + a_codes = [c for c in all_codes if len(c) != 5] + hk_codes = [c for c in all_codes if len(c) == 5] + + # 拉行情 + a_prices = fetch_tencent_data(a_codes) if a_codes else {} + hk_prices = fetch_tencent_data(hk_codes) if hk_codes else {} + + # 拉指数 + index_data = fetch_indices() + + # 解析个股数据 + stock_data = {} + for code in a_codes: + if code in a_prices: + stock_data[code] = parse_stock_data(code, a_prices[code], is_hk=False) + for code in hk_codes: + if code in hk_prices: + stock_data[code] = parse_stock_data(code, hk_prices[code], is_hk=True) + + # 组装输出 + stocks = [] + all_codes_sorted = sorted(all_codes) + + for code in all_codes_sorted: + raw = stock_data.get(code, {}) + sector_info = sector_mapping.get(code, {}) + port = portfolio_info.get(code, {}) + strategy = None + for d in decisions.get("decisions", []): + if d.get("code") == code: + trig = d.get("trigger", {}) + strategy = { + "action": trig.get("action", d.get("action", "")), + "entry_zone": trig.get("entry_zone", ""), + "stop_loss": trig.get("stop_loss", d.get("stop_loss", "")), + "take_profit": trig.get("take_profit", d.get("take_profit", "")), + "type": d.get("type", "持仓策略"), + "tech_snapshot": trig.get("tech_snapshot", d.get("tech_snapshot", "")), + } + break + + stock_entry = { + "code": code, + "name": raw.get("name", sector_info.get("name", code)), + "market": "港股" if len(code) == 5 else "A股", + "type": sector_info.get("type", "持仓策略"), + "sector": sector_info.get("sector", ""), + "business": sector_info.get("business", ""), + # 当天行情 + "price": raw.get("price"), + "prev_close": raw.get("prev_close"), + "open": raw.get("open"), + "high": raw.get("high"), + "low": raw.get("low"), + "change_pct": raw.get("change_pct"), + "volume": raw.get("volume"), + # 基本面 + "pe": raw.get("pe"), + "total_market_cap": raw.get("total_market_cap"), + "high_52w": raw.get("high_52w"), + "low_52w": raw.get("low_52w"), + "turnover_rate": raw.get("turnover_rate"), + "amplitude": raw.get("amplitude"), + # 持仓 + "cost": port.get("cost", 0), + "shares": port.get("shares", 0), + "position_pct": port.get("position_pct", 0), + # 现策略 + "strategy": strategy, + } + # 浮亏% + cost = port.get("cost", 0) + price = raw.get("price", 0) + if cost > 0 and price > 0: + stock_entry["pnl_pct"] = round((price - cost) / cost * 100, 2) + else: + stock_entry["pnl_pct"] = None + + stocks.append(stock_entry) + + # 按行业分组统计 + sector_groups = {} + for s in stocks: + sector = s.get("sector", "未分类") + if sector not in sector_groups: + sector_groups[sector] = [] + sector_groups[sector].append({ + "code": s["code"], + "name": s["name"], + "change_pct": s["change_pct"], + "pnl_pct": s["pnl_pct"], + "type": s["type"], + }) + + # 汇总 + total = len(stocks) + up_count = sum(1 for s in stocks if s["change_pct"] is not None and s["change_pct"] > 0) + down_count = sum(1 for s in stocks if s["change_pct"] is not None and s["change_pct"] < 0) + deep_loss = sum(1 for s in stocks if s["pnl_pct"] is not None and s["pnl_pct"] < -20) + + output = { + "collected_at": datetime.now().isoformat(), + "total_stocks": total, + "summary": { + "up_count": up_count, + "down_count": down_count, + "deep_loss_count": deep_loss, + "holdings_count": len(portfolio_info), + "watchlist_count": total - len(portfolio_info), + }, + "index_data": index_data, + "sector_groups": sector_groups, + "stocks": stocks, + } + + save_json(OUTPUT_PATH, output) + print(f"数据收集完成: {total}只股票, {len(index_data)}个指数, {len(sector_groups)}个行业分组") + print(f" 上涨{up_count} 下跌{down_count} 深套{deep_loss}") + print(f" 输出: {OUTPUT_PATH}") + + +if __name__ == "__main__": + run() diff --git a/data/stocks/01478.json b/data/stocks/01478.json index adde0c8..78259ea 100644 --- a/data/stocks/01478.json +++ b/data/stocks/01478.json @@ -451,6 +451,11 @@ "content": "**港股概览:** 恒指+1.8%反弹,持仓分化。腾讯(00700)浮亏-4.6%目标已超但盈亏比偏低;丘钛(01478)深套-49.7%及万科(02202)深套-52.4%均暂远离止损(距12-22", "report_id": "cron_d42f2ce3b479_2026-06-29_20-08-09" }, + { + "time": "2026-07-04T08:56:00.135656", + "content": "③ 丘钛科技(01478) 距止损6.48仅+7.3%!仓位7.97%", + "report_id": "cron_d42f2ce3b479_2026-07-03_20-03-44" + }, { "time": "2026-06-01T10:25:54.503460", "content": "丘钛科技(01478) 仓位8.58% +4.10%→ 持有,走势健康", diff --git a/data/stocks/688981.json b/data/stocks/688981.json index 638ede0..bf3f274 100644 --- a/data/stocks/688981.json +++ b/data/stocks/688981.json @@ -146,6 +146,11 @@ "content": "- 中芯国际688981 151.0 | 买入区143.67~150.85 **(现价刚穿上沿)**", "report_id": "cron_d42f2ce3b479_2026-06-29_20-08-09" }, + { + "time": "2026-07-04T08:56:00.135656", + "content": "② 中芯国际A(688981) 已跌破止损位!仓位5.44%", + "report_id": "cron_d42f2ce3b479_2026-07-03_20-03-44" + }, { "time": "2026-06-11T08:55:23.441938", "content": "• 中芯国际(688981) 竞价125.00(-1.81%),策略买入区116~136内", diff --git a/mo_config.py b/mo_config.py index 79d1e60..1199092 100644 --- a/mo_config.py +++ b/mo_config.py @@ -62,6 +62,7 @@ class MoConfig: @property def live_prices_path(self) -> Path: + """⚠️ DEPRECATED: 实时价格已迁移到 mofin_db.live_prices 表。""" return self.data_dir / "live_prices.json" @property @@ -70,6 +71,7 @@ class MoConfig: @property def multi_tf_cache_path(self) -> Path: + """⚠️ DEPRECATED: 多周期缓存已迁移到 mofin_db.mtf_cache 表。""" return self.data_dir / "multi_tf_cache.json" @property diff --git a/scripts/branch_evaluator.py b/scripts/branch_evaluator.py index a712606..a6aa549 100644 --- a/scripts/branch_evaluator.py +++ b/scripts/branch_evaluator.py @@ -19,10 +19,6 @@ from datetime import datetime, date from mo_data import read_portfolio, read_decisions from mofin_db import get_conn, write_holding_strategy -# 路径 -DECISIONS_PATH = "/home/hmo/web-dashboard/data/decisions.json" -PORTFOLIO_PATH = "/home/hmo/web-dashboard/data/portfolio.json" - # 引入 strategy_tree 模块 sys.path.insert(0, "/home/hmo/MoFin") try: @@ -132,10 +128,6 @@ def evaluate_all(): conn.close() except Exception: pass - # [migrated to DB] — cold backup removed - # with open(DECISIONS_PATH, "w") as f: - # json.dump(data, f, indent=2, ensure_ascii=False) - # 输出摘要(空 = 静默) lines = [] init_note = f" | 自动初始化{auto_init_count}只" if auto_init_count else "" diff --git a/scripts/branch_scanner.py b/scripts/branch_scanner.py index 77f2f9b..62cacaf 100644 --- a/scripts/branch_scanner.py +++ b/scripts/branch_scanner.py @@ -20,7 +20,6 @@ from urllib.request import Request, urlopen from mo_data import read_decisions from mofin_db import get_conn, write_holding_strategy -DECISIONS_PATH = "/home/hmo/web-dashboard/data/decisions.json" SCANNER_STATE = "/home/hmo/web-dashboard/data/scanner_state.json" @@ -110,10 +109,6 @@ def main(): conn.close() except Exception: pass - # [migrated to DB] — cold backup removed - # with open(DECISIONS_PATH, "w") as f: - # json.dump(data, f, indent=2, ensure_ascii=False) - # 更新状态快照 state = {"scenario": sid, "updated_at": now.isoformat(), "branches": {}} for e in decisions: diff --git a/scripts/capital_flow_collector.py b/scripts/capital_flow_collector.py index 06a2a4a..2ba6e3d 100644 --- a/scripts/capital_flow_collector.py +++ b/scripts/capital_flow_collector.py @@ -15,7 +15,6 @@ from mo_data import read_portfolio, read_decisions, read_watchlist from mofin_db import get_conn, write_capital_flow_cache DATA_DIR = "/home/hmo/web-dashboard/data" -DECISIONS_PATH = f"{DATA_DIR}/decisions.json" CACHE_PATH = f"{DATA_DIR}/capital_flow_cache.json" UA = "Mozilla/5.0" diff --git a/scripts/data_governance.py b/scripts/data_governance.py index 91ab14f..894086a 100644 --- a/scripts/data_governance.py +++ b/scripts/data_governance.py @@ -1,94 +1,93 @@ -#!/usr/bin/env python3 -"""data_governance.py — MoFin 数据治理 (no_agent) - -1. holding_strategies 去重归档 -2. 检查缺失策略的持仓 -3. 报告数据健康状况 -""" - -import json, sqlite3 -from pathlib import Path -from datetime import datetime -from mo_data import read_portfolio, read_decisions, read_watchlist - -BASE = Path("/home/hmo/MoFin") -DATA = BASE / "data" -DB_PATH = DATA / "mofin.db" -DECISIONS_PATH = DATA / "decisions.json" - - -def clean_holding_strategies(conn): - """归档旧策略,只保留每只股票最新一条""" - codes = conn.execute( - "SELECT code, COUNT(*) as cnt, MAX(created_at) as latest " - "FROM holding_strategies GROUP BY code HAVING cnt > 1" - ).fetchall() - - total_archived = 0 - for code, cnt, latest in codes: - # 标记除了最新一条以外的所有记录为已归档 - conn.execute( - "UPDATE holding_strategies SET superseded_at=? " - "WHERE code=? AND created_at 0 and price > 0: - loss = (price - cost) / cost * 100 - if loss < -25: - deep.append((d.get("name",""), d["code"], loss, d.get("stop_loss",0))) - if deep: - print(f"\n🔴 {len(deep)}只深套(>-25%):") - for name, code, loss, sl in deep: - print(f" {name}({code}): {loss:.0f}% 止损={sl}") - else: - print("\n✅ 无深套持仓") - - conn.close() - - -if __name__ == "__main__": - main() +#!/usr/bin/env python3 +"""data_governance.py — MoFin 数据治理 (no_agent) + +1. holding_strategies 去重归档 +2. 检查缺失策略的持仓 +3. 报告数据健康状况 +""" + +import json, sqlite3 +from pathlib import Path +from datetime import datetime +from mo_data import read_portfolio, read_decisions, read_watchlist + +BASE = Path("/home/hmo/MoFin") +DATA = BASE / "data" +DB_PATH = DATA / "mofin.db" + + +def clean_holding_strategies(conn): + """归档旧策略,只保留每只股票最新一条""" + codes = conn.execute( + "SELECT code, COUNT(*) as cnt, MAX(created_at) as latest " + "FROM holding_strategies GROUP BY code HAVING cnt > 1" + ).fetchall() + + total_archived = 0 + for code, cnt, latest in codes: + # 标记除了最新一条以外的所有记录为已归档 + conn.execute( + "UPDATE holding_strategies SET superseded_at=? " + "WHERE code=? AND created_at 0 and price > 0: + loss = (price - cost) / cost * 100 + if loss < -25: + deep.append((d.get("name",""), d["code"], loss, d.get("stop_loss",0))) + if deep: + print(f"\n🔴 {len(deep)}只深套(>-25%):") + for name, code, loss, sl in deep: + print(f" {name}({code}): {loss:.0f}% 止损={sl}") + else: + print("\n✅ 无深套持仓") + + conn.close() + + +if __name__ == "__main__": + main() diff --git a/scripts/data_validate.py b/scripts/data_validate.py index d613ce0..d732026 100644 --- a/scripts/data_validate.py +++ b/scripts/data_validate.py @@ -1,90 +1,88 @@ -#!/usr/bin/env python3 -"""data_validate.py — 数据自检,在所有报告产出前执行 - -检查清单: -1. 总资产 = 市值 + 现金 (误差 < 1%) -2. 持仓 vs 决策交叉检查 -3. 币种一致性(港股必须currency=HKD) -4. 数据时效:portfolio.json/decisions.json 今日已更新 - -返回值:通过→退出码0,输出"OK"。失败→退出码1,输出问题描述。 -""" - -import json, sys -from datetime import datetime, timezone -from mo_data import read_portfolio, read_decisions, read_watchlist - -DATA_DIR = "/home/hmo/web-dashboard/data" -PORTFOLIO_PATH = f"{DATA_DIR}/portfolio.json" -DECISIONS_PATH = f"{DATA_DIR}/decisions.json" -STALE_REPORT = f"{DATA_DIR}/strategy_staleness_report.json" - -issues = [] - -# ── 1. 总资产校验 ──────────────────────────────────────────── -try: - pf = mo_data.read_portfolio() - mv_calc = sum(h["shares"] * h["price"] for h in pf.get("holdings", []) if h.get("price")) - stored_ta = pf.get("total_assets", 0) - cash = pf.get("cash", 0) - - expected_ta = round(mv_calc + cash, 2) - if stored_ta > 0 and abs(stored_ta - expected_ta) / stored_ta > 0.01: - issues.append(f"总资产不匹配: 存储{stored_ta} ≠ 计算{expected_ta} (市值{mv_calc}+现金{cash})") -except Exception as e: - issues.append(f"portfolio.json读取失败: {e}") - -# ── 2. 持仓 vs 决策交叉检查 ────────────────────────────────── -try: - dec = mo_data.read_decisions() - dec_codes = {} - for d in dec.get("decisions", []): - dec_codes[d["code"]] = d - - for h in pf.get("holdings", []): - code = h["code"] - if code not in dec_codes: - issues.append(f"持仓{code}({h.get('name','?')}) 在decisions.json中无对应决策") - - for code, d in dec_codes.items(): - if d.get("status") == "active" and d.get("type") == "持仓策略": - if not any(h["code"] == code for h in pf.get("holdings", [])): - issues.append(f"决策{code}({d.get('name','?')})标记持仓但portfolio.json中无此股") -except Exception as e: - issues.append(f"决策检查失败: {e}") - -# ── 3. 币种一致性 ──────────────────────────────────────────── -try: - for d in dec.get("decisions", []): - code = str(d.get("code", "")) - # 港股必须标记currency - if len(code) == 5 and code[0] in ("0", "1"): - cur = d.get("currency", "") - if cur not in ("HKD", "CNY"): - issues.append(f"港股{code}({d.get('name','?')}) 缺currency标记,不可靠") - # 如果标记了HKD,stop_loss也应该是合理的HKD价(>10) - sl = d.get("stop_loss", 0) - if cur == "HKD" and sl > 0 and sl < 1: - issues.append(f"港股{code} currency=HKD但stop_loss={sl} 异常低") -except Exception as e: - issues.append(f"币种检查失败: {e}") - -# ── 4. 数据时效 ────────────────────────────────────────────── -today = datetime.now().strftime("%Y-%m-%d") -try: - if pf.get("updated_at", "").startswith(today): - pass # OK - else: - issues.append(f"portfolio.json updated_at={pf.get('updated_at','?')} 不是今日") -except: - pass - -# ── 输出 ────────────────────────────────────────────────────── -if issues: - print("DATA_VALIDATE_FAIL") - for i in issues: - print(f" ⚠️ {i}") - sys.exit(1) -else: - print("DATA_VALIDATE_OK") - sys.exit(0) +#!/usr/bin/env python3 +"""data_validate.py — 数据自检,在所有报告产出前执行 + +检查清单: +1. 总资产 = 市值 + 现金 (误差 < 1%) +2. 持仓 vs 决策交叉检查 +3. 币种一致性(港股必须currency=HKD) +4. 数据时效:portfolio.json/decisions.json 今日已更新 + +返回值:通过→退出码0,输出"OK"。失败→退出码1,输出问题描述。 +""" + +import json, sys +from datetime import datetime, timezone +from mo_data import read_portfolio, read_decisions, read_watchlist + +DATA_DIR = "/home/hmo/web-dashboard/data" +STALE_REPORT = f"{DATA_DIR}/strategy_staleness_report.json" + +issues = [] + +# ── 1. 总资产校验 ──────────────────────────────────────────── +try: + pf = mo_data.read_portfolio() + mv_calc = sum(h["shares"] * h["price"] for h in pf.get("holdings", []) if h.get("price")) + stored_ta = pf.get("total_assets", 0) + cash = pf.get("cash", 0) + + expected_ta = round(mv_calc + cash, 2) + if stored_ta > 0 and abs(stored_ta - expected_ta) / stored_ta > 0.01: + issues.append(f"总资产不匹配: 存储{stored_ta} ≠ 计算{expected_ta} (市值{mv_calc}+现金{cash})") +except Exception as e: + issues.append(f"portfolio.json读取失败: {e}") + +# ── 2. 持仓 vs 决策交叉检查 ────────────────────────────────── +try: + dec = mo_data.read_decisions() + dec_codes = {} + for d in dec.get("decisions", []): + dec_codes[d["code"]] = d + + for h in pf.get("holdings", []): + code = h["code"] + if code not in dec_codes: + issues.append(f"持仓{code}({h.get('name','?')}) 在decisions.json中无对应决策") + + for code, d in dec_codes.items(): + if d.get("status") == "active" and d.get("type") == "持仓策略": + if not any(h["code"] == code for h in pf.get("holdings", [])): + issues.append(f"决策{code}({d.get('name','?')})标记持仓但portfolio.json中无此股") +except Exception as e: + issues.append(f"决策检查失败: {e}") + +# ── 3. 币种一致性 ──────────────────────────────────────────── +try: + for d in dec.get("decisions", []): + code = str(d.get("code", "")) + # 港股必须标记currency + if len(code) == 5 and code[0] in ("0", "1"): + cur = d.get("currency", "") + if cur not in ("HKD", "CNY"): + issues.append(f"港股{code}({d.get('name','?')}) 缺currency标记,不可靠") + # 如果标记了HKD,stop_loss也应该是合理的HKD价(>10) + sl = d.get("stop_loss", 0) + if cur == "HKD" and sl > 0 and sl < 1: + issues.append(f"港股{code} currency=HKD但stop_loss={sl} 异常低") +except Exception as e: + issues.append(f"币种检查失败: {e}") + +# ── 4. 数据时效 ────────────────────────────────────────────── +today = datetime.now().strftime("%Y-%m-%d") +try: + if pf.get("updated_at", "").startswith(today): + pass # OK + else: + issues.append(f"portfolio.json updated_at={pf.get('updated_at','?')} 不是今日") +except: + pass + +# ── 输出 ────────────────────────────────────────────────────── +if issues: + print("DATA_VALIDATE_FAIL") + for i in issues: + print(f" ⚠️ {i}") + sys.exit(1) +else: + print("DATA_VALIDATE_OK") + sys.exit(0) diff --git a/scripts/fix_trigger.py b/scripts/fix_trigger.py index 4437ee6..54e4aad 100644 --- a/scripts/fix_trigger.py +++ b/scripts/fix_trigger.py @@ -5,9 +5,6 @@ import json, sys, os from mo_data import read_portfolio, read_decisions, read_watchlist from mofin_db import get_conn, write_holding_strategy -DECISIONS_PATH = "/home/hmo/web-dashboard/data/decisions.json" -DECISIONS_BAK = DECISIONS_PATH + ".bak" - try: dec = read_decisions() except Exception as e: @@ -40,18 +37,9 @@ for d in dec.get("decisions", []): if new_trig: print(f" {code} {name}: trigger={new_trig}") -# 备份 -os.makedirs(os.path.dirname(DECISIONS_BAK), exist_ok=True) -with open(DECISIONS_BAK, 'w') as f: - json.dump(mo_data.read_decisions(), f, indent=2, ensure_ascii=False) - # DB 写入(替代 json.dump) conn = get_conn() for d in dec.get("decisions", []): write_holding_strategy(conn, d.get("code", ""), d.get("name", ""), d) conn.close() -# [migrated to DB] — cold backup removed -# with open(DECISIONS_PATH, 'w') as f: -# json.dump(dec, f, indent=2, ensure_ascii=False) - print(f"\n共{count}只,已更新trigger字段") diff --git a/scripts/import_holding_xls.py b/scripts/import_holding_xls.py index cc7f09f..ef311b5 100755 --- a/scripts/import_holding_xls.py +++ b/scripts/import_holding_xls.py @@ -19,7 +19,6 @@ from mo_data import read_decisions from mofin_db import get_conn, write_holdings_batch, write_portfolio_summary, write_holding_strategy STOCKS_FILE = "/home/hmo/stocks/holding.xls" -PORTFOLIO_PATH = "/home/hmo/web-dashboard/data/portfolio.json" DB_PATH = "/home/hmo/web-dashboard/data/mofin.db" @@ -151,9 +150,6 @@ def main(): 'updated_at': datetime.now().strftime('%Y-%m-%d %H:%M'), 'source': STOCKS_FILE, } - # [migrated to DB] — cold backup removed; DB writes below - # with open(PORTFOLIO_PATH, 'w') as f: - # json.dump(portfolio, f, indent=2, ensure_ascii=False) # DB 写入 try: conn = get_conn() diff --git a/scripts/intraday_health_check.py b/scripts/intraday_health_check.py index 5ea69f9..3d171f9 100644 --- a/scripts/intraday_health_check.py +++ b/scripts/intraday_health_check.py @@ -1,261 +1,258 @@ -#!/usr/bin/env python3 -"""intraday_health_check.py — 盘中高频轻量自检 (no_agent) - -每15分钟检查最关键的活动组件,只查会直接影响盘中运行的。 -发现问题→写TODO(消费管道与每日体检共享)。 -""" - -import json, os, sqlite3, subprocess, urllib.request, sys, socket -from pathlib import Path -from datetime import datetime, timedelta - -# ── MoFin path ───────────────────────────────────────────────────── -sys.path.insert(0, "/home/hmo/MoFin") -from mo_data import read_portfolio, read_decisions, read_watchlist - -BASE = Path("/home/hmo/MoFin") -DATA = BASE / "data" -DB_PATH = DATA / "mofin.db" -CRON_JOBS = Path("/home/hmo/.hermes/profiles/position-analyst/cron/jobs.json") -GATEWAY_URL = "http://localhost:8643/v1/chat/completions" -GATEWAY_KEY = "hermes123" - -ISSUES = [] -OK_COUNT = 0 - - -def log(ok, msg): - global OK_COUNT - if ok: - OK_COUNT += 1 - else: - ISSUES.append(msg) - - -def check_port(port): - try: - r = subprocess.run(["ss", "-tlnp"], capture_output=True, text=True, timeout=5) - return f":{port}" in r.stdout - except: - return False - - -def check_http(url, timeout=5): - """检查HTTP可达性,5秒超时防止hang住""" - try: - for k in list(os.environ.keys()): - if 'proxy' in k.lower(): - os.environ.pop(k) - req = urllib.request.Request(url, method="GET") - urllib.request.urlopen(req, timeout=timeout) - return True - except: - return False - - -def db_today_count(table, date_col): - today = datetime.now().strftime("%Y-%m-%d") - try: - conn = sqlite3.connect(str(DB_PATH)) - r = conn.execute(f"SELECT COUNT(*) FROM {table} WHERE date({date_col}) = ?", (today,)).fetchone() - conn.close() - return r[0] - except: - return -1 - - -def check_xiaoguo(): - """小果管道:进程/scanner有数据/API可达(降级不报错)""" - # 进程 — 不一定有常驻进程(no_agent cron模式) - # 数据 — 今日有扫描记录 - scans_today = db_today_count("xiaoguo_scan_tracker", "last_scanned_at") - if scans_today <= 0: - # 可能是小果离线了,不报严重,记录即可 - return - # API — 用socket快速检测可达性(3s超时) - try: - s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - s.settimeout(3) - s.connect(("node122", 18003)) - s.close() - except: - pass - - -PORTFOLIO_PATH = str(DATA / "portfolio.json") - - -def check_price_monitor(): - """价格监控:检查price_monitor cron最近是否运行 + 数据是否更新 - - 注意:price_events 存储的是区间偏离事件(价格穿过买入区/止损/止盈边界), - 不是心跳信号。横盘期/无操作信号时自然不会有新事件。因此不检查event数, - 改为检查 cron 最后运行时间和 portfolio.json 数据新鲜度。 - """ - # 检查cron最近运行记录 - cron_ok = False - try: - with open(str(CRON_JOBS)) as f: - data = json.load(f) - jobs_list = data.get("jobs", []) if isinstance(data.get("jobs"), list) else [] - if not jobs_list: - jobs_list = list(data.get("jobs", {}).values()) - for job in jobs_list: - if not job: - continue - script = job.get("script") or "" - name = job.get("name") or "" - if "price_monitor" in script or "价格监控" in name: - last_run = job.get("last_run_at") - if last_run: - last_dt = datetime.fromisoformat(last_run) - # 兼容带时区和无时区两种格式 - ref_now = datetime.now(last_dt.tzinfo) if last_dt.tzinfo else datetime.now() - elapsed = (ref_now - last_dt).total_seconds() - if elapsed < 600: # 10分钟内运行过 - cron_ok = True - break - except Exception: - pass - - if not cron_ok: - log(False, "价格监控cron无最近运行记录(>10分钟未运行)") - return - - # 检查portfolio.json数据新鲜度 - # 兼容 '2026-07-02 10:43'(price_monitor写入,无秒)和 '2026-07-02 10:43:53'(DB写入,有秒) - def _parse_updated_at(ts: str) -> datetime | None: - for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"): - try: - return datetime.strptime(ts, fmt) - except ValueError: - continue - return None - - try: - pf = read_portfolio() - pf_updated = pf.get("updated_at", "") - if pf_updated: - pf_dt = _parse_updated_at(pf_updated) - if pf_dt is None: - log(False, f"价格数据updated_at格式无法解析: {pf_updated}") - else: - seconds_ago = (datetime.now() - pf_dt).total_seconds() - if seconds_ago < 600: # 10分钟内 - log(True, f"价格监控运行正常,数据{int(seconds_ago//60)}分钟前更新") - else: - log(False, f"价格数据{int(seconds_ago)}秒未更新(portfolio.json)") - else: - log(False, "portfolio.json缺少updated_at字段") - except Exception as e: - log(False, f"价格数据新鲜度检查失败: {e}") - - -def check_bots(): - zhiwei = subprocess.run(["systemctl", "is-active", "xmpp-zhiwei.service"], - capture_output=True, text=True, timeout=5).stdout.strip() == "active" - xiaoguo = subprocess.run(["systemctl", "is-active", "xmpp-xiaoguo.service"], - capture_output=True, text=True, timeout=5).stdout.strip() == "active" - log(zhiwei, "知微XMPP Bot离线") - log(xiaoguo, "小果XMPP Bot离线") - - -def check_gateways(): - log(check_port(8643), "知微Gateway :8643 未监听") - log(check_port(8645), "小果Gateway :8645 未监听") - - -def check_signal_pipeline(): - """信号从xiaoguo_scanner→signal_news→consumer是否通畅""" - unproc = 0 - try: - conn = sqlite3.connect(str(DB_PATH)) - r = conn.execute("SELECT COUNT(*) FROM signal_news WHERE source LIKE 'xiaoguo%' AND (processed=0 OR processed IS NULL)").fetchone() - unproc = r[0] - conn.close() - except: - pass - log(unproc < 30, f"信号堆积: {unproc}条未处理(需<30)") - - # 宏观风险状态检查 - try: - risk_path = DATA / "macro_risk_state.json" - if risk_path.exists(): - risk = json.loads(risk_path.read_text()) - level = risk.get("level", "none") - expired = risk.get("expired", False) - # 提取摘要做原因描述(state.json用signals数组,不是reason字段) - signals = risk.get("signals", []) - reason = "" - if signals and isinstance(signals, list) and len(signals) > 0: - first_sig = signals[0] - summary = first_sig.get("summary", "") - if summary: - reason = summary[:80].replace("\n", " ") - if level == "high" and not expired: - reason_clean = reason.replace("【高风险】", "").strip()[:60] - log(False, f"🔴 宏观风险HIGH: {reason_clean}") - elif level == "high" and expired: - log(True, f"⏳ 宏观风险HIGH已过期(无新信号超过15分钟)") - elif level == "medium": - log(True, f"⚠️ 宏观风险MEDIUM: {reason}") - else: - log(True, "无宏观风险状态文件(可能未生成)") - except: - pass - - -def write_todos(): - if not ISSUES: - return - for msg in ISSUES: - title = f"[盘中自检] {msg}" - try: - conn = sqlite3.connect(str(DB_PATH), timeout=10) - conn.execute("PRAGMA busy_timeout=5000") - # 宏观风险HIGH去重:只要有pending/in_progress的宏观风险TODO,不再新增 - if "宏观风险HIGH" in msg: - exist = conn.execute( - "SELECT id FROM todos WHERE title LIKE '%宏观风险HIGH%' AND status IN ('pending','in_progress') LIMIT 1" - ).fetchone() - else: - exist = conn.execute( - "SELECT id FROM todos WHERE title=? AND status IN ('pending','in_progress')", (title,) - ).fetchone() - if not exist: - conn.execute( - "INSERT INTO todos (title, description, priority, source, status, fix_action) " - "VALUES (?, ?, 'high', 'intraday_check', 'pending', NULL)", - (title, f"盘中自动发现: {msg}")) - conn.commit() - conn.close() - except: - pass - - -def main(): - now = datetime.now() - # 只在交易时段运行 - if now.weekday() >= 5 or now.hour < 9 or now.hour >= 15: - print("[SILENT] 非交易时段") - return - - check_bots() - check_gateways() - check_xiaoguo() - if 9 <= now.hour < 16: - check_price_monitor() - check_signal_pipeline() - - write_todos() - - if ISSUES: - print(f"盘中自检 | {now.strftime('%H:%M')} | {len(ISSUES)}项异常:") - for i in ISSUES: - print(f" ⚠️ {i}") - else: - print(f"[SILENT] 盘中自检通过 | {OK_COUNT}项正常") - - -if __name__ == "__main__": - main() +#!/usr/bin/env python3 +"""intraday_health_check.py — 盘中高频轻量自检 (no_agent) + +每15分钟检查最关键的活动组件,只查会直接影响盘中运行的。 +发现问题→写TODO(消费管道与每日体检共享)。 +""" + +import json, os, sqlite3, subprocess, urllib.request, sys, socket +from pathlib import Path +from datetime import datetime, timedelta + +# ── MoFin path ───────────────────────────────────────────────────── +sys.path.insert(0, "/home/hmo/MoFin") +from mo_data import read_portfolio, read_decisions, read_watchlist + +BASE = Path("/home/hmo/MoFin") +DATA = BASE / "data" +DB_PATH = DATA / "mofin.db" +CRON_JOBS = Path("/home/hmo/.hermes/profiles/position-analyst/cron/jobs.json") +GATEWAY_URL = "http://localhost:8643/v1/chat/completions" +GATEWAY_KEY = "hermes123" + +ISSUES = [] +OK_COUNT = 0 + + +def log(ok, msg): + global OK_COUNT + if ok: + OK_COUNT += 1 + else: + ISSUES.append(msg) + + +def check_port(port): + try: + r = subprocess.run(["ss", "-tlnp"], capture_output=True, text=True, timeout=5) + return f":{port}" in r.stdout + except: + return False + + +def check_http(url, timeout=5): + """检查HTTP可达性,5秒超时防止hang住""" + try: + for k in list(os.environ.keys()): + if 'proxy' in k.lower(): + os.environ.pop(k) + req = urllib.request.Request(url, method="GET") + urllib.request.urlopen(req, timeout=timeout) + return True + except: + return False + + +def db_today_count(table, date_col): + today = datetime.now().strftime("%Y-%m-%d") + try: + conn = sqlite3.connect(str(DB_PATH)) + r = conn.execute(f"SELECT COUNT(*) FROM {table} WHERE date({date_col}) = ?", (today,)).fetchone() + conn.close() + return r[0] + except: + return -1 + + +def check_xiaoguo(): + """小果管道:进程/scanner有数据/API可达(降级不报错)""" + # 进程 — 不一定有常驻进程(no_agent cron模式) + # 数据 — 今日有扫描记录 + scans_today = db_today_count("xiaoguo_scan_tracker", "last_scanned_at") + if scans_today <= 0: + # 可能是小果离线了,不报严重,记录即可 + return + # API — 用socket快速检测可达性(3s超时) + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(3) + s.connect(("node122", 18003)) + s.close() + except: + pass + + +def check_price_monitor(): + """价格监控:检查price_monitor cron最近是否运行 + 数据是否更新 + + 注意:price_events 存储的是区间偏离事件(价格穿过买入区/止损/止盈边界), + 不是心跳信号。横盘期/无操作信号时自然不会有新事件。因此不检查event数, + 改为检查 cron 最后运行时间和 portfolio.json 数据新鲜度。 + """ + # 检查cron最近运行记录 + cron_ok = False + try: + with open(str(CRON_JOBS)) as f: + data = json.load(f) + jobs_list = data.get("jobs", []) if isinstance(data.get("jobs"), list) else [] + if not jobs_list: + jobs_list = list(data.get("jobs", {}).values()) + for job in jobs_list: + if not job: + continue + script = job.get("script") or "" + name = job.get("name") or "" + if "price_monitor" in script or "价格监控" in name: + last_run = job.get("last_run_at") + if last_run: + last_dt = datetime.fromisoformat(last_run) + # 兼容带时区和无时区两种格式 + ref_now = datetime.now(last_dt.tzinfo) if last_dt.tzinfo else datetime.now() + elapsed = (ref_now - last_dt).total_seconds() + if elapsed < 600: # 10分钟内运行过 + cron_ok = True + break + except Exception: + pass + + if not cron_ok: + log(False, "价格监控cron无最近运行记录(>10分钟未运行)") + return + + # 检查portfolio.json数据新鲜度 + # 兼容 '2026-07-02 10:43'(price_monitor写入,无秒)和 '2026-07-02 10:43:53'(DB写入,有秒) + def _parse_updated_at(ts: str) -> datetime | None: + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"): + try: + return datetime.strptime(ts, fmt) + except ValueError: + continue + return None + + try: + pf = read_portfolio() + pf_updated = pf.get("updated_at", "") + if pf_updated: + pf_dt = _parse_updated_at(pf_updated) + if pf_dt is None: + log(False, f"价格数据updated_at格式无法解析: {pf_updated}") + else: + seconds_ago = (datetime.now() - pf_dt).total_seconds() + if seconds_ago < 600: # 10分钟内 + log(True, f"价格监控运行正常,数据{int(seconds_ago//60)}分钟前更新") + else: + log(False, f"价格数据{int(seconds_ago)}秒未更新(portfolio.json)") + else: + log(False, "portfolio.json缺少updated_at字段") + except Exception as e: + log(False, f"价格数据新鲜度检查失败: {e}") + + +def check_bots(): + zhiwei = subprocess.run(["systemctl", "is-active", "xmpp-zhiwei.service"], + capture_output=True, text=True, timeout=5).stdout.strip() == "active" + xiaoguo = subprocess.run(["systemctl", "is-active", "xmpp-xiaoguo.service"], + capture_output=True, text=True, timeout=5).stdout.strip() == "active" + log(zhiwei, "知微XMPP Bot离线") + log(xiaoguo, "小果XMPP Bot离线") + + +def check_gateways(): + log(check_port(8643), "知微Gateway :8643 未监听") + log(check_port(8645), "小果Gateway :8645 未监听") + + +def check_signal_pipeline(): + """信号从xiaoguo_scanner→signal_news→consumer是否通畅""" + unproc = 0 + try: + conn = sqlite3.connect(str(DB_PATH)) + r = conn.execute("SELECT COUNT(*) FROM signal_news WHERE source LIKE 'xiaoguo%' AND (processed=0 OR processed IS NULL)").fetchone() + unproc = r[0] + conn.close() + except: + pass + log(unproc < 30, f"信号堆积: {unproc}条未处理(需<30)") + + # 宏观风险状态检查 + try: + risk_path = DATA / "macro_risk_state.json" + if risk_path.exists(): + risk = json.loads(risk_path.read_text()) + level = risk.get("level", "none") + expired = risk.get("expired", False) + # 提取摘要做原因描述(state.json用signals数组,不是reason字段) + signals = risk.get("signals", []) + reason = "" + if signals and isinstance(signals, list) and len(signals) > 0: + first_sig = signals[0] + summary = first_sig.get("summary", "") + if summary: + reason = summary[:80].replace("\n", " ") + if level == "high" and not expired: + reason_clean = reason.replace("【高风险】", "").strip()[:60] + log(False, f"🔴 宏观风险HIGH: {reason_clean}") + elif level == "high" and expired: + log(True, f"⏳ 宏观风险HIGH已过期(无新信号超过15分钟)") + elif level == "medium": + log(True, f"⚠️ 宏观风险MEDIUM: {reason}") + else: + log(True, "无宏观风险状态文件(可能未生成)") + except: + pass + + +def write_todos(): + if not ISSUES: + return + for msg in ISSUES: + title = f"[盘中自检] {msg}" + try: + conn = sqlite3.connect(str(DB_PATH), timeout=10) + conn.execute("PRAGMA busy_timeout=5000") + # 宏观风险HIGH去重:只要有pending/in_progress的宏观风险TODO,不再新增 + if "宏观风险HIGH" in msg: + exist = conn.execute( + "SELECT id FROM todos WHERE title LIKE '%宏观风险HIGH%' AND status IN ('pending','in_progress') LIMIT 1" + ).fetchone() + else: + exist = conn.execute( + "SELECT id FROM todos WHERE title=? AND status IN ('pending','in_progress')", (title,) + ).fetchone() + if not exist: + conn.execute( + "INSERT INTO todos (title, description, priority, source, status, fix_action) " + "VALUES (?, ?, 'high', 'intraday_check', 'pending', NULL)", + (title, f"盘中自动发现: {msg}")) + conn.commit() + conn.close() + except: + pass + + +def main(): + now = datetime.now() + # 只在交易时段运行 + if now.weekday() >= 5 or now.hour < 9 or now.hour >= 15: + print("[SILENT] 非交易时段") + return + + check_bots() + check_gateways() + check_xiaoguo() + if 9 <= now.hour < 16: + check_price_monitor() + check_signal_pipeline() + + write_todos() + + if ISSUES: + print(f"盘中自检 | {now.strftime('%H:%M')} | {len(ISSUES)}项异常:") + for i in ISSUES: + print(f" ⚠️ {i}") + else: + print(f"[SILENT] 盘中自检通过 | {OK_COUNT}项正常") + + +if __name__ == "__main__": + main() diff --git a/scripts/price_monitor.py b/scripts/price_monitor.py deleted file mode 100644 index 34b5901..0000000 --- a/scripts/price_monitor.py +++ /dev/null @@ -1,806 +0,0 @@ -#!/usr/bin/env python3 -"""price_monitor.py — 高频价格监控脚本(批量版) -规则:进入区间报一次,离开区间报一次,中间不重复。 -每次运行时一次性刷新所有持仓+自选股的实时价。 -""" -import json -import urllib.request -import os -import sys -import time -from datetime import datetime - -# ── MoFin unified model ────────────────────────────────────────────── -sys.path.insert(0, "/home/hmo/MoFin") -from mo_models import is_hk_stock, get_hk_rate, calc_total_assets, calc_total_mv, calc_position_pct -from mofin_db import get_conn, write_holdings_batch, write_portfolio_summary, write_price_event, write_watchlist_stock, write_holding_strategy, write_holding_strategy -from mo_data import read_portfolio, read_decisions, read_watchlist - -DECISIONS_PATH = "/home/hmo/web-dashboard/data/decisions.json" -PORTFOLIO_PATH = "/home/hmo/web-dashboard/data/portfolio.json" -WATCHLIST_PATH = "/home/hmo/web-dashboard/data/watchlist.json" -BREACH_PATH = "/home/hmo/.hermes/zone_breach.json" -STATE_PATH = os.path.expanduser("~/.hermes/price_trigger_state.json") -EVENTS_PATH = "/home/hmo/web-dashboard/data/price_events.json" - -# 策略重评依赖(技术面驱动,非机械百分比) -sys.path.insert(0, "/home/hmo/web-dashboard") -try: - from strategy_lifecycle import reassess_strategy - HAS_REASSESS = True -except ImportError: - HAS_REASSESS = False - -try: - HK_RATE = get_hk_rate() -except Exception: - HK_RATE = 0.87 # ultimate fallback - -# 分支系统与情景检测 -try: - sys.path.insert(0, '/home/hmo/MoFin') - from strategy_tree import detect_scenario, evaluate_branches - HAS_TREE = True -except Exception: - HAS_TREE = False - def detect_scenario(): return {} - def evaluate_branches(*a, **kw): return [] - -# 情景缓存(每次run_once刷新) -_SCENARIO_CACHE = {} -_BRANCH_CACHE = {} # code -> branches list - -UA = "Mozilla/5.0" - -# ── 批量拉取价格 ────────────────────────────────────────────────────────── - -def fetch_all_prices(codes): - """腾讯批量行情API:仅用于A股(沪市/深市) - A股:sh600110 / sz000001 - 港股已迁移至 fetch_hk_eastmoney()(东方财富实时行情) - 返回 {code: (price, change, change_pct)} - """ - if not codes: - return {} - - # 只处理A股(6位代码),港股走东方财富 - a_codes = [c for c in codes if len(str(c).strip()) == 6] - if not a_codes: - return {} - - symbols = [] - code_map = {} - for code in a_codes: - code_s = str(code).strip() - if code_s.startswith(('5', '6', '9')): - sym = f"sh{code_s}" - else: - sym = f"sz{code_s}" - symbols.append(sym) - code_map[sym] = code_s - - url = f"http://qt.gtimg.cn/q={','.join(symbols)}" - try: - req = urllib.request.Request(url, headers={"User-Agent": UA}) - with urllib.request.urlopen(req, timeout=10) as r: - text = r.read().decode("gbk") - except Exception as e: - print(f"⚠️ 腾讯A股拉取失败: {e}", file=sys.stderr) - return {} - - results = {} - for line in text.strip().split("\n"): - line = line.strip() - if not line or "=" not in line: - continue - try: - raw_value = line.split("=", 1)[1].strip().strip('"').strip(";") - fields = raw_value.split("~") - if len(fields) < 6: - continue - sym = line.split("=", 1)[0].strip().lstrip("v_") - orig_code = code_map.get(sym) - if not orig_code: - continue - price = float(fields[3]) if fields[3] else 0 - prev_close = float(fields[4]) if fields[4] else 0 - change = price - prev_close if prev_close > 0 else 0 - change_pct = fields[32] if len(fields) > 32 and fields[32] else "0" - results[orig_code] = (price, change, change_pct) - except (ValueError, IndexError): - continue - - return results - - -# ── 港股实时行情(新浪财经批量版,实时,无延迟)───────────────────────────── - -def fetch_hk_sina_batch(codes): - """新浪财经港股批量实时行情 — 一次HTTP请求获取全部港股。 - - 新浪港股API(hq.sinajs.cn)支持批量查询,返回实时数据。 - 对比东财逐股查询(0.2s间隔×17只=3.4s),新浪1次请求搞定。 - - API: https://hq.sinajs.cn/list=hk00700,hk09988 - 格式: hq_str_hk00700="TENCENT,腾讯控股,当前价,昨收,开盘,最高,最低,涨跌额,涨跌幅,..." - - 返回 {code: (price, change, change_pct)} - """ - if not codes: - return {} - - hk_codes = [str(c).strip() for c in codes if len(str(c).strip()) <= 5] - if not hk_codes: - return {} - - symbols = [f"hk{c}" for c in hk_codes] - url = f"https://hq.sinajs.cn/list={','.join(symbols)}" - - try: - # 新浪要求有 Referer,且需绕过系统代理(某些环境下东财/新浪走代理会断连) - proxy_handler = urllib.request.ProxyHandler({}) - opener = urllib.request.build_opener(proxy_handler) - req = urllib.request.Request(url, headers={ - "User-Agent": "Mozilla/5.0", - "Referer": "https://finance.sina.com.cn", - }) - with opener.open(req, timeout=10) as r: - text = r.read().decode("gbk") - except Exception as e: - print(f"⚠️ 新浪港股批量拉取失败: {e}", file=sys.stderr) - return {} - - results = {} - for line in text.strip().split("\n"): - line = line.strip() - if "=" not in line: - continue - try: - code = line.split("=", 1)[0].replace("hq_str_hk", "").replace("var ", "").strip() - raw = line.split("=", 1)[1].strip().strip('"').strip(";") - fields = raw.split(",") - if len(fields) < 9: - continue - price = float(fields[2]) if fields[2] else 0 - prev_close = float(fields[3]) if fields[3] else 0 - change_amt = float(fields[7]) if fields[7] else 0 - change_pct = fields[8] if fields[8] else "0" - # 新浪 field[2] 可能非实时最新价,用 prev_close + change 计算更准确 - if prev_close > 0 and abs(change_amt) > 0: - price = round(prev_close + change_amt, 2) - change = round(change_amt, 2) - if price > 0: - results[code] = (price, change, change_pct) - except (ValueError, IndexError): - continue - - return results - - -# ── 港股备用通道(东方财富逐股 + 腾讯15min延迟)─────────────────────────── - -def fetch_hk_eastmoney_fallback(codes): - """东方财富港股实时行情(备用通道),逐股查询、间隔1秒避免限流。 - - FTP 说明:港股限流严重,不适合主通道,降级为备用。 - 建议用上面的 fetch_hk_sina_batch() 做主通道。 - - 返回 {code: (price, change, change_pct)} - Fallback: 仍失败时回退到腾讯 qt.gtimg.cn(15分钟延迟) - """ - if not codes: - return {} - - hk_codes = [str(c).strip() for c in codes if len(str(c).strip()) <= 5] - if not hk_codes: - return {} - - results = {} - - # 东方财富逐股查询,1秒间隔避免限流 - for code in hk_codes: - try: - url = (f"https://push2.eastmoney.com/api/qt/stock/get" - f"?secid=116.{code}" - f"&fields=f43,f170,f60,f57,f58" - f"&fltt=2") - proxy_handler = urllib.request.ProxyHandler({}) - opener = urllib.request.build_opener(proxy_handler) - req = urllib.request.Request(url, headers={ - "User-Agent": UA, - "Referer": "https://quote.eastmoney.com/", - }) - with opener.open(req, timeout=5) as r: - resp = json.loads(r.read().decode("utf-8")) - - if resp.get("rc") != 0: - continue - item = resp.get("data", {}) - if not item: - continue - price = float(item.get("f43", 0)) if item.get("f43") else 0 - prev_close = float(item.get("f60", 0)) if item.get("f60") else 0 - change = round(price - prev_close, 2) if prev_close > 0 else 0 - change_pct = str(item.get("f170", "0")) - if price > 0: - results[code] = (price, change, change_pct) - time.sleep(1.0) # 1秒间隔,大幅降低限流概率 - except Exception as e: - print(f" [东财备用 {code}] {e}", file=sys.stderr) - continue - - # Fallback: 腾讯 qt.gtimg.cn(15分钟延迟) - missing = [c for c in hk_codes if c not in results] - if missing: - try: - fallback = _fetch_hk_tencent_fallback(missing) - results.update(fallback) - except Exception: - pass - - return results - - -def _fetch_hk_tencent_fallback(codes): - """腾讯港股行情(15分钟延迟,仅作 fallback)""" - symbols = [f"hk{c}" for c in codes] - url = f"http://qt.gtimg.cn/q={','.join(symbols)}" - req = urllib.request.Request(url, headers={"User-Agent": UA}) - with urllib.request.urlopen(req, timeout=10) as r: - text = r.read().decode("gbk") - - code_map = {f"hk{c}": c for c in codes} - results = {} - for line in text.strip().split("\n"): - if "=" not in line: - continue - try: - raw = line.split("=", 1)[1].strip().strip('"').strip(";") - fields = raw.split("~") - if len(fields) < 6: - continue - sym = line.split("=", 1)[0].strip().lstrip("v_") - orig = code_map.get(sym) - if not orig: - continue - price = float(fields[3]) if fields[3] else 0 - prev_close = float(fields[4]) if fields[4] else 0 - change = price - prev_close if prev_close > 0 else 0 - change_pct = fields[32] if len(fields) > 32 and fields[32] else "0" - results[orig] = (price, change, change_pct) - except (ValueError, IndexError): - continue - return results - - -def refresh_data_prices(): - """一次性刷新portfolio.json和watchlist.json的所有实时价""" - all_codes = set() - - # 收集所有需要拉取的代码 - try: - pf = read_portfolio() - for s in pf.get('holdings', []): - all_codes.add(s['code']) - except: - pf = {"holdings": []} - - try: - wl = read_watchlist() - for s in wl.get('stocks', []): - all_codes.add(s['code']) - except: - wl = {"stocks": []} - - if not all_codes: - return 0 - - # 分批拉取:A股走腾讯(实时) + 港股走新浪批量(实时,无限流) - all_list = list(all_codes) - prices = fetch_all_prices(all_list) # A股(腾讯,实时) - hk_prices = fetch_hk_sina_batch(all_list) # 港股(新浪批量,实时) - # 新浪未覆盖的走备用通道(东财逐股→腾讯15min延迟) - hk_codes_missing = [c for c in all_list if len(str(c).strip()) <= 5 and c not in hk_prices] - if hk_codes_missing: - fallback = fetch_hk_eastmoney_fallback(hk_codes_missing) - hk_prices.update(fallback) - prices.update(hk_prices) - updated = 0 - - # 保存全量实时价快照(供报告管道消费,确保分析用最新数据) - try: - live = {"updated_at": datetime.now().isoformat(), "prices": {}} - for code in all_codes: - if code in prices: - p, c, chg = prices[code] - live["prices"][code] = {"price": p, "change_pct": chg} - # json.dump(live, ...) — 已迁移到 DB,见根 price_monitor.py - except Exception: - pass - - # 更新portfolio(只在价格变化时写入,避免触发文件变更通知) - changed = False - for s in pf.get('holdings', []): - if s['code'] in prices: - price, _, change_pct = prices[s['code']] - if price > 0: - # 港股:API返回HKD,需转RMB - if is_hk_stock(s['code']): - price = round(price * HK_RATE, 2) - old = s.get('price', 0) - if abs(old - price) > 0.001: - s['price'] = round(price, 2) - s['change_pct'] = float(change_pct) if change_pct else 0 - updated += 1 - changed = True - if changed: - pf['updated_at'] = datetime.now().strftime('%Y-%m-%d %H:%M') - pf['total_mv'] = calc_total_mv(pf.get('holdings', [])) - pf['total_assets'] = calc_total_assets(pf) - pf['position_pct'] = calc_position_pct(pf) - # DB 写入(替代 json.dump,强制币种约束) - try: - conn = get_conn() - write_holdings_batch(conn, pf['holdings']) - write_portfolio_summary(conn, pf) - conn.close() - except Exception as e: - print(f" [DB写入失败] {e}", flush=True) - # [migrated to DB] — JSON cold backup removed - # json.dump(pf, open(PORTFOLIO_PATH, 'w'), ensure_ascii=False, indent=2) - elif pf.get('updated_at'): - try: - last_ts = datetime.strptime(pf['updated_at'], '%Y-%m-%d %H:%M') - if (datetime.now() - last_ts).total_seconds() > 600: - pf['updated_at'] = datetime.now().strftime('%Y-%m-%d %H:%M') - # DB 写入(时间戳更新) - try: - conn2 = get_conn() - write_portfolio_summary(conn2, pf) - conn2.close() - except Exception: - pass - # [migrated to DB] — cold backup removed - # json.dump(pf, open(PORTFOLIO_PATH, 'w'), ensure_ascii=False, indent=2) - except: - pass - - # 更新watchlist(只在价格变化时写入) - changed = False - for s in wl.get('stocks', []): - if s['code'] in prices: - price, _, change_pct = prices[s['code']] - if price > 0: - # 港股:API返回HKD,需转RMB - if is_hk_stock(s['code']): - price = round(price * HK_RATE, 2) - old = s.get('price', 0) - if abs(old - price) > 0.001: - s['price'] = round(price, 2) - s['change_pct'] = float(change_pct) if change_pct else 0 - updated += 1 - changed = True - if changed: - wl['updated_at'] = datetime.now().isoformat() - # DB 写入(替代 json.dump) - try: - conn = get_conn() - for s in wl.get('stocks', []): - s['currency'] = 'CNY' # 自选股价格统一CNY - write_watchlist_stock(conn, s) - conn.close() - except Exception as e: - print(f" [DB watchlist写入失败] {e}", flush=True) - # [migrated to DB] — cold backup removed; DB writes above - # json.dump(wl, open(WATCHLIST_PATH, 'w'), ensure_ascii=False, indent=2) - - # --- 汇总值重算(使用 mo_models 唯一公式)--- - try: - live_market_value = calc_total_mv(pf.get('holdings', [])) - old_mv = pf.get('total_mv', 0) - - if abs(old_mv - live_market_value) > 0.01: - pf['total_mv'] = round(live_market_value, 2) - - pf['total_assets'] = calc_total_assets(pf) - if pf['total_assets'] > 0: - pf['position_pct'] = calc_position_pct(pf) - pf['updated_at'] = datetime.now().strftime('%Y-%m-%d %H:%M') - # DB 写入 - try: - conn = get_conn() - write_portfolio_summary(conn, pf) - conn.close() - except Exception as e: - print(f" [DB汇总写入失败] {e}", flush=True) - # [migrated to DB] — cold backup removed; DB writes above - # json.dump(pf, open(PORTFOLIO_PATH, 'w'), ensure_ascii=False, indent=2) - except Exception as e: - print(f" [汇总重算失败] {e}", flush=True) - # --- 结束汇总重算 --- - - return updated - - -# ── 分支系统辅助函数 ────────────────────────────────────────────────────── - -def _branch_alert_suffix(code, price, shares=0, cost=0): - """返回分支信息后缀:「 | 情景→动作」""" - if not HAS_TREE or not _SCENARIO_CACHE.get('id'): - return "" - try: - sc_id = _SCENARIO_CACHE['id'] - results = evaluate_branches(code, sc_id, price, shares, cost) - for r in results: - if r.get('applicable'): - _record_branch_trigger(code, r.get('branch_id',''), price) - branch_action = r.get('action_type', r.get('action', 'hold')) - return f" | {sc_id}→{branch_action}" - except Exception: - pass - return "" - - -def _record_branch_trigger(code, branch_id, price): - """记录分支触发事件(自成长:trigger_count+1)""" - try: - raw = read_decisions() - for d in raw.get('decisions', []): - if d.get('code') == code and d.get('strategy_tree',{}).get('branches'): - for b in d['strategy_tree']['branches']: - if b['id'] == branch_id: - b.setdefault('trigger_count', 0) - b['trigger_count'] += 1 - b['last_trigger_price'] = round(price, 2) - b['last_triggered'] = datetime.now().isoformat() - break - # DB 写入(替代 json.dump) - try: - conn3 = get_conn() - for d in raw.get('decisions', []): - write_holding_strategy(conn3, d.get('code', ''), d.get('name', ''), d) - conn3.close() - except Exception: - pass - # [migrated to DB] — cold backup removed - # json.dump(raw, open(DECISIONS_PATH, 'w'), ensure_ascii=False, indent=2) - except Exception: - pass - - -# ── 区间偏离检测 ────────────────────────────────────────────────────────── - -def load_state(): - try: - with open(STATE_PATH) as f: - return json.load(f) - except: - return {} - -def save_state(state): - os.makedirs(os.path.dirname(STATE_PATH), exist_ok=True) - with open(STATE_PATH, 'w') as f: - json.dump(state, f, ensure_ascii=False, indent=2) - -def load_breaches(): - try: - with open(BREACH_PATH) as f: - return json.load(f) - except: - return {} - -def save_breaches(data): - os.makedirs(os.path.dirname(BREACH_PATH), exist_ok=True) - with open(BREACH_PATH, 'w') as f: - json.dump(data, f, ensure_ascii=False, indent=2) - - -def load_events(): - try: - with open(EVENTS_PATH) as f: - return json.load(f) - except: - return {"events": []} - - -def save_events(events): - os.makedirs(os.path.dirname(EVENTS_PATH), exist_ok=True) - with open(EVENTS_PATH, 'w') as f: - json.dump(events, f, ensure_ascii=False, indent=2) - - -def record_event(code, name, event_type, price, trigger_value, event_label=""): - """记录一次价格触发事件到 price_events.json + SQLite""" - events = load_events() - now = datetime.now().isoformat() - events["events"].append({ - "code": code, - "name": name, - "event_type": event_type, # entry_zone, stop_loss, take_profit, exit_zone - "price": round(price, 2), - "trigger_value": trigger_value, - "event_label": event_label, - "timestamp": now, - "date": datetime.now().strftime("%Y-%m-%d"), - }) - # 保留最近10000条 - events["events"] = events["events"][-10000:] - save_events(events) - - # ── SQLite 双写 ── - try: - from mofin_db import get_conn, init_all_tables, write_price_event - conn = get_conn() - init_all_tables(conn) - write_price_event(conn, code, name, event_type, price, trigger_value, event_label) - conn.close() - except Exception: - pass # SQLite 写入失败不影响主流程 - - -def get_trigger_zones(d): - """返回该decision所有可监控的区间列表,从顶层字段读取""" - zones = [] - is_holding = d.get('shares', 0) > 0 - # 买入区间(自选和持仓都监控) - el = d.get("entry_low", 0) - eh = d.get("entry_high", 0) - if el and eh and float(el) > 0 and float(eh) > 0: - try: - zones.append(("entry_zone", "买入区间", float(el), float(eh))) - except: - pass - # 止损+止盈(只有持仓才监控,自选无意义) - if is_holding: - sl = d.get("stop_loss", 0) - if sl and float(sl) > 0: - try: - zones.append(("stop_loss", "止损", 0, float(sl))) - except: - pass - tp = d.get("take_profit", 0) - if tp and float(tp) > 0: - try: - zones.append(("take_profit_zone", "止盈区间", 0, float(tp))) - except: - pass - return zones - - -def run_once(round_label=""): - """执行一轮完整的监控流程""" - global _SCENARIO_CACHE, _BRANCH_CACHE - label = f" [{round_label}]" if round_label else "" - start = time.time() - - # 刷新情景与分支缓存(每轮更新) - _SCENARIO_CACHE = detect_scenario() if HAS_TREE else {} - _BRANCH_CACHE = {} - try: - raw = read_decisions() - for d in raw.get('decisions', []): - tree = d.get('strategy_tree', {}) - if tree and tree.get('branches'): - _BRANCH_CACHE[d['code']] = tree['branches'] - except Exception: - pass - - # === 第一步:一次性刷新所有价格 === - refreshed = refresh_data_prices() - - # === 第二步:检查触发条件 === - try: - dec = read_decisions() - except: - print(f"❌{label} 无法读取decisions.json", file=sys.stderr) - return - - active = [d for d in dec.get("decisions", []) if d.get("status") in ("active", "updated")] - state = load_state() - outputs = [] - state_updated = False - - # 收集所有需要检查的代码 - check_codes = set() - for d in active: - if get_trigger_zones(d): - check_codes.add(d["code"]) - - # 批量拉取这些股票的价格 - prices = fetch_all_prices(list(check_codes)) - - for d in active: - code = d["code"] - - zones = get_trigger_zones(d) - if not zones: - continue - - price_info = prices.get(code) - if not price_info: - continue - price, _, _ = price_info - if price == 0: - continue - - name = d.get("name", code) - if code not in state: - state[code] = {} - - for key, label, lo, hi in zones: - in_zone = lo <= price <= hi - prev_in_zone = state[code].get(key, None) - - if in_zone and prev_in_zone != True: - if key == "stop_loss": - branch_sfx = _branch_alert_suffix(code, price, d.get('shares',0), d.get('cost',0)) - outputs.append(f"⚠️ {name}({code}) {price} → 跌破止损{hi}!{branch_sfx}") - record_event(code, name, "stop_loss", price, str(hi)) - else: - extra = "" - if "_price" in key: - batch_shares = d.get(key.replace("_price", "_shares"), "") - action = d.get(key.replace("_price", "_action"), "") - if batch_shares: - extra = f" {action}{batch_shares}股" if action else f" {batch_shares}股" - elif key in ("take_profit_zone",): - act = d.get("take_profit_action", "") - if act: - extra = f"({act})" - branch_sfx = _branch_alert_suffix(code, price, d.get('shares',0), d.get('cost',0)) - outputs.append(f"⚡ {name}({code}) {price} → 进入{label}{lo}~{hi}{extra}{branch_sfx}") - record_event(code, name, "entry_zone", price, f"{lo}~{hi}", label) - state[code][key] = True - state_updated = True - - elif not in_zone and prev_in_zone == True: - if key != "stop_loss": - outputs.append(f"📌 {name}({code}) {price} → 离开{label}{lo}~{hi}") - state[code][key] = False - state_updated = True - - # === 第三步:买入区偏离检测 + 自动重评 === - reassesed_codes = [] - for d in active: - code = d["code"] - name = d.get("name", code) - price_info = prices.get(code) - if not price_info: - continue - price, _, _ = price_info - if price == 0: - continue - - # 从 decisions.json 中读取 analysis 的买入区 - entry_low = d.get("entry_low", 0) - entry_high = d.get("entry_high", 0) - if not entry_low or not entry_high: - continue - - in_buy_zone = entry_low <= price <= entry_high - prev_in_buy_zone = state.get(code, {}).get("__buy_zone", None) - - # 状态变化时才触发:True→False离区 或 False→True进区 - # [2026-07-01 fix] prev_in_buy_zone is None(新加自选首次检测) - # 也要触发——否则新自选全程不走重评,timing_signal卡在初始值 - if in_buy_zone and (prev_in_buy_zone == False or prev_in_buy_zone is None): - # 进入买入区 → 触发技术面重评,更新止损/止盈/信号 - outputs.append(f"🔄 {name}({code}) {price} → 重新进入买入区{entry_low}~{entry_high},触发技术面重评") - do_reassess = True - elif not in_buy_zone and prev_in_buy_zone == True: - # 离开买入区 → 立即重评,更新止损/止盈/区间 - outputs.append(f"🔄 {name}({code}) {price} → 离开买入区{entry_low}~{entry_high},立即技术面重评") - do_reassess = True - else: - do_reassess = False - - if do_reassess and HAS_REASSESS: - try: - cost = d.get("cost", 0) or 0 - shares = d.get("shares", 0) or 0 - profit_pct = (price - cost) / cost * 100 if cost else 0 - is_deep_loss = profit_pct < -20 - sentiment = "neutral" - if d.get("tech_snapshot"): - if "bearish" in d["tech_snapshot"]: - sentiment = "bearish" - elif "bullish" in d["tech_snapshot"]: - sentiment = "bullish" - - # 调用技术面驱动重评(非机械百分比) - result = reassess_strategy( - code, name, price, cost, shares, - current_action=d.get("action", ""), - volume_signal="中性", sentiment=sentiment, - ) - outputs.append(f" 📊 新策略: 损{result['stop_loss']} 盈{result['take_profit']} 区{result['entry_low']}~{result['entry_high']} RR={result['rr_ratio']}") - reassesed_codes.append(code) - except Exception as e: - outputs.append(f" ⚠️ 重评失败: {e}") - - # 更新买入区状态 - if "__buy_zone" not in state.get(code, {}): - if code not in state: - state[code] = {} - state[code]["__buy_zone"] = in_buy_zone - state_updated = True - - # 如果有重评过的股票,更新 decisions.json - if reassesed_codes and HAS_REASSESS: - try: - # 重新 regenerate_all 只针对受影响的股票效率太低 - # 直接全量重评(regenerate_all 内部会批量拉价格、做技术分析) - from strategy_lifecycle import regenerate_all - r = regenerate_all(stdout=False) - outputs.append(f" ✅ 策略已全量重评: {r.get('ok',0)}/{r.get('total',0)}成功") - outputs.append(f" 📌 触发股票: {', '.join(reassesed_codes)}") - except Exception as e: - outputs.append(f" ⚠️ 全量重评失败: {e}") - - # === 3.5 资金流异常检测(2026-06-27 新增)=== - try: - from mofin_db import get_conn, read_capital_flow_cache - cf = read_capital_flow_cache(get_conn()) - # 检查所有 active decision 中的资金流异常 - for d in active: - code = d["code"] - stock_cf = cf.get("stocks", {}).get(code, {}) - analysis = stock_cf.get("analysis", {}) - alerts = analysis.get("alerts", []) - if alerts: - name = d.get("name", code) - for a in alerts: - outputs.append(f" 💰 {name}({code}) {a}") - except Exception: - pass - - # === 第四步:情景变化检测 + 输出 → 直接推XMPP === - now_str = datetime.now().strftime("%H:%M:%S") - elapsed = time.time() - start - - # 情景变化检测(跨轮对比) - if HAS_TREE and _SCENARIO_CACHE.get('id'): - prev_scenario = state.get('_system', {}).get('last_scenario', '') - curr_scenario = _SCENARIO_CACHE['id'] - if prev_scenario and curr_scenario != prev_scenario: - combo = _SCENARIO_CACHE.get('combo_action', '') - outputs.insert(0, f"🌀 情景切换: {prev_scenario}→{curr_scenario} | {combo}") - if outputs: - state.setdefault('_system', {})['last_scenario'] = curr_scenario - state_updated = True - elif not prev_scenario: - state.setdefault('_system', {})['last_scenario'] = curr_scenario - state_updated = True - - if outputs: - # 简短一行一个触发 - for o in outputs: - print(o) - # 推送XMPP(只推关键事件:止损跌破+情景切换+资金流异动,不推买入区进出/重评等操作细节) - critical = [o for o in outputs if o.startswith(("⚠️", "🌀", "💰"))] - if critical: - try: - body = "\n".join([f"{now_str}"] + critical) - payload = json.dumps({ - "to": "hmo@yoin.fun", "body": body, "type": "chat", - }).encode("utf-8") - req = urllib.request.Request( - "http://127.0.0.1:5805/", data=payload, - headers={"Content-Type": "application/json"}, - ) - urllib.request.urlopen(req, timeout=5) - except Exception: - pass - # else: SILENT — 无触发,无输出,不推 - - if state_updated: - save_state(state) - - -def main(): - """每cron触发跑一轮""" - run_once() - - -if __name__ == "__main__": - main() diff --git a/scripts/process_trade.py b/scripts/process_trade.py index 3ff63c8..85f4ffb 100644 --- a/scripts/process_trade.py +++ b/scripts/process_trade.py @@ -20,8 +20,6 @@ from mo_data import read_portfolio, read_decisions, read_watchlist sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from mofin_db import get_conn, write_holdings_batch, write_portfolio_summary, write_holding_strategy -PORTFOLIO_PATH = "/home/hmo/web-dashboard/data/portfolio.json" -DECISIONS_PATH = "/home/hmo/web-dashboard/data/decisions.json" def parse_args(): args = {} diff --git a/scripts/prune_branches.py b/scripts/prune_branches.py index 65fab36..97f3cd1 100755 --- a/scripts/prune_branches.py +++ b/scripts/prune_branches.py @@ -16,7 +16,6 @@ from datetime import datetime, timedelta from mo_data import read_decisions from mofin_db import get_conn, write_holding_strategy -DECISIONS_PATH = "/home/hmo/web-dashboard/data/decisions.json" PRUNE_LOG = "/home/hmo/MoFin/data/prune_log.json" @@ -33,10 +32,6 @@ def save_decisions(data): conn.close() except Exception: pass - # [migrated to DB] — cold backup removed - # with open(DECISIONS_PATH, "w") as f: - # json.dump(data, f, indent=2, ensure_ascii=False) - def main(): data = load_decisions() diff --git a/scripts/stale_detector.py b/scripts/stale_detector.py index 0230073..391855f 100644 --- a/scripts/stale_detector.py +++ b/scripts/stale_detector.py @@ -1,287 +1,284 @@ -#!/usr/bin/env python3 -"""stale_detector.py — 检查所有策略,标记价格偏离/过期的策略 - -读取 decisions.json 的扁平列表。自选策略和持仓策略分开判断。 -可被 cron no_agent 模式调用:stdout 注入到后续 LLM 分析。 - -输出格式: - [FLAG] [自选/持仓] 股票名(代码) 价XX | 买入A~B | 问题 - -用法: - python3 stale_detector.py -""" -import json -import sys -import os -from datetime import datetime, timezone -sys.path.insert(0, '/home/hmo/MoFin') -from mo_data import read_portfolio, read_decisions, read_watchlist - -DECISIONS_PATH = "/home/hmo/web-dashboard/data/decisions.json" -PORTFOLIO_PATH = "/home/hmo/web-dashboard/data/portfolio.json" - - -def fetch_prices(codes): - """统一价格源:优先 stock_quote.py,腾讯API降级为兜底""" - if not codes: - return {} - # 尝试用 stock_quote.py 获取(脚本强制规范) - try: - import subprocess - script = None - for p in ["/home/hmo/MoFin/scripts/stock_quote.py", "/home/hmo/MoFin/stock_quote.py"]: - if os.path.exists(p): - script = p - break - if script: - result = subprocess.run( - [sys.executable, script] + [str(c) for c in codes], - capture_output=True, text=True, timeout=30 - ) - if result.returncode == 0 and result.stdout.strip(): - results = {} - for line in result.stdout.strip().split("\n"): - if not line.strip(): - continue - try: - item = json.loads(line) - code = str(item.get("code", "")) - price = item.get("price") - change = item.get("change_pct", 0) - if code and price is not None: - results[code] = (float(price), float(change)) - except (json.JSONDecodeError, ValueError): - continue - if results: - return results - except Exception as e: - print(f"[STALE] stock_quote.py 回退: {e}", file=sys.stderr) - - # 兜底:腾讯API(不应依赖,仅作为最后手段) - import urllib.request - symbols, code_map = [], {} - for c in codes: - c = str(c).strip() - p = "sh" if (len(c) == 6 and c[0] in "569") else "sz" if len(c) == 6 else "hk" - sym = f"{p}{c}" - symbols.append(sym) - code_map[sym] = c - try: - req = urllib.request.Request( - f"http://qt.gtimg.cn/q={','.join(symbols)}", - headers={"User-Agent": "curl/7.81"}, - ) - with urllib.request.urlopen(req, timeout=10) as r: - text = r.read().decode("gbk") - except Exception as e: - print(f"FETCH_FAIL (fallback): {e}", file=sys.stderr) - return {} - - results = {} - for line in text.strip().split("\n"): - if "=" not in line: - continue - try: - raw = line.split("=", 1)[1].strip().strip('"').strip(";") - fld = raw.split("~") - if len(fld) < 6: - continue - sym = line.split("=", 1)[0].strip().lstrip("v_") - oc = code_map.get(sym) - if not oc: - continue - p = float(fld[3]) if fld[3] else 0 - c = fld[32] if len(fld) > 32 else "0" - results[oc] = (p, c) - except (ValueError, IndexError): - continue - return results - - -def main(): - decisions_list = read_decisions() - if not isinstance(decisions_list, list): - decisions_list = decisions_list.get("decisions", []) if isinstance(decisions_list, dict) else [] - - # 只保留有买入区的条目,排除已关闭的(inactive/closed) - EXCLUDED_STATUSES = ("closed", "inactive") - to_check = [d for d in decisions_list if (d.get("entry_low") is not None or d.get("entry_high") is not None) and d.get("status") not in EXCLUDED_STATUSES] - if not to_check: - print("[SILENT] 无需要检查的策略") - return 0 - - # ----- 组合级监测:读取总仓位 + 弱势比例 ----- - position_pct = 0 - cash = 0 - total_assets = 0 - try: - pf = read_portfolio() - position_pct = pf.get("position_pct", 0) - cash = pf.get("cash", 0) - total_assets = pf.get("total_assets", 0) - except Exception: - pass - # 统计持仓策略中弱势/深套的比例 - weak_count = 0 - holding_count = 0 - for d in decisions_list: - if d.get("type") == "持仓策略" and d.get("status") not in ("closed", "inactive"): - holding_count += 1 - cat = d.get("stock_category", "") - if cat in ("弱势", "深套"): - weak_count += 1 - weak_ratio = (weak_count / holding_count * 100) if holding_count > 0 else 0 - - prices = fetch_prices([d["code"] for d in to_check]) - now = datetime.now(timezone.utc).astimezone() - found = 0 - - for d in to_check: - code = d["code"] - name = d.get("name", code) - el = d.get("entry_low") - eh = d.get("entry_high") - sl = d.get("stop_loss") - tp = d.get("take_profit") - ts = d.get("created_at") or d.get("timestamp") or d.get("updated_at", "") - is_wl = "自选" in (d.get("type", "")) - - pi = prices.get(code) - if not pi: - continue - price, chg = pi - if price <= 0: - continue - - issues, flags = [], [] - tag = "[自选]" if is_wl else "[持仓]" - - # -- 偏离 -- - if is_wl and el and eh: - # 读取 timing_signal 判断策略有效性(timing_signal 字段优先,fallback to action) - current_str = d.get("current", "") or "" - timing_signal = d.get("timing_signal", "") or current_str - has_nonbuy_signal = any(kw in timing_signal for kw in [ - "等企稳再入", "等企稳", "弱势持有", "观望", - "不建议买入", "谨慎买入", - ]) - - # 直接计算 R/R(不依赖文本匹配) - rr_invalid = False - if sl and sl > 0 and tp and tp > 0 and price > sl: - rr = (tp - price) / (price - sl) - if rr < 1.5: - rr_invalid = True - # 也检查 tp 是否接近或低于成本(微盈/浮亏止盈) - cost = d.get("cost", 0) - if cost and cost > 0 and tp <= cost * 1.05: - rr_invalid = True - - strategy_deficient = has_nonbuy_signal or rr_invalid - # 对自选无止盈位的也标记(策略不完整) - if not tp or tp == 0: - strategy_deficient = True - - if el <= price <= eh: - flags.append("[WL_IN]") - if strategy_deficient: - flags.append("[STRATEGY_STALE]") - prefix = "⚠️仓位挤占 " if position_pct > 80 else "" - issues.append(f"[STRATEGY_STALE] {prefix}价{price:.2f}在买入区{el}~{eh}但策略不完整({'RR='+f'{rr:.2f}<1.5' if rr_invalid else '无止盈位' if not tp else '非买入信号'}),买入区需重评") - else: - prefix = "⚠️仓位挤占 " if position_pct > 80 else "" - issues.append(f"[PUSH] {prefix}价{price:.2f}入买入区{el}~{eh}") - elif price > eh * 1.35: - flags.append("[WL_HIGH]") - issues.append(f"价{price:.2f}高出买入区+{((price/eh)-1)*100:.0f}%,买入区需重评") - elif price > eh * 1.20: - flags.append("[WL_DRIFT]") - issues.append(f"价{price:.2f}高于买入区+{((price/eh)-1)*100:.0f}%") - elif not is_wl and eh: - dp = (price / eh - 1) * 100 - if dp > 35: - flags.append("[SEVERE]") - issues.append(f"偏离买入区上沿+{dp:.0f}%") - elif dp > 20: - flags.append("[DRIFT]") - issues.append(f"偏离买入区上沿+{dp:.0f}%") - elif dp > 10: - flags.append("[WARN]") - issues.append(f"偏离买入区上沿+{dp:.0f}%") - # 持仓在买入区内但 R/R 不达标 - if el and sl and sl > 0 and tp and tp > 0 and price > sl: - if el <= price <= eh: - rr = (tp - price) / (price - sl) - if rr < 1.5: - flags.append("[RR_WARN]") - issues.append(f"买入区内RR仅{rr:.2f}<1.5,策略需重评") - - # -- 距止损/止盈(仅持仓) -- - if not is_wl: - if sl and sl > 0: - dsl = (price / sl - 1) * 100 - if dsl < 5: - # 成本基准校验:浮盈>5%时止损是利润保护,不是危险信号 - # (mirrors NEAR_TP cost_check logic at line 195-198) - cost = d.get("cost") - if cost and cost > 0 and price > cost * 1.05: - flags.append("[PROFIT_PROTECT]") - pnl = (price / cost - 1) * 100 - issues.append(f"距止损仅{dsl:.1f}%(利润保护,浮盈{pnl:.0f}%)") - else: - flags.append("[NEAR_SL]") - issues.append(f"距止损仅{dsl:.1f}%") - if tp and tp > 0: - dtp = (tp / price - 1) * 100 - if dtp < 5: - # 成本基准校验:止盈标记只有在盈利≥5%时才有效 - cost_check = True - cost = d.get("cost") - if cost and cost > 0 and price < cost * 1.05: - cost_check = False - if cost_check: - flags.append("[NEAR_TP]") - issues.append(f"距止盈仅{dtp:.1f}%") - - # -- 过期 -- - stale_limit = 30 if is_wl else 14 - if ts: - try: - ud = datetime.fromisoformat(ts) - if ud.tzinfo is None: - ud = ud.replace(tzinfo=timezone.utc) - days = (now - ud).days - if days > stale_limit: - flags.append("[STALE]") - issues.append(f"{days}天未更新(>{stale_limit})") - except (ValueError, TypeError): - pass - - if issues: - print(f"{' '.join(flags)} {tag} {name}({code}) 价{price:.2f}{chg} | 买入{el}~{eh} | {'; '.join(issues)}") - found += 1 - - if found == 0: - print("[SILENT] 所有策略正常") - - # ----- 组合级警报 ----- - portfolio_alerts = 0 - if holding_count > 0: - if weak_ratio > 40: - print(f"\n[PORTFOLIO_WEAK] 组合弱势比例{weak_ratio:.0f}% ({weak_count}/{holding_count})!仓位{position_pct:.1f}% → 建议系统性减仓") - portfolio_alerts += 1 - elif weak_ratio > 30: - print(f"\n[PORTFOLIO_WEAK_MILD] 组合弱势比例{weak_ratio:.0f}% ({weak_count}/{holding_count}),仓位{position_pct:.1f}%,关注") - portfolio_alerts += 1 - if position_pct > 80 and holding_count > 0: - # 仓位过满提醒 - print(f"[PORTFOLIO_FULL] 总仓位{position_pct:.1f}% > 80%,现金{cash:.0f}({cash/total_assets*100:.1f}%)") - portfolio_alerts += 1 - if portfolio_alerts > 0: - found += portfolio_alerts - - return found - - -if __name__ == "__main__": - main() +#!/usr/bin/env python3 +"""stale_detector.py — 检查所有策略,标记价格偏离/过期的策略 + +读取 decisions.json 的扁平列表。自选策略和持仓策略分开判断。 +可被 cron no_agent 模式调用:stdout 注入到后续 LLM 分析。 + +输出格式: + [FLAG] [自选/持仓] 股票名(代码) 价XX | 买入A~B | 问题 + +用法: + python3 stale_detector.py +""" +import json +import sys +import os +from datetime import datetime, timezone +sys.path.insert(0, '/home/hmo/MoFin') +from mo_data import read_portfolio, read_decisions, read_watchlist + + +def fetch_prices(codes): + """统一价格源:优先 stock_quote.py,腾讯API降级为兜底""" + if not codes: + return {} + # 尝试用 stock_quote.py 获取(脚本强制规范) + try: + import subprocess + script = None + for p in ["/home/hmo/MoFin/scripts/stock_quote.py", "/home/hmo/MoFin/stock_quote.py"]: + if os.path.exists(p): + script = p + break + if script: + result = subprocess.run( + [sys.executable, script] + [str(c) for c in codes], + capture_output=True, text=True, timeout=30 + ) + if result.returncode == 0 and result.stdout.strip(): + results = {} + for line in result.stdout.strip().split("\n"): + if not line.strip(): + continue + try: + item = json.loads(line) + code = str(item.get("code", "")) + price = item.get("price") + change = item.get("change_pct", 0) + if code and price is not None: + results[code] = (float(price), float(change)) + except (json.JSONDecodeError, ValueError): + continue + if results: + return results + except Exception as e: + print(f"[STALE] stock_quote.py 回退: {e}", file=sys.stderr) + + # 兜底:腾讯API(不应依赖,仅作为最后手段) + import urllib.request + symbols, code_map = [], {} + for c in codes: + c = str(c).strip() + p = "sh" if (len(c) == 6 and c[0] in "569") else "sz" if len(c) == 6 else "hk" + sym = f"{p}{c}" + symbols.append(sym) + code_map[sym] = c + try: + req = urllib.request.Request( + f"http://qt.gtimg.cn/q={','.join(symbols)}", + headers={"User-Agent": "curl/7.81"}, + ) + with urllib.request.urlopen(req, timeout=10) as r: + text = r.read().decode("gbk") + except Exception as e: + print(f"FETCH_FAIL (fallback): {e}", file=sys.stderr) + return {} + + results = {} + for line in text.strip().split("\n"): + if "=" not in line: + continue + try: + raw = line.split("=", 1)[1].strip().strip('"').strip(";") + fld = raw.split("~") + if len(fld) < 6: + continue + sym = line.split("=", 1)[0].strip().lstrip("v_") + oc = code_map.get(sym) + if not oc: + continue + p = float(fld[3]) if fld[3] else 0 + c = fld[32] if len(fld) > 32 else "0" + results[oc] = (p, c) + except (ValueError, IndexError): + continue + return results + + +def main(): + decisions_list = read_decisions() + if not isinstance(decisions_list, list): + decisions_list = decisions_list.get("decisions", []) if isinstance(decisions_list, dict) else [] + + # 只保留有买入区的条目,排除已关闭的(inactive/closed) + EXCLUDED_STATUSES = ("closed", "inactive") + to_check = [d for d in decisions_list if (d.get("entry_low") is not None or d.get("entry_high") is not None) and d.get("status") not in EXCLUDED_STATUSES] + if not to_check: + print("[SILENT] 无需要检查的策略") + return 0 + + # ----- 组合级监测:读取总仓位 + 弱势比例 ----- + position_pct = 0 + cash = 0 + total_assets = 0 + try: + pf = read_portfolio() + position_pct = pf.get("position_pct", 0) + cash = pf.get("cash", 0) + total_assets = pf.get("total_assets", 0) + except Exception: + pass + # 统计持仓策略中弱势/深套的比例 + weak_count = 0 + holding_count = 0 + for d in decisions_list: + if d.get("type") == "持仓策略" and d.get("status") not in ("closed", "inactive"): + holding_count += 1 + cat = d.get("stock_category", "") + if cat in ("弱势", "深套"): + weak_count += 1 + weak_ratio = (weak_count / holding_count * 100) if holding_count > 0 else 0 + + prices = fetch_prices([d["code"] for d in to_check]) + now = datetime.now(timezone.utc).astimezone() + found = 0 + + for d in to_check: + code = d["code"] + name = d.get("name", code) + el = d.get("entry_low") + eh = d.get("entry_high") + sl = d.get("stop_loss") + tp = d.get("take_profit") + ts = d.get("created_at") or d.get("timestamp") or d.get("updated_at", "") + is_wl = "自选" in (d.get("type", "")) + + pi = prices.get(code) + if not pi: + continue + price, chg = pi + if price <= 0: + continue + + issues, flags = [], [] + tag = "[自选]" if is_wl else "[持仓]" + + # -- 偏离 -- + if is_wl and el and eh: + # 读取 timing_signal 判断策略有效性(timing_signal 字段优先,fallback to action) + current_str = d.get("current", "") or "" + timing_signal = d.get("timing_signal", "") or current_str + has_nonbuy_signal = any(kw in timing_signal for kw in [ + "等企稳再入", "等企稳", "弱势持有", "观望", + "不建议买入", "谨慎买入", + ]) + + # 直接计算 R/R(不依赖文本匹配) + rr_invalid = False + if sl and sl > 0 and tp and tp > 0 and price > sl: + rr = (tp - price) / (price - sl) + if rr < 1.5: + rr_invalid = True + # 也检查 tp 是否接近或低于成本(微盈/浮亏止盈) + cost = d.get("cost", 0) + if cost and cost > 0 and tp <= cost * 1.05: + rr_invalid = True + + strategy_deficient = has_nonbuy_signal or rr_invalid + # 对自选无止盈位的也标记(策略不完整) + if not tp or tp == 0: + strategy_deficient = True + + if el <= price <= eh: + flags.append("[WL_IN]") + if strategy_deficient: + flags.append("[STRATEGY_STALE]") + prefix = "⚠️仓位挤占 " if position_pct > 80 else "" + issues.append(f"[STRATEGY_STALE] {prefix}价{price:.2f}在买入区{el}~{eh}但策略不完整({'RR='+f'{rr:.2f}<1.5' if rr_invalid else '无止盈位' if not tp else '非买入信号'}),买入区需重评") + else: + prefix = "⚠️仓位挤占 " if position_pct > 80 else "" + issues.append(f"[PUSH] {prefix}价{price:.2f}入买入区{el}~{eh}") + elif price > eh * 1.35: + flags.append("[WL_HIGH]") + issues.append(f"价{price:.2f}高出买入区+{((price/eh)-1)*100:.0f}%,买入区需重评") + elif price > eh * 1.20: + flags.append("[WL_DRIFT]") + issues.append(f"价{price:.2f}高于买入区+{((price/eh)-1)*100:.0f}%") + elif not is_wl and eh: + dp = (price / eh - 1) * 100 + if dp > 35: + flags.append("[SEVERE]") + issues.append(f"偏离买入区上沿+{dp:.0f}%") + elif dp > 20: + flags.append("[DRIFT]") + issues.append(f"偏离买入区上沿+{dp:.0f}%") + elif dp > 10: + flags.append("[WARN]") + issues.append(f"偏离买入区上沿+{dp:.0f}%") + # 持仓在买入区内但 R/R 不达标 + if el and sl and sl > 0 and tp and tp > 0 and price > sl: + if el <= price <= eh: + rr = (tp - price) / (price - sl) + if rr < 1.5: + flags.append("[RR_WARN]") + issues.append(f"买入区内RR仅{rr:.2f}<1.5,策略需重评") + + # -- 距止损/止盈(仅持仓) -- + if not is_wl: + if sl and sl > 0: + dsl = (price / sl - 1) * 100 + if dsl < 5: + # 成本基准校验:浮盈>5%时止损是利润保护,不是危险信号 + # (mirrors NEAR_TP cost_check logic at line 195-198) + cost = d.get("cost") + if cost and cost > 0 and price > cost * 1.05: + flags.append("[PROFIT_PROTECT]") + pnl = (price / cost - 1) * 100 + issues.append(f"距止损仅{dsl:.1f}%(利润保护,浮盈{pnl:.0f}%)") + else: + flags.append("[NEAR_SL]") + issues.append(f"距止损仅{dsl:.1f}%") + if tp and tp > 0: + dtp = (tp / price - 1) * 100 + if dtp < 5: + # 成本基准校验:止盈标记只有在盈利≥5%时才有效 + cost_check = True + cost = d.get("cost") + if cost and cost > 0 and price < cost * 1.05: + cost_check = False + if cost_check: + flags.append("[NEAR_TP]") + issues.append(f"距止盈仅{dtp:.1f}%") + + # -- 过期 -- + stale_limit = 30 if is_wl else 14 + if ts: + try: + ud = datetime.fromisoformat(ts) + if ud.tzinfo is None: + ud = ud.replace(tzinfo=timezone.utc) + days = (now - ud).days + if days > stale_limit: + flags.append("[STALE]") + issues.append(f"{days}天未更新(>{stale_limit})") + except (ValueError, TypeError): + pass + + if issues: + print(f"{' '.join(flags)} {tag} {name}({code}) 价{price:.2f}{chg} | 买入{el}~{eh} | {'; '.join(issues)}") + found += 1 + + if found == 0: + print("[SILENT] 所有策略正常") + + # ----- 组合级警报 ----- + portfolio_alerts = 0 + if holding_count > 0: + if weak_ratio > 40: + print(f"\n[PORTFOLIO_WEAK] 组合弱势比例{weak_ratio:.0f}% ({weak_count}/{holding_count})!仓位{position_pct:.1f}% → 建议系统性减仓") + portfolio_alerts += 1 + elif weak_ratio > 30: + print(f"\n[PORTFOLIO_WEAK_MILD] 组合弱势比例{weak_ratio:.0f}% ({weak_count}/{holding_count}),仓位{position_pct:.1f}%,关注") + portfolio_alerts += 1 + if position_pct > 80 and holding_count > 0: + # 仓位过满提醒 + print(f"[PORTFOLIO_FULL] 总仓位{position_pct:.1f}% > 80%,现金{cash:.0f}({cash/total_assets*100:.1f}%)") + portfolio_alerts += 1 + if portfolio_alerts > 0: + found += portfolio_alerts + + return found + + +if __name__ == "__main__": + main() diff --git a/scripts/strategy-staleness-check.py b/scripts/strategy-staleness-check.py index e251881..87f1b6d 100644 --- a/scripts/strategy-staleness-check.py +++ b/scripts/strategy-staleness-check.py @@ -15,12 +15,7 @@ from datetime import datetime from mo_data import read_decisions, read_portfolio from mo_data import read_decisions, read_portfolio -DECISIONS_PATH = "/home/hmo/web-dashboard/data/decisions.json" OUTPUT_PATH = "/home/hmo/web-dashboard/data/strategy_staleness_report.json" -PORTFOLIO_PATH = "/home/hmo/web-dashboard/data/portfolio.json" - -# Fallback: if new path not found, use old path -FALLBACK_PATH = "/home/hmo/data/decisions.json" WARN_DAYS = 14 # 超过14天未更新→警告 CRITICAL_DAYS = 21 # 超过21天→严重警告 @@ -57,10 +52,6 @@ def parse_buy_zone(current): return None, None def main(): - # Try new path first, fall back to old format - path = DECISIONS_PATH if os.path.exists(DECISIONS_PATH) else FALLBACK_PATH - is_new_format = (path == DECISIONS_PATH) - data = read_decisions() # Filter: exclude closed strategies diff --git a/scripts/strategy_lifecycle.py b/scripts/strategy_lifecycle.py deleted file mode 100644 index d10dd37..0000000 --- a/scripts/strategy_lifecycle.py +++ /dev/null @@ -1,2059 +0,0 @@ -#!/usr/bin/env python3 -"""策略生命周期管理系统 — 技术面驱动版本 v2 - -核心原则: -1. 止损放在合理的技术位,不拍数字 -2. 新买入推荐:止损=弱支撑(约3%跌幅),止盈=强压力,盈亏比≥2:1 -3. 已持仓:止损=强支撑(约5-8%跌幅),目标=强压力 -4. 买入区间:弱支撑~弱压力之间 -5. 买入时机:量价齐跌不买,缩量至支撑买,量价齐升追买 -""" - -import json -import urllib.request -import os -import sys -import re -from datetime import datetime -import technical_analysis as ta -import multi_timeframe as mtf -from mo_data import read_portfolio, read_decisions, read_watchlist -from mo_models import is_hk_stock, to_cny, get_hk_rate - -# is_hk_stock 已从 mo_models 导入,不再在此复写。 - - -def calc_atr(code, period=14): - """从腾讯API K线数据计算ATR(period),返回ATR值或None""" - try: - url = f"http://ifzq.gtimg.cn/appstock/app/fqkline/get?param=hk{code},day,,,60,qfq" - req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'}) - resp = urllib.request.urlopen(req, timeout=5).read().decode('utf-8') - data = json.loads(resp) - bars = data.get('data', {}).get(f'hk{code}', {}).get('day', []) - if len(bars) < period + 1: - return None - trs = [] - for i in range(1, min(len(bars), period + 1)): - try: - high = float(bars[i][2]) - low = float(bars[i][3]) - prev_close = float(bars[i-1][4]) if len(bars[i-1]) > 4 else float(bars[i-1][3]) - tr = max(high - low, abs(high - prev_close), abs(low - prev_close)) - trs.append(tr) - except (ValueError, IndexError): - continue - if not trs: - return None - return round(sum(trs) / len(trs), 2) - except Exception: - return None - -# 提示词版本追踪 -try: - from prompt_manager.tracking import record_strategy_generation - HAS_PROMPT_TRACKING = True -except ImportError: - HAS_PROMPT_TRACKING = False - -PORTFOLIO_PATH = "/home/hmo/web-dashboard/data/portfolio.json" -WATCHLIST_PATH = "/home/hmo/web-dashboard/data/watchlist.json" - -def safe_json_load(path, default=None): - """安全加载 JSON,遇到坏数据自动修复""" - if not os.path.exists(path): - return default if default is not None else {} - try: - with open(path, "r", encoding="utf-8") as f: - return json.load(f) - except json.JSONDecodeError: - # 尝试修复:替换字符串内未转义的换行符,去多余括号 - with open(path, "r", encoding="utf-8") as f: - raw = f.read() - fixed = raw - - # 修复1: 字符串内未转义的换行 -> \\n - result = [] - in_str = False - for ch in fixed: - if ch == '"': - in_str = not in_str - result.append(ch) - elif in_str and ch in '\n\r': - result.append('\\n') - else: - result.append(ch) - fixed = ''.join(result) - - # 修复2: 去掉多余的尾部括号 - fixed = fixed.rstrip('}') - # 补回正确的闭合 - if not fixed.endswith('}'): - fixed += '}' - - try: - return json.loads(fixed) - except json.JSONDecodeError as e: - print(f"[WARN] watchlist.json 自动修复失败: {e}", file=sys.stderr) - return default if default is not None else {} -KNOWLEDGE_LOG = "/home/hmo/Obsidian/knowledge/finance/analyst-knowledge-log.md" -MACRO_CONTEXT_PATH = "/home/hmo/web-dashboard/data/macro_context.json" -MARKET_CONTEXT_PATH = "/home/hmo/web-dashboard/data/market.json" -STOCK_SECTOR_MAP_PATH = "/home/hmo/web-dashboard/data/stock_sector_map.json" - - -def load_stock_sector_map(): - """读取个股归属行业映射 - - stock_sector_map.json 格式: {code: [sector1, sector2, ...]} - 跳过 _note, _created_at 等元数据键。 - """ - # 优先从 SQLite 读取 - try: - from mofin_db import get_conn, query_sector_stocks - conn = get_conn() - # 从 stock_sectors 表反向构建 code→[sectors] 映射 - rows = conn.execute("SELECT code, sector_name FROM stock_sectors ORDER BY code").fetchall() - conn.close() - code_to_sectors = {} - for code, sector in rows: - if code not in code_to_sectors: - code_to_sectors[code] = [] - code_to_sectors[code].append(sector) - return code_to_sectors - except Exception: - pass - try: - with open(STOCK_SECTOR_MAP_PATH) as f: - data = json.load(f) - code_to_sectors = {} - for key, value in data.items(): - if key.startswith("_"): - continue - if isinstance(value, list): - code_to_sectors[key] = value - return code_to_sectors - except Exception: - return {} - - -def load_market_context(): - """读取市场上下文,优先 SQLite,回退 market.json""" - # 优先从 SQLite 读取 - try: - from mofin_db import get_conn, query_latest_market - conn = get_conn() - market = query_latest_market(conn) - conn.close() - if market and market.get("sectors"): - sector_perf = {} - for s in market["sectors"]: - name = s.get("name", "") - if name: - sector_perf[name] = { - "change": s.get("change_pct", 0), - "up_count": s.get("up_count", 0), - "down_count": s.get("down_count", 0), - "net_inflow": s.get("net_inflow", 0), - "lead_stock": s.get("lead_stock", ""), - "lead_stock_change": s.get("lead_stock_change", 0), - } - return { - "sector_perf": sector_perf, - "breadth": market.get("up_ratio", 50), - "mood": market.get("mood", "neutral"), - "top_gainers": {g["name"]: g["change_pct"] for g in market.get("top_gainers", [])}, - "top_losers": {g["name"]: g["change_pct"] for g in market.get("top_losers", [])}, - "total_sectors": len(market["sectors"]), - "market_timestamp": market.get("timestamp", ""), - } - except Exception: - pass - try: - with open(MARKET_CONTEXT_PATH) as f: - market = json.load(f) - sectors = market.get("sectors", []) - sector_perf = {} - for s in sectors: - name = s.get("name", "") - if name: - sector_perf[name] = { - "change": s.get("change", 0), - "up_count": s.get("up_count", 0), - "down_count": s.get("down_count", 0), - "net_inflow": s.get("net_inflow", 0), - "lead_stock": s.get("lead_stock", ""), - "lead_stock_change": s.get("lead_stock_change", 0), - } - top_gainers = {s.get("name", ""): s.get("change", 0) - for s in market.get("top_gainers", [])} - top_losers = {s.get("name", ""): s.get("change", 0) - for s in market.get("top_losers", [])} - return { - "sector_perf": sector_perf, - "breadth": market.get("up_ratio", 50), - "mood": market.get("mood", "neutral"), - "top_gainers": top_gainers, - "top_losers": top_losers, - "total_sectors": market.get("total_sectors", 0), - "market_timestamp": market.get("timestamp", ""), - } - except Exception: - return { - "sector_perf": {}, - "breadth": 50, - "mood": "neutral", - "top_gainers": {}, - "top_losers": {}, - "total_sectors": 0, - "market_timestamp": "", - } - - -def compute_sector_adjustment(code, market_ctx, stock_sector_map): - """根据个股所属行业的市场表现+小果情感,返回调整系数 - - 返回 dict: - stop_bias: 止损调整系数(<1.0收紧, >1.0放宽) - target_bias: 止盈调整系数 - note: 行业背景一句话 - sector_name: 匹配到的行业名称 - sector_change: 行业涨跌幅 - """ - # 默认无调整 - adj = {"stop_bias": 1.0, "target_bias": 1.0, "note": "", - "sector_name": "", "sector_change": 0} - - sectors_for_code = stock_sector_map.get(code, []) - if not sectors_for_code: - return adj - - sector_perf = market_ctx.get("sector_perf", {}) - breadth = market_ctx.get("breadth", 50) - - # 找第一个能匹配到的行业 - for sec in sectors_for_code: - if sec in sector_perf: - perf = sector_perf[sec] - chg = perf.get("change", 0) - adj["sector_name"] = sec - adj["sector_change"] = chg - - # 行业暴跌 > 3% - if chg <= -3: - adj["stop_bias"] = 0.92 # 止损收紧8% - adj["target_bias"] = 0.90 # 止盈下调10% - adj["note"] = f"行业{sec}大跌{chg:+.1f}%,收紧止损" - # 行业大跌 1~3% - elif chg <= -1: - adj["stop_bias"] = 0.96 - adj["target_bias"] = 0.95 - adj["note"] = f"行业{sec}下跌{chg:+.1f}%,适度防御" - # 行业大涨 > 3% - elif chg >= 3: - adj["stop_bias"] = 1.05 # 止损放宽5%(给趋势空间) - adj["target_bias"] = 1.03 - adj["note"] = f"行业{sec}大涨{chg:+.1f}%,可适度积极" - # 行业上涨 1~3% - elif chg >= 1: - adj["stop_bias"] = 1.02 - adj["note"] = f"行业{sec}上涨{chg:+.1f}%,正常" - else: - adj["note"] = f"行业{sec}{chg:+.1f}%,中性" - break - # 尝试处理命名差异:market.json中的行业名可能多了"板块"后缀 - for market_sec_name in sector_perf: - if sec in market_sec_name or market_sec_name in sec: - perf = sector_perf[market_sec_name] - chg = perf.get("change", 0) - adj["sector_name"] = market_sec_name - adj["sector_change"] = chg - if chg <= -3: - adj["stop_bias"] = 0.92 - adj["target_bias"] = 0.90 - adj["note"] = f"行业{market_sec_name}大跌{chg:+.1f}%,收紧止损" - elif chg <= -1: - adj["stop_bias"] = 0.96 - adj["target_bias"] = 0.95 - adj["note"] = f"行业{market_sec_name}下跌{chg:+.1f}%,适度防御" - elif chg >= 3: - adj["stop_bias"] = 1.05 - adj["target_bias"] = 1.03 - adj["note"] = f"行业{market_sec_name}大涨{chg:+.1f}%,可适度积极" - elif chg >= 1: - adj["stop_bias"] = 1.02 - adj["note"] = f"行业{market_sec_name}上涨{chg:+.1f}%,正常" - else: - adj["note"] = f"行业{market_sec_name}{chg:+.1f}%,中性" - break - - # 如果breath<30% (大盘极弱),再加一层收紧 - if breadth < 30: - adj["stop_bias"] *= 0.97 # 再收紧3% - breadth_note = "大盘仅{}%个股上涨".format(int(breadth)) - adj["note"] = (adj["note"] + " | " + breadth_note) if adj["note"] else breadth_note - elif breadth < 40: - adj["stop_bias"] *= 0.99 - breadth_note = "大盘偏弱({}%上涨)".format(int(breadth)) - adj["note"] = (adj["note"] + " | " + breadth_note) if adj["note"] else breadth_note - - # 小果情感约束:利空置信度>80%时收紧止损 - try: - xiaoguo_path = "/home/hmo/web-dashboard/data/xiaoguo_sentiment.json" - if os.path.exists(xiaoguo_path): - xg = json.load(open(xiaoguo_path)) - stock_sentiment = xg.get("stocks", {}).get(code, {}) - if stock_sentiment: - sentiment = stock_sentiment.get("sentiment", "") - confidence = stock_sentiment.get("confidence", 0) - summary = stock_sentiment.get("summary", "") - if sentiment == "negative" and confidence > 0.8: - adj["stop_bias"] = min(adj["stop_bias"], 0.95) - adj["note"] += f" | 小果利空{confidence:.0%}:{summary[:30]}" - except Exception: - pass - - return adj - - -def load_macro_context(): - """读取宏观上下文,返回 (bias, desc),优先 DB,回退 JSON""" - try: - import sqlite3 - from pathlib import Path - conn = sqlite3.connect(str(Path(__file__).parent.parent / "data" / "mofin.db")) - row = conn.execute( - "SELECT indices, structure FROM macro_context_log " - "WHERE has_valid_data=1 ORDER BY created_at DESC LIMIT 1" - ).fetchone() - conn.close() - if row: - indices = json.loads(row[0]) if row[0] else {} - structure = json.loads(row[1]) if row[1] else {} - overall = structure.get("overall", "neutral") - desc = structure.get("description", "") - else: - raise ValueError("no db data") - except Exception: - try: - with open(MACRO_CONTEXT_PATH) as f: - ctx = json.load(f) - overall = ctx.get("structure", {}).get("overall", "neutral") - desc = ctx.get("structure", {}).get("description", "") - except Exception: - return 1.0, "宏观未加载" - if "bearish" in overall: - return 0.8, f"宏观{desc}" - elif overall == "bullish": - return 1.05, f"宏观{desc}" - elif overall == "strong_bullish": - return 1.1, f"宏观{desc}" - else: - return 1.0, f"宏观{desc}" - - -def batch_fetch_prices(codes): - """批量获取实时价格,合并为一次API调用(自动分批,每批15只)""" - if not codes: - return {} - - # 分批处理,避免单次请求过大导致超时 - batch_size = 15 - all_results = {} - for batch_start in range(0, len(codes), batch_size): - batch = codes[batch_start:batch_start + batch_size] - symbols = [] - code_map = {} - for raw_code in batch: - raw_code = str(raw_code).split('_')[0] - if not raw_code: - continue - if len(raw_code) == 5 and raw_code.isdigit(): - prefix = "hk" - elif raw_code.startswith(("6", "5")): - prefix = "sh" - else: - prefix = "sz" - sym = f"{prefix}{raw_code}" - symbols.append(sym) - code_map[sym] = raw_code - if not symbols: - continue - - url = f"http://qt.gtimg.cn/q={','.join(symbols)}" - max_retries = 2 - for attempt in range(max_retries + 1): - try: - r = urllib.request.urlopen(url, timeout=10) - text = r.read().decode("gbk") - except Exception as e: - if attempt < max_retries: - continue - print(f" batch_fetch_prices error: {e}", file=sys.stderr) - continue - - for line in text.strip().split("\n"): - line = line.strip() - if not line or "=" not in line: - continue - try: - sym = line.split("=", 1)[0].strip().lstrip("v_") - raw_value = line.split("=", 1)[1].strip().strip('"').strip(";") - fields = raw_value.split("~") - if len(fields) < 35: - continue - orig_code = code_map.get(sym) - if not orig_code: - continue - def f(i): - try: - return float(fields[i]) if fields[i].strip() else 0.0 - except: - return 0.0 - all_results[orig_code] = { - "price": f(3), "close": f(4), "high": f(33), "low": f(34), - "code": orig_code, - } - except Exception: - continue - break # Success - break retry loop - - return all_results - - -def get_price_tencent(code): - """获取实时价格,港股转CNY统一存CNY""" - try: - from mo_models import to_cny, is_hk_stock - except ImportError: - to_cny = lambda v, r=None: v - is_hk_stock = lambda c: len(str(c).strip()) == 5 and str(c).strip().isdigit() - try: - raw_code = code.split('_')[0] - if not raw_code: - return None - if is_hk_stock(raw_code): - prefix = "hk" - elif raw_code.startswith("6") or raw_code.startswith("5"): - prefix = "sh" - else: - prefix = "sz" - url = f"http://qt.gtimg.cn/q={prefix}{raw_code}" - r = urllib.request.urlopen(url, timeout=5) - fields = r.read().decode("gbk").split('"')[1].split("~") - def f(i): - try: - return float(fields[i]) if fields[i].strip() else 0.0 - except: - return 0.0 - price = f(3) - if is_hk_stock(raw_code) and price > 0: - price = to_cny(price) - return { - "price": price, "close": f(4), "high": f(33), "low": f(34), - "code": raw_code, - } - except Exception as e: - print(f" get_price error {code}: {e}", file=sys.stderr) - return None - - -def reassess_strategy(code, name, price, cost, shares, current_action, - volume_signal="", sentiment="neutral", - is_watchlist=False): - """根据技术分析重评策略""" - - tech = ta.full_analysis(code) - if tech and "support_resistance" in tech: - sr = tech["support_resistance"] - candle = tech.get("candlestick", {}) - vol = tech.get("volume", {}) - ss = sr.get("strong_support") - ws = sr.get("weak_support") - wr = sr.get("weak_resist") - sr_resist = sr.get("strong_resist") - pivot = sr.get("pivot") - effective_range = sr.get("effective_range") - print(f" TECH: 强撑={ss} 弱撑={ws} 枢轴={pivot} 弱压={wr} 强压={sr_resist} 有效区间={effective_range}") - else: - print(f" ⚠️ 技术分析不可用", file=sys.stderr) - ss = ws = wr = sr_resist = pivot = None - candle = {} - vol = {} - - # ----- 多周期技术分析(周线/月线/均线) ----- - mtf_analysis = {} - mtf_adj = {} - try: - mtf_result = mtf.full_multi_tf_analysis(code) - if mtf_result.get("daily") and mtf_result["daily"].get("count", 0) >= 5: - mtf_analysis = mtf_result - mtf_adj = mtf_result.get("strategy_adjustment", {}) - daily_mas = mtf_result.get("daily", {}).get("mas", {}) - weekly = mtf_result.get("weekly", {}) - monthly = mtf_result.get("monthly", {}) - trend_align = mtf_adj.get("trend_alignment", "未知") - print(f" 多周期: {trend_align} | " - f"MA5={daily_mas.get('ma5','?')} MA20={daily_mas.get('ma20','?')} MA60={daily_mas.get('ma60','?')} | " - f"周线{weekly.get('trend',{}).get('description','?')} 月线{monthly.get('trend',{}).get('description','?')}") - except Exception as e: - print(f" 多周期分析失败: {e}", file=sys.stderr) - - profit_pct = (price - cost) / cost * 100 if cost else 0 - is_new_entry = (cost == 0) or (shares == 0) - is_deep_loss = profit_pct < -20 - - # ----- 股票分类(短炒/中短线/中长线/弱势/深套) ----- - stock_category = "中短线" - time_horizon = "2周~3月" - position_advice = "中等仓位" - try: - mtf_cache = json.load(open("/home/hmo/web-dashboard/data/multi_tf_cache.json")) - stock_data = mtf_cache.get(code, {}) - daily_klines = stock_data.get("daily", []) - fund = stock_data.get("fundamentals", {}) - closes = [d["close"] for d in daily_klines] if daily_klines else [] - - if len(closes) >= 10: - cur = closes[-1] - ma20 = sum(closes[-20:])/20 if len(closes)>=20 else 0 - ma60 = sum(closes[-60:])/60 if len(closes)>=60 else 0 - highs = [d["high"] for d in daily_klines[-20:]] - lows = [d["low"] for d in daily_klines[-20:]] - volatility = ((max(highs)-min(lows))/min(lows)*100) if min(lows)>0 else 0 - pe = fund.get("pe") or 0 - eps = fund.get("eps") or 0 - mcap = fund.get("mcap_total") or 0 - is_high_vol = volatility > 30 - is_high_pe = pe > 100 or pe < 0 - is_value = 0 < pe < 20 and eps > 0.5 - - if is_deep_loss: - stock_category = "深套" - time_horizon = "长期" - position_advice = "不补不割" - elif is_high_vol and is_high_pe: - stock_category = "短炒" - time_horizon = "数日~2周" - position_advice = "小仓快进快出" - elif cur < ma20 and cur < ma60 and ma20 > 0: - stock_category = "弱势" - time_horizon = "观望" - position_advice = "减仓或观望" - elif (is_value or mcap > 1000) and cur > ma20: - stock_category = "中长线" - time_horizon = "数月~1年" - position_advice = "正常配置" - elif volatility > 20: - stock_category = "中短线" - time_horizon = "2~6周" - position_advice = "中等仓位" - except Exception: - pass - - print(f" 分类: {stock_category} | {time_horizon} | {position_advice}") - - # ----- 短炒+强趋势检测:短炒分类但多周期多头时用移动止损代替弱支撑止损 ----- - is_short_term_strong_trend = False - if stock_category == "短炒": - trend_align = mtf_adj.get("trend_alignment", "") - strong_trend_indicators = ["多周期看多", "多周期多头", "上升"] - if any(ind in trend_align for ind in strong_trend_indicators): - is_short_term_strong_trend = True - print(f" ⚡ 短炒+强趋势检测: 趋势={trend_align} → 启用移动止损, 不止盈") - position_advice = "小仓强趋势让利润跑" - - # ----- 止损设置(含最小距离3%保护) ----- - if is_new_entry: - # 新买入推荐:止损 = 弱支撑(约2-3%跌幅,合理可控) - if ws and ws > 0: - new_stop = round(ws, 2) - else: - new_stop = round(price * 0.96, 2) - elif is_deep_loss: - # 深套:止损 = 强支撑再下移(不轻易割) - if ss and ss > 0: - new_stop = round(min(ss, price * 0.85), 2) - else: - new_stop = round(price * 0.85, 2) - else: - # 已持仓正常:止损 = 强支撑 - if is_short_term_strong_trend: - # 短炒+强趋势:用移动止损(距现价-5%),不止盈让利润跑 - trailing_sl = round(max(ws or 0, price * 0.95), 2) if ws else round(price * 0.95, 2) - new_stop = trailing_sl - print(f" 短炒强趋势移动止损: {new_stop} (距现价-{(1-new_stop/price)*100:.1f}%)") - elif ss and ss > 0: - new_stop = round(ss, 2) - else: - new_stop = round(price * 0.88, 2) - - # 已盈利仓位(>5%):用较紧的移动止损保护利润,但不超过成本线 - if profit_pct > 5 and not is_new_entry and not is_deep_loss: - # 取 max(弱支撑, 成本线, 当前价×0.95) 作为止损 - cost_protect = cost if cost > 0 else 0 - trailing_stop = round(max(ws or 0, cost_protect, price * 0.95), 2) - if trailing_stop > new_stop: - new_stop = trailing_stop - print(f" 已启用移动止损: {new_stop} (保护+{profit_pct:.1f}%利润)", file=sys.stderr) - - # 最小止损距离 —— 随趋势强度调整(2026-06-23 震度保护规则) - # 强趋势(多周期看多 + MA多头排列):最小1.5%下行空间 - # 普通/弱势:最小3%下行空间 - is_strong_trend = False - trend_align = mtf_adj.get("trend_alignment", "") - strong_trend_indicators = ["多周期看多", "多周期多头", "上升"] - try: - if any(ind in trend_align for ind in strong_trend_indicators) and ma20 > ma60 and cur >= ma20: - is_strong_trend = True - except (NameError, TypeError): - pass # ma20/ma60/cur may be unbound if MTF data insufficient - - if is_strong_trend: - min_stop_gap = 0.015 # 1.5% - else: - min_stop_gap = 0.03 # 3% - - min_stop = round(price * (1 - min_stop_gap), 2) - if new_stop > min_stop and not is_deep_loss: - old_stop = new_stop - new_stop = min_stop - if old_stop != new_stop: - print(f" 最小止损 {round(min_stop_gap*100)}%间距约束: {old_stop}→{new_stop} (趋势{'强' if is_strong_trend else '普通'})") - - # 港股附加:ATR波动率校验 — 止损距现价不得小于 1×ATR(14) - if is_hk_stock(code): - atr = calc_atr(code) - if atr and atr > 0: - min_atr_stop = round(price - atr, 2) - if new_stop > min_atr_stop: - old_stop_val = new_stop - new_stop = min_atr_stop - print(f" 港股ATR波动率校验({atr:.2f}): 止损 {old_stop_val}→{new_stop} (1×ATR间距)") - - # ----- 止盈设置 ----- - if is_short_term_strong_trend and not is_new_entry: - # 短炒+强趋势:不止盈让利润跑 - mtf_tp = mtf_adj.get("take_profit_reference", {}) - if mtf_tp and mtf_tp.get("level", 0) > price * 1.2: - new_target = round(mtf_tp["level"], 2) - else: - new_target = 0 # 无多周期阻力时不编造止盈 - print(f" 短炒强趋势不止盈: 止盈设为{new_target} (+{(new_target/price-1)*100:.0f}%)") - elif sr_resist and sr_resist > 0: - new_target = round(sr_resist, 2) - else: - new_target = 0 # 无技术面数据时不编造止盈 - - # ----- 风险回报比校验 ----- - stop_distance = price - new_stop if price > new_stop else price * 0.02 - target_distance = new_target - price if new_target > price else 0 - - # 1:2 检查 - min_target_distance = stop_distance * 2.0 - if target_distance < min_target_distance: - # 尝试更高的阻力位,但不超过下一个真实压力位 - candidate_targets = [] - if wr and wr > price and wr != sr_resist: - candidate_targets.append(wr) - if sr_resist and sr_resist > price: - candidate_targets.append(sr_resist) - # 检查有效区间,如果有更高的自然目标位 - if effective_range and price < effective_range * 0.9: - candidate_targets.append(effective_range) - - found = False - for level in candidate_targets: - if (level - price) >= min_target_distance: - new_target = level - found = True - break - - # 如果仍然不满足,检查是否至少能到 1:1.5 - min15_distance = stop_distance * 1.5 - if not found: - for level in candidate_targets: - if (level - price) >= min15_distance: - new_target = level - found = True - break - - # ----- 风险回报比最终计算 ----- - risk = max(price - new_stop, price * 0.01) - reward = max(new_target - price, 0) - rr_ratio = reward / risk if risk > 0 else 0 - - # ----- 状态判断 ----- - if is_deep_loss: - status = "updated" - action_note = "深套持有" - elif is_new_entry: - if rr_ratio < 1.5: - status = "review" - action_note = "⚠️盈亏比不足1:1.5,不建议买入" - elif rr_ratio < 2.0: - status = "updated" - action_note = "⚠️盈亏比偏低(1:{:.1f}),谨慎买入".format(rr_ratio) - else: - status = "updated" - action_note = "" - else: - if rr_ratio < 0.5: - status = "updated" - action_note = "⚠️盈亏比极低,关注" - elif rr_ratio < 1.5: - status = "updated" - action_note = "⚠️盈亏比偏低(1:{:.1f}),不建议加仓".format(rr_ratio) - else: - status = "updated" - action_note = "" - - # 短炒+强趋势:在action_note追加标记 - if is_short_term_strong_trend and not is_new_entry and not is_deep_loss: - extra_note = "短炒强趋势持" if "深套" not in action_note else "" - if extra_note: - action_note = f"{action_note} | {extra_note}" if action_note else extra_note - - # ----- 买入区间(有盈亏比严格约束) ----- - max_acceptable_entry = None # 最大可接受买入价(满足R/R约束) - - if new_target and new_stop and new_target > new_stop and not is_deep_loss: - # 买入价的R/R约束: - # 要求 (target - entry) / (entry - stop) >= min_rr - # 即 entry <= (target + min_rr * stop) / (1 + min_rr) - min_rr = 1.0 # 至少1:1,才不亏 - recommend_rr = 1.5 # 推荐1:1.5以上 - - max_for_recommend = (new_target + recommend_rr * new_stop) / (1 + recommend_rr) - max_for_neutral = (new_target + min_rr * new_stop) / (1 + min_rr) - - if is_new_entry: - # 新买入:要求1:1.5+ - max_acceptable_entry = max_for_recommend - else: - # 已持仓加仓:至少1:1 - max_acceptable_entry = max_for_neutral - - if is_new_entry: - # 新买入:买入区 = 弱支撑附近(不是当前价附近!) - # 只在价格跌到弱支撑附近时才推买入 - entry_low = round(price * 0.98, 2) - entry_high = round(price * 1.02, 2) - if max_acceptable_entry and entry_high > max_acceptable_entry: - entry_high = round(max_acceptable_entry, 2) - # 确保买入区不小于1% - if entry_high - entry_low < price * 0.01: - if max_acceptable_entry and price <= max_acceptable_entry: - entry_low = round(max(price * 0.99, new_stop), 2) - entry_high = round(min(price * 1.01, max_acceptable_entry), 2) - elif ws and ws > 0 and wr and wr > 0 and not is_deep_loss: - # 已持仓正常:买入区 = 弱支撑~弱支撑上方5%(给合理回调空间) - # 上限不能低于成本价×0.95(保护已有持仓不被高位逼空) - entry_low = round(ws, 2) - entry_max = round(ws * 1.05, 2) # 比弱支撑高5%,有足够空间 - # 如果当前价已远离买入区,保持买入区不变(不因价格涨了就收窄) - min_upper = round(cost * 0.95, 2) if cost > 0 else 0 - if entry_max < min_upper: - entry_max = min_upper - if max_acceptable_entry: - entry_high = round(min(entry_max, max_acceptable_entry), 2) - else: - entry_high = entry_max - # 如果当前价已远离买入区(高于买入区上沿),禁止加仓推荐 - if price > entry_high: - # 买入区锁定在弱支撑位,但标记为"价格远离" - pass - # 如果买入区过窄,标记但不扩展(加仓必须在支撑位) - if entry_high - entry_low < price * 0.005: - entry_low = round(ws * 0.995, 2) - entry_high = round(ws * 1.005, 2) - else: - entry_low = round(price * 0.90, 2) - entry_high = round(price * 1.05, 2) - - # 买入区间稳定性保护:上边界单次变动不超过5% - if 'entry_high' in dir() and entry_high: - # 读取当前策略中已有的买入区上界,如果有且变化过大则限制 - old_entry_high = None - if 'current_action' in dir() and current_action: - import re - m = re.search(r'买入区[\d.]+~([\d.]+)', current_action) - if m: - old_entry_high = float(m.group(1)) - if old_entry_high and old_entry_high > 0: - max_change = old_entry_high * 0.95 # 单次最多下降5% - if entry_high < max_change: - entry_high = round(max_change, 2) - - # ----- 买入时机信号(三维分析:大盘+行业+个股,基本面+消息面+技术面+资金流)----- - # [2026-07-01] 扩展:不再只看volume_signal + candlestick_sentiment - # 融合大盘趋势、行业板块强弱、基本面估值作为修正因子 - volume_signal = vol.get("volume_signal", "") - candlestick_sentiment = candle.get("sentiment", "neutral") - timing_signal = "neutral" - - # --- 三维分析数据装载 --- - # 因子1: 大盘环境(从macro_context_log读) - market_bearish = False - market_bullish = False - try: - import sqlite3 - _db = sqlite3.connect("/home/hmo/MoFin/data/mofin.db", timeout=5) - _mc = _db.execute( - "SELECT structure FROM macro_context_log WHERE has_valid_data=1 ORDER BY rowid DESC LIMIT 1" - ).fetchone() - if _mc and _mc[0]: - _s = json.loads(_mc[0]) - _overall = _s.get("overall", "") - if "bearish" in _overall: - market_bearish = True - elif _overall == "bullish": - market_bullish = True - _db.close() - except Exception: - pass - - # 因子2: 行业板块强弱 - sector_strong = False - sector_weak = False - try: - _db2 = sqlite3.connect("/home/hmo/MoFin/data/mofin.db", timeout=5) - _rows2 = _db2.execute( - "SELECT name, change_pct FROM sector_snapshots ORDER BY change_pct DESC" - ).fetchall() - if _rows2: - # 找到该股所属行业(简单匹配name或通过stock_sectors) - _my_sectors = _db2.execute( - "SELECT sector_name FROM stock_sectors WHERE code=?", - (code,) - ).fetchall() - if _my_sectors: - for (_sn,) in _my_sectors: - for r_name, r_chg in _rows2: - if _sn in r_name or r_name in _sn: - _rank = [r[0] for r in _rows2].index(r_name) if r_name in [x[0] for x in _rows2] else -1 - _total = len(_rows2) - if _rank >= 0: - if _rank < _total * 0.2: - sector_strong = True - if _rank > _total * 0.8: - sector_weak = True - break - _db2.close() - except Exception: - pass - - # 因子3: 基本面估值 - is_value_stock = False - try: - _db3 = sqlite3.connect("/home/hmo/MoFin/data/mofin.db", timeout=5) - _fd = _db3.execute( - "SELECT pe, eps FROM stock_fundamentals WHERE code=?", (code,) - ).fetchone() - if _fd: - _pe, _eps = _fd - is_value_stock = (0 < (_pe or 0) < 25 and (_eps or 0) > 0.3) - _db3.close() - except Exception: - pass - - # --- 三维修正规则 --- - # 大盘偏弱时收紧买入信号,大盘偏强时放宽 - # 行业领先加分,行业落后减分 - # 低估值加分(有安全边际) - - def _adjust_timing(signal, market_b, market_bb, sec_s, sec_w, is_val): - """根据三维因子修正 timing_signal""" - # 大盘偏弱时降级买入信号 - if market_b: - if signal in ("买入", "加仓"): - if not sec_s: # 大盘弱+行业不强→降级 - return "关注" - # 大盘偏强时放宽 - if market_bb: - if signal == "关注" and (sec_s or is_val): - return "买入" - # 行业弱势时降级买入信号 - if sec_w: - if signal in ("买入", "加仓"): - return "关注" - # 行业强势+低估时升级关注 - if sec_s and is_val: - if signal == "关注": - return "买入" - return signal - - if is_new_entry: - # 新买入时机 - if volume_signal == "主动买盘占优" and candlestick_sentiment == "bullish": - timing_signal = "买入" - elif volume_signal == "主动卖盘占优": - timing_signal = "观望" - elif volume_signal == "买卖均衡" and ws and price <= ws * 1.03: - timing_signal = "买入" - elif candlestick_sentiment == "bullish": - timing_signal = "买入" - elif ws and price < ws * 1.02: - timing_signal = "关注" - # 新买入时三维修正:大盘向上+行业强→升级,大盘弱→降级 - _pre_signal = timing_signal - timing_signal = _adjust_timing(timing_signal, market_bearish, market_bullish, - sector_strong, sector_weak, is_value_stock) - if timing_signal != _pre_signal: - print(f" 三维修正(新入): {_pre_signal}→{timing_signal} " - f"| 大盘{'弱' if market_bearish else '强' if market_bullish else '中性'}" - f"| 行业{'强' if sector_strong else '弱' if sector_weak else '中性'}" - f"| 估值{'低' if is_value_stock else '一般'}") - else: - # 已持仓时机(用于加仓/减仓参考) - if is_short_term_strong_trend: - # 短炒+强趋势:强趋势持有,禁止加仓信号 - timing_signal = "持有" - elif profit_pct > 5: - # 已盈利 - if volume_signal == "主动买盘占优": - timing_signal = "持有" - elif volume_signal == "主动卖盘占优" and not is_new_entry: - timing_signal = "关注" - else: - timing_signal = "持有" - elif profit_pct > 0: - # 微盈 - if volume_signal == "主动买盘占优": - timing_signal = "持有" - elif ws and price <= ws * 1.02: - timing_signal = "加仓" - else: - timing_signal = "持有" - else: - # 浮亏 - if volume_signal == "主动卖盘占优" and ss and price <= ss * 1.03: - timing_signal = "关注" - elif volume_signal == "主动买盘占优" and sr_resist and price >= sr_resist * 0.97: - timing_signal = "关注" - elif volume_signal == "买卖均衡" and ws and price <= ws * 1.02: - timing_signal = "加仓" - else: - timing_signal = "持有" - - # ----- 【v3.2新增】分类约束:弱势/深套禁止输出买入/加仓类信号 ----- - if stock_category == "弱势" or is_deep_loss: - buy_signals = ["买入", "加仓", "可追"] - if any(s in timing_signal for s in buy_signals): - old_signal = timing_signal - timing_signal = "弱势持有" if stock_category == "弱势" else "深套持有" - print(f" 分类约束: {stock_category} 原信号\"{old_signal}\" → \"{timing_signal}\"") - - # ----- 构造 action 描述(供 cron prompt 使用) ----- - action_parts = [] - if profit_pct < -20: - action_parts.append("深套持有") - elif profit_pct < -10: - action_parts.append("持有观察") - elif profit_pct < 0: - action_parts.append("持有观察") - elif profit_pct < 5: - action_parts.append("盈利持有") - else: - action_parts.append("盈利良好") - - if action_note: - action_parts.append(action_note) - - if is_watchlist: - # 自选股(未入场):有止损参考+买入区,内部算RR需要止盈位 - action_parts.append(f"目标参考{new_target}") - action_parts.append(f"止损参考{new_stop}") - action_parts.append(f"买入区{entry_low}~{entry_high}") - elif is_new_entry: - action_parts.append(f"损{new_stop}") - action_parts.append(f"盈{new_target}") - action_parts.append(f"买{entry_low}~{entry_high}") - else: - action_parts.append(f"止损{new_stop}") - action_parts.append(f"目标{new_target}") - action_parts.append(f"买入区{entry_low}~{entry_high}") - - if timing_signal != "neutral": - action_parts.append(f"信号:{timing_signal}") - - new_action = " | ".join(action_parts) - - # 技术面快照 - tech_snapshot = "" - if candle: - tech_snapshot = (f"形态:{candle.get('pattern','?')}/{candle.get('sentiment','?')} " - f"量价:{vol.get('volume_signal','?')} " - f"强撑:{ss} 弱撑:{ws} 弱压:{wr} 强压:{sr_resist}") - # 加入均线信息(如果可用) - try: - dm = mtf_analysis.get("daily", {}).get("mas", {}) - ma_parts = [] - for m in ['ma5', 'ma10', 'ma20', 'ma60']: - v = dm.get(m) - if v: - ma_parts.append(f"{m.upper()}={v}") - if ma_parts: - tech_snapshot += " | " + " ".join(ma_parts) - except (NameError, AttributeError): - pass - - # 多周期快照(追加到 tech_snapshot) - mtf_context = "" - if mtf_adj: - trend_align = mtf_adj.get("trend_alignment", "") - daily_mas = mtf_analysis.get("daily", {}).get("mas", {}) - ma20 = daily_mas.get("ma20") - ma60 = daily_mas.get("ma60") - stop_ref = mtf_adj.get("stop_loss_reference", {}) - take_ref = mtf_adj.get("take_profit_reference", {}) - - parts = [] - if trend_align: - parts.append(trend_align) - if ma20: - parts.append(f"MA20={ma20}") - if ma60: - parts.append(f"MA60={ma60}") - if stop_ref: - parts.append(f"长撑:{stop_ref.get('source','?')}={stop_ref['level']}") - if take_ref: - parts.append(f"长压:{take_ref.get('source','?')}={take_ref['level']}") - mtf_context = " | ".join(parts) - - now_str = datetime.now().strftime('%Y-%m-%d %H:%M') - return { - 'stop_loss': new_stop, - 'take_profit': new_target, - 'entry_low': entry_low, - 'entry_high': entry_high, - 'action': new_action, - 'status': status, - 'tech_snapshot': tech_snapshot, - 'timing_signal': timing_signal, - 'rr_ratio': round(rr_ratio, 2), - 'action_note': action_note, - 'reassessed_at': now_str, - 'multi_tf_context': mtf_context, # 多周期上下文 - 'stock_category': stock_category, # 股票分类:短炒/中短线/中长线/弱势/深套 - 'time_horizon': time_horizon, # 时间跨度 - 'position_advice': position_advice, # 仓位建议 - } - - -def load_stock_news_sentiment(code): - """加载小果消息面情感""" - try: - path = "/home/hmo/web-dashboard/data/xiaoguo_sentiment.json" - if not os.path.exists(path): - return {} - xg = json.load(open(path)) - return xg.get("stocks", {}).get(code, {}) - except Exception: - return {} - - -def load_fundamentals(code): - """加载个股基本面""" - try: - path = "/home/hmo/web-dashboard/data/multi_tf_cache.json" - if not os.path.exists(path): - return {} - m = json.load(open(path)) - return m.get(code, {}).get("fundamentals", {}) or {} - except Exception: - return {} - - -def _get_portfolio_risk_state(): - """读取 portfolio 组合风险状态(2026-06-23 引擎协调)""" - try: - # 数据一致性检查:警告多副本(2026-06-23 bugfix) - _check_portfolio_consistency() - p = json.load(open('/home/hmo/web-dashboard/data/portfolio.json')) - pos_pct = p.get('position_pct', 0) - cash = p.get('cash', 0) - holdings = p.get('holdings', []) - weak_cnt = sum(1 for h in holdings if h.get('change_pct', 0) < -15) - total = len(holdings) or 1 - weak_ratio = weak_cnt / total - return { - 'position_pct': pos_pct, - 'cash': cash, - 'is_high_position': pos_pct > 80, - 'is_very_high_position': pos_pct > 90, - 'is_high_weak': weak_ratio > 0.35, - 'weak_ratio': round(weak_ratio * 100), - 'total_holdings': total, - } - except: - return {} - - -def _is_buy_signal(signal): - """判断信号是否为买入/持有类(用于防洗盘)""" - if not signal: - return False - buy_keywords = ['买入', '持有', '加仓', '关注'] - for kw in buy_keywords: - if kw in signal: - return True - return False - - -def _check_portfolio_consistency(): - """数据一致性检查:如果存在多份 portfolio.json 则报警(2026-06-23 bugfix)""" - main = '/home/hmo/web-dashboard/data/portfolio.json' - main_cash = None - try: - import json - main_cash = json.load(open(main)).get('cash') - except Exception: - return - for path in [ - '/home/hmo/data/portfolio.json', - '/home/hmo/projects/MoFin/data/portfolio.json', - '/home/hmo/web-dashboard.bak/data/portfolio.json', - ]: - if os.path.exists(path): - try: - other = json.load(open(path)) - if other.get('cash') != main_cash: - print(f"⚠️ 数据一致性: {os.path.realpath(path)} cash={other.get('cash')} ≠ 主文件 cash={main_cash} (需清理)", file=sys.stderr) - except Exception: - pass - - -def _check_contradiction(code, today_only=True): - """反馈循环核——检查本股是否有刚卖出的记录 - - 返回 dict or None: - - sold_reason: 'portfolio_trim'|'stop_loss' - - sold_at: 卖出日期 - - days_ago: 卖出距今交易日数 - - is_today: 是否今日卖出 - - tag: 追加到信号的标注 - """ - try: - from datetime import datetime, date - dec = json.load(open('/home/hmo/web-dashboard/data/decisions.json')) - for e in dec.get('decisions', []): - if e.get('code') != code: - continue - sold_at = e.get('sold_at', '') - if not sold_at: - return None - try: - sd = datetime.strptime(sold_at, '%Y-%m-%d').date() - td = date.today() - days = (td - sd).days - except: - return None - - reason = e.get('sold_reason', 'portfolio_trim') - if reason == 'stop_loss': - tag = '止损离场(逻辑破坏,短期不关注)' - else: - tag = '组合减仓后关注(已清仓,等回踩确认)' - - return { - 'sold_reason': reason, - 'sold_at': sold_at, - 'days_ago': days, - 'is_today': days == 0, - 'tag': tag, - } - except: - return None - return None - - -def _get_sell_priority_list(): - """减仓优先级排序:深套>亏损>微盈>盈利(2026-06-23 反馈循环) - - 返回 [(code, name, change_pct, position_pct, priority_label), ...] - 按卖出的优先顺序排列(最先应该卖的在最前) - """ - try: - p = json.load(open('/home/hmo/web-dashboard/data/portfolio.json')) - holdings = p.get('holdings', []) - ranked = [] - for h in holdings: - chg = h.get('change_pct', 0) - pos = h.get('position_pct', 0) - if chg < -30: - label = '深套(>30%),优先减' - rank = 0 - elif chg < -20: - label = '深套(>20%),优先减' - rank = 1 - elif chg < -10: - label = '亏损,建议减' - rank = 2 - elif chg < 0: - label = '微亏,可减' - rank = 3 - elif chg < 10: - label = '微盈,持有' - rank = 4 - else: - label = '盈利,最后减' - rank = 5 - ranked.append((rank, h['code'], h.get('name',''), chg, pos, label)) - ranked.sort(key=lambda x: (x[0], -x[4])) # 优先 rank, 其次仓位大优先 - return [{'code':c,'name':n,'change_pct':chg,'position_pct':pos,'label':l} - for r,c,n,chg,pos,l in ranked] - except: - return [] - - -def enrich_timing_signal(base_signal, macro_desc="", sector_note="", - profit_pct=0, stock_category="", is_new_entry=False, - fundamentals=None, news_sentiment=None, - timing_signal_override=None, - portfolio_context=None, - rr_ratio=0): # 2026-06-24 新参:盈亏比约束 - """多因子合成timing_signal——大盘+行业+基本面+技术+组合风险+盈亏比 - - 返回 (enriched_signal, factors_list) - - enriched_signal: 可读的多因子信号描述 - - factors_list: 各因子的摘要列表(用于后续显示) - """ - # 如果已手动设定,尊重手动 - if timing_signal_override and timing_signal_override != "neutral": - return timing_signal_override, [timing_signal_override] - - factors = [] - - # 1. 大盘因子 - if "偏强" in macro_desc or "大涨" in macro_desc or "bullish" in macro_desc.lower(): - macro_txt = "大盘偏强" - factors.append(macro_txt) - elif "偏弱" in macro_desc or "大跌" in macro_desc or "bearish" in macro_desc.lower(): - macro_txt = "大盘偏弱" - factors.append(macro_txt) - elif macro_desc and macro_desc != "宏观未加载": - factors.append("大盘中性") - - # 2. 行业因子 - if sector_note: - # 把"行业X大跌3%+"简化为"行业偏弱","行业X大涨3%+"简化为"行业偏强" - if "大跌" in sector_note or "下跌" in sector_note: - factors.append("行业偏弱") - elif "大涨" in sector_note: - factors.append("行业偏强") - elif "上涨" in sector_note: - factors.append("行业偏强") - else: - factors.append("行业中性") - - # 3. 基本面因子 - if fundamentals: - pe = fundamentals.get("pe", 0) - eps = fundamentals.get("eps", 0) - profit_growth = fundamentals.get("profit_growth", fundamentals.get("yoy_profit", "")) - revenue_growth = fundamentals.get("revenue_growth", fundamentals.get("yoy_revenue", "")) - mcap = fundamentals.get("mcap_total", 0) - - pe = pe or 0 - eps = eps or 0 - profit_growth_str = str(profit_growth or "") - revenue_growth_str = str(revenue_growth or "") - - # 净利增长 - for val in [profit_growth_str, revenue_growth_str]: - try: - v = float(val.replace("%", "").replace("+", "")) - if v > 50: - factors.append("净利增50%+") - break - elif v > 20: - factors.append(f"净利增{int(v)}%") - break - elif v < -20: - factors.append("净利降20%+") - break - except (ValueError, AttributeError): - continue - - # PE估值 - if 0 < pe < 15: - factors.append("低估值") - elif pe > 100 or pe < 0: - factors.append("高估值") - - # 市值 - if mcap and mcap > 5000: - factors.append("蓝筹") - - # 4. 消息面因子(小果情感) - if news_sentiment: - ns = news_sentiment.get("sentiment", "") - nc = news_sentiment.get("confidence", 0) - if ns == "positive" and nc >= 0.7: - kws = news_sentiment.get("keywords", []) - kw_str = f"({'/'.join(kws[:3])})" if kws else "" - factors.append(f"消息偏多{kw_str}") - elif ns == "negative" and nc >= 0.7: - kws = news_sentiment.get("keywords", []) - kw_str = f"({'/'.join(kws[:3])})" if kws else "" - factors.append(f"消息偏空{kw_str}") - - # 5. 技术面(基础信号) - if base_signal and base_signal != "neutral": - factors.append(base_signal) - - # 5.5 组合风险因子(2026-06-23 双引擎协调) - if portfolio_context and not is_new_entry: - if portfolio_context.get('is_very_high_position'): - factors.append("组合仓位极重(>90%)") - elif portfolio_context.get('is_high_position'): - factors.append("组合仓位偏重(>80%)") - if portfolio_context.get('is_high_weak'): - factors.append(f"弱势占{portfolio_context.get('weak_ratio')}%") - elif portfolio_context and is_new_entry: - # 新买入推荐:注明组合上下文 - if portfolio_context.get('is_high_position'): - factors.append(f"仓{portfolio_context.get('position_pct')}%现金有限") - elif portfolio_context.get('is_high_weak'): - factors.append("组合风险信号") - - # 5.7 盈亏比因子(2026-06-24 新增——RR<1.5降级买入信号) - if rr_ratio > 0: - if rr_ratio < 1.5: - factors.append(f"RR{rr_ratio}过低") - elif rr_ratio >= 3: - factors.append(f"RR{rr_ratio}") - # 1.5~3之间:中性,不特别标注 - - # 如果没有足够因素,返回信号不充分 - if not factors: - return "信号不充分", [] - - # 信号只应包含明确的买卖方向,不能从行业/大盘等上下文因子拼凑 - # base_signal 存在且非 neutral → 用 base_signal - # 否则 → 信号不充分(不拿 factors[-1] 当信号) - if base_signal and base_signal != "neutral": - clean_signal = base_signal - else: - # 从 factors 中找第一个有效的操作方向信号 - valid_direction = {"买入", "加仓", "观望", "持有", "关注", "信号不充分"} - signal_found = "" - for f in reversed(factors): - if f in valid_direction: - signal_found = f - break - clean_signal = signal_found if signal_found else "信号不充分" - - # 6. RR约束降级(2026-06-24 新增) - # 买入/加仓信号但RR<1.5 → 降级为"信号不充分" - buy_signals = {"买入", "加仓"} - if clean_signal in buy_signals and 0 < rr_ratio < 1.5: - clean_signal = "信号不充分" - factors.append("RR过低降级") - - return clean_signal, factors - - -def reassess_with_context(code, name, price, cost, shares, current_action, - volume_signal="", sentiment="neutral", is_watchlist=False): - """reassess_strategy + 多因子信号合成(大盘+行业+技术) - - 为 per_stock_reassess 等单只场景提供一站式多因子分析 - """ - result = reassess_strategy( - code, name, price, cost, shares, - current_action, volume_signal, sentiment, is_watchlist - ) - if not result: - return result - - # 加载宏观+行业+消息+基本面上下文 - try: - macro_bias, macro_desc = load_macro_context() - market_ctx = load_market_context() - stock_sector_map = load_stock_sector_map() - sector_adj = compute_sector_adjustment(code, market_ctx, stock_sector_map) - sector_note = sector_adj.get("note", "") - news_sentiment = load_stock_news_sentiment(code) - fund = load_fundamentals(code) - except Exception: - macro_desc = "" - sector_note = "" - news_sentiment = {} - fund = {} - - # ── DSA 集成:注入大盘复盘 + 新闻情报 ────────────────────────── - try: - from mo_bridge import enrich_analysis_context - region = "hk" if len(str(code)) == 5 and str(code)[0] in ('0','1') else "cn" - dsa_ctx = enrich_analysis_context(stock_code=code, stock_name=name, - region=region, include_news=True) - if dsa_ctx: - macro_desc = (macro_desc + "\n\n" + dsa_ctx).strip() - except Exception: - pass # DSA 不可用时静默跳过 - - enriched, factors = enrich_timing_signal( - base_signal=result.get("timing_signal", ""), - macro_desc=macro_desc, - sector_note=sector_note, - profit_pct=(price - cost) / cost * 100 if cost else 0, - stock_category=result.get("stock_category", ""), - is_new_entry=is_watchlist, - fundamentals=fund, - news_sentiment=news_sentiment, - portfolio_context=_get_portfolio_risk_state(), - rr_ratio=result.get("rr_ratio", 0), - ) - result["timing_signal"] = enriched - result["signal_factors"] = factors - - # 6. 防洗盘:信号不要一天一翻(2026-06-23) - # 如果旧信号是买入/持有类,新信号是谨慎/等待类,但中期趋势未破→维持旧信号 - try: - dec = json.load(open('/home/hmo/web-dashboard/data/decisions.json')) - for e in dec.get('decisions', []): - if e.get('code') == code: - old_signal = e.get('timing_signal', '') - if old_signal and _is_buy_signal(old_signal) and not _is_buy_signal(enriched): - # 中等趋势检查:MA5 > MA20 + 多周期看多 - mtf = result.get('multi_tf_context', '') - if '看多' in mtf or '多头' in mtf: - try: - closes = [float(k.split()[2]) for k in mtf.split('|') if 'MA5' in k] - except: - closes = [] - has_uptrend = 'MA5' in mtf and 'MA20' in mtf - if has_uptrend: - print(f" 防洗盘: {old_signal}→保持旧信号(中期趋势完整)") - result["timing_signal"] = f"{old_signal}(正常回调价稳)" - sf = result.get("signal_factors") or [] - if "正常回调价稳" not in sf: - result["signal_factors"] = sf + ["正常回调价稳"] - break - except Exception as e: - print(f" 防洗盘跳过: {e}") - - # 7. 反馈循环核:检查本股是否有刚卖出的记录(2026-06-23) - contradiction = _check_contradiction(code) - if contradiction and contradiction.get('is_today'): - # 今日刚卖出 → 不屏蔽信号,但必须自标注矛盾 - print(f" 反馈循环: {contradiction.get('tag')} (sold_at={contradiction.get('sold_at')})") - if _is_buy_signal(result.get('timing_signal', '')): - result['action_note'] = contradiction['tag'] - # 在 timing_signal 中追加反馈标注,供报告层可见 - curr_signal = result.get('timing_signal', '') - if '⚠️' not in curr_signal: - result['timing_signal'] = f"⚠️{contradiction['tag']}|{curr_signal}" - elif contradiction: - # 非今日卖出但近期卖出 → 标注已清仓 - print(f" 近期清仓: sold_at={contradiction.get('sold_at')} ({contradiction.get('days_ago')}日前)") - if _is_buy_signal(result.get('timing_signal', '')): - curr_signal = result.get('timing_signal', '') - if '已清仓' not in curr_signal: - result['timing_signal'] = f"已清仓,{curr_signal}" - - # 重建 action 文本(同步多因子信号) - try: - if new_action_needs_refresh(result, {"source": "auto"}, price): - _refresh_action_text(result, price, name) - except Exception: - pass - - return result - - -def new_action_needs_refresh(result, old_entry, price): - """判断宏观/行业调整后是否需要刷新action文本""" - # 自选股和手动策略不做调整,不需要刷新 - if old_entry.get("source") == "manual": - return False - return True - - -def _refresh_action_text(result, price, name): - """根据调整后的止损/止盈重建action文本""" - sl = result.get("stop_loss", 0) - tp = result.get("take_profit", 0) - el = result.get("entry_low", 0) - eh = result.get("entry_high", 0) - ts = result.get("timing_signal", "") - an = result.get("action_note", "") - old_action = result.get("action", "") - - # 保持原action的前缀(持有状态部分不变) - # action格式一般是: "状态 | 止损X | 目标Y | 买入区X~Y | 信号:Z" - parts = old_action.split(" | ") - new_parts = [] - for p in parts: - p = p.strip() - # 替换止损数字 - if p.startswith("止损") or p.startswith("止损参考"): - if sl: - p = f"止损{sl}" if "止损参考" not in old_action.split(" | ")[0] else f"止损参考{sl}" - # 替换目标/止盈数字 - if p.startswith("目标") or p.startswith("止盈"): - if tp: - p = f"目标{tp}" - # 替换买入区数字 - if "买入区" in p and "~" in p: - if el and eh: - p = f"买入区{el}~{eh}" - new_parts.append(p) - result["action"] = " | ".join(new_parts) - - -def check_sector_alerts(market_ctx, stock_sector_map, holdings, wl): - """行业轮动主动预警:检测板块崩盘级别信号→查持仓→输出预警 - - 返回 list of alerts: [{code, name, sector, chg, action}] - """ - alerts = [] - if not market_ctx: - return alerts - - sector_perf = market_ctx.get("sector_perf", {}) - - # 找出所有跌幅>3%的行业 - crashing_sectors = {name: data for name, data in sector_perf.items() - if data.get("change", 0) <= -3} - - if not crashing_sectors: - return alerts - - # 构建 code→持仓信息 的映射 - holding_map = {} - for h in holdings: - c = h.get("code", "") - if c: - holding_map[c] = {"name": h.get("name", c), "type": "持仓"} - for s in wl.get("stocks", []): - c = s.get("code", "") - if c and c not in holding_map: - holding_map[c] = {"name": s.get("name", c), "type": "自选"} - - # 对每个暴跌行业,查持仓中是否有股票属于该行业 - for sec_name, sec_data in sorted(crashing_sectors.items(), - key=lambda x: x[1].get("change", 0)): - chg = sec_data.get("change", 0) - for code, sectors in stock_sector_map.items(): - if code in holding_map and sec_name in sectors: - info = holding_map[code] - alerts.append({ - "code": code, - "name": info["name"], - "sector": sec_name, - "sector_change": chg, - "type": info["type"], - "action": f"行业{sec_name}跌{chg:+.1f}%,{info['type']}需关注", - }) - - alerts.sort(key=lambda a: a["sector_change"]) - return alerts - - -def regenerate_all(stdout=True): - """全量重评所有持仓+自选策略""" - # 优先从 SQLite 读取 - try: - from mofin_db import get_conn, query_holdings, query_watchlist - conn = get_conn() - holdings = query_holdings(conn) - wl_stocks = query_watchlist(conn) - conn.close() - pf = {"holdings": holdings} - wl = {"stocks": wl_stocks} - except Exception: - pf = safe_json_load(PORTFOLIO_PATH, {}) - wl = safe_json_load(WATCHLIST_PATH, {}) - - all_stocks = {} - for item in pf.get("holdings", []): - code = item.get("code", "") - if code: - all_stocks[code] = {"source": "portfolio", "data": item} - for item in wl.get("stocks", []): - code = item.get("code", "") - if code and code not in all_stocks: - all_stocks[code] = {"source": "watchlist", "data": item} - - total = len(all_stocks) - ok = 0 - errors = 0 - results = [] - decisions = [] - - # 加载现有 decisions.json 以便追踪变更 - decisions_path = "/home/hmo/web-dashboard/data/decisions.json" - try: - existing_decisions = {d["code"]: d for d in mo_data.read_decisions().get("decisions", []) if d.get("code")} - except: - existing_decisions = {} - - # 加载宏观上下文(影响策略参数调整) - macro_bias, macro_desc = load_macro_context() - if stdout: - print(f" 宏观参考: {macro_desc} (bias={macro_bias})") - - # 加载市场上下文 — 行业板块表现 + 大盘宽度(策略参数调整用) - market_ctx = load_market_context() - stock_sector_map = load_stock_sector_map() - market_breadth = market_ctx.get("breadth", 50) - market_mood = market_ctx.get("mood", "neutral") - if stdout: - sectors_found = sum(1 for c in all_stocks if stock_sector_map.get(c)) - print(f" 市场参考: {market_mood} 上涨比{market_breadth}% 已匹配{sectors_found}/{total}只个股行业") - - # 批量预取所有价格(一次API调用 vs 之前N次) - prices_map = batch_fetch_prices(list(all_stocks.keys())) - if stdout: - print(f" 批量获取价格: {len(prices_map)}/{total} 成功") - - for code, info in sorted(all_stocks.items()): - stock = info["data"] - name = stock.get("name", code) - cost = stock.get("cost", 0) or 0 - shares = stock.get("shares", 0) or 0 - source = info["source"] - - q = prices_map.get(code) - if not q or not q.get("price"): - results.append({"code": code, "name": name, "error": "腾讯API无数据"}) - errors += 1 - if stdout: - print(f" ❌ {name}({code}): 腾讯API无数据") - continue - - price = q["price"] - profit_pct = (price - cost) / cost * 100 if cost else 0 - current_action = stock.get("analysis", {}).get("action", "") - close_yest = q.get("close", 0) - sentiment = "neutral" - if close_yest and price > close_yest * 1.02: - sentiment = "bullish" - elif close_yest and price < close_yest * 0.98: - sentiment = "bearish" - - try: - is_wl = (source == "watchlist") - result = reassess_strategy( - code, name, price, cost, shares, - current_action, volume_signal="中性", sentiment=sentiment, - is_watchlist=(source == "watchlist"), - ) - - # --- Manual param preservation: 用户手动策略永不覆盖 --- - old_entry = existing_decisions.get(code, {}) - if old_entry.get("source") == "manual": - # 仅覆盖策略参数,技术分析/信号/价格照常保留 - for key in ["entry_low", "entry_high", "stop_loss", "take_profit"]: - if key in old_entry and old_entry[key] is not None: - result[key] = old_entry[key] - # 重算盈亏比(基于手动参数) - manual_stop = result.get("stop_loss", 0) or 0 - manual_target = result.get("take_profit", 0) or 0 - risk = max(price - manual_stop, price * 0.01) if manual_stop > 0 else price * 0.01 - reward = max(manual_target - price, 0) if manual_target > 0 else 0 - result["rr_ratio"] = round(reward / risk, 2) if risk > 0 else 0 - # 重建 action 文本(引用手动参数,不引用自动计算的) - profit_pct = (price - cost) / cost * 100 if cost else 0 - manual_action_parts = [] - if profit_pct < -20: - manual_action_parts.append("深套持有") - elif profit_pct < -10: - manual_action_parts.append("持有观察") - elif profit_pct < 0: - manual_action_parts.append("持有观察") - elif profit_pct < 5: - manual_action_parts.append("盈利持有") - else: - manual_action_parts.append("盈利良好") - if result.get("action_note"): - manual_action_parts.append(result["action_note"]) - if is_wl: - if manual_stop > 0: - manual_action_parts.append(f"止损参考{manual_stop}") - manual_action_parts.append(f"买入区{result['entry_low']}~{result['entry_high']}") - else: - if manual_stop > 0: - manual_action_parts.append(f"止损{manual_stop}") - if manual_target > 0: - manual_action_parts.append(f"目标{manual_target}") - manual_action_parts.append(f"买入区{result['entry_low']}~{result['entry_high']}") - ts = result.get("timing_signal", "") - if ts and ts != "neutral": - manual_action_parts.append(f"信号:{ts}") - result["action"] = " | ".join(manual_action_parts) - result["status"] = "manual" # 标记为手动管理,变更追踪不受影响 - if stdout: - print(f" [手动保留] {name}({code}) 策略参数未覆盖") - - # 宏观偏差调整:收盘后重评时根据宏观方向微调止损/止盈 - # 自选股不做止盈宏观调整(无持仓) - # 手动策略不做宏观偏差调整(尊重用户设定) - if macro_bias != 1.0 and not is_wl and old_entry.get("source") != "manual": - old_stop = result.get("stop_loss", 0) - old_target = result.get("take_profit", 0) - if macro_bias < 1.0 and old_stop > 0: # 宏观偏弱 → 收紧止损 - # 止损上移(但保留最小3%间距) - adjusted_stop = round(old_stop * (1 + (1 - macro_bias) * 0.3), 2) - min_stop = round(price * 0.97, 2) - result["stop_loss"] = min(adjusted_stop, min_stop) - if old_target > 0: - result["take_profit"] = round(old_target * (1 - (1 - macro_bias) * 0.2), 2) - elif macro_bias > 1.0 and old_target > 0: # 宏观偏强 → 止盈上调让利润跑 - result["take_profit"] = round(old_target * (1 + (macro_bias - 1) * 0.3), 2) - - # 行业偏差调整:根据个股所在行业的市场表现微调止损/止盈 - # 手动策略不做行业调整(尊重用户设定) - sector_adj = compute_sector_adjustment(code, market_ctx, stock_sector_map) - sector_note = sector_adj.get("note", "") - if sector_note and old_entry.get("source") != "manual": - old_stop = result.get("stop_loss", 0) - old_target = result.get("take_profit", 0) - stop_bias = sector_adj.get("stop_bias", 1.0) - target_bias = sector_adj.get("target_bias", 1.0) - if stop_bias != 1.0 and old_stop > 0: - # 行业偏差调整(在宏观调整之后叠加) - adjusted = round(old_stop * stop_bias, 2) - # 保留最小3%间距 - min_stop = round(price * 0.97, 2) - result["stop_loss"] = min(adjusted, min_stop) - if target_bias != 1.0 and old_target > 0 and not is_wl: - result["take_profit"] = round(old_target * target_bias, 2) - - # 加载消息面+基本面(逐个股) - news_sentiment = load_stock_news_sentiment(code) - fund = load_fundamentals(code) - - # 多因子合成 timing_signal:大盘+行业+消息+基本面+技术 - if old_entry.get("source") != "manual": - enriched, _ = enrich_timing_signal( - base_signal=result.get("timing_signal", ""), - macro_desc=macro_desc, - sector_note=sector_note, - profit_pct=profit_pct, - stock_category=result.get("stock_category", ""), - is_new_entry=(source == "watchlist"), - fundamentals=fund, - news_sentiment=news_sentiment, - rr_ratio=result.get("rr_ratio", 0), - ) - result["timing_signal"] = enriched - - # 在宏观/行业/多因子调整后重建 action 文本(同步调整后的止损/止盈数字) - if new_action_needs_refresh(result, old_entry, price): - _refresh_action_text(result, price, name) - - extra = { - "rr_ratio": result.get("rr_ratio"), - "action_note": result.get("action_note", ""), - "timing_signal": result.get("timing_signal", ""), - } - analysis = { - "stop_loss": result["stop_loss"], - "take_profit": result["take_profit"], - "entry_low": result["entry_low"], - "entry_high": result["entry_high"], - "action": result["action"], - "tech_snapshot": result.get("tech_snapshot", ""), - "multi_tf_context": result.get("multi_tf_context", ""), - "reassessed_at": result["reassessed_at"], - "status": result["status"], - **extra, - } - stock["analysis"] = analysis - # 同步 top-level 字段 → zone_breach/price_monitor 依赖这些字段 - # (2026-06-24 bugfix: analysis 子对象有但顶层没有,导致新持仓的止损检测盲区) - stock["stop_loss"] = result.get("stop_loss", 0) - stock["take_profit"] = result.get("take_profit", 0) - stock["entry_low"] = result.get("entry_low", 0) - stock["entry_high"] = result.get("entry_high", 0) - # 同步 trigger 字段 -> price_monitor 依赖 - sl = result.get("stop_loss", 0) - tp = result.get("take_profit", 0) - el = result.get("entry_low", 0) - eh = result.get("entry_high", 0) - trig = {} - if sl and float(sl) > 0: - trig["stop_loss"] = float(sl) - if el and eh and float(el) > 0 and float(eh) > 0: - trig["entry_zone"] = f"{float(el)}~{float(eh)}" - if tp and float(tp) > 0: - trig["take_profit_zone"] = f"0~{float(tp)}" - stock["trigger"] = trig - results.append({ - "code": code, "name": name, - "price": price, "cost": cost, - "action": result["action"], - "stop_loss": result["stop_loss"], - "take_profit": result["take_profit"], - "rr_ratio": result["rr_ratio"], - }) - ok += 1 - if stdout: - rr_str = f" RR={result['rr_ratio']}" if "rr_ratio" in result else "" - print(f" ✅ {name}({code}) {price} {result['action']}{rr_str}") - - # 记录所有股票的决策日志(含变更追踪) - status_display = result.get("status", "active") - # 构建行业上下文 - sector_ctx_str = "" - sec_name = sector_adj.get("sector_name", "") - sec_chg = sector_adj.get("sector_change", 0) - if sec_name: - sector_ctx_str = f"行业{sec_name}{sec_chg:+.1f}%" - if sector_adj.get("note"): - # note 已包含大盘宽度信息 - sector_ctx_str = sector_adj["note"] - elif market_breadth < 40: - # 无行业映射时至少记录大盘宽度 - sector_ctx_str = f"大盘上涨比{market_breadth}%" - new_entry = { - "code": code, "name": name, "price": price, - "cost": old_entry.get("cost", cost) if old_entry else cost, # 优先保留旧成本(holding.xls权威) - "shares": old_entry.get("shares", 0), # 保留持仓股数 - "avg_price": old_entry.get("avg_price", 0), # 保留持仓均价 - "action": result["action"], - "stop_loss": result.get("stop_loss"), - "entry_low": result["entry_low"], - "entry_high": result["entry_high"], - "tech_snapshot": result.get("tech_snapshot", ""), - "timing_signal": result.get("timing_signal", ""), - "rr_ratio": result.get("rr_ratio", 0), - "status": status_display, - "note": result.get("action_note", ""), - "timestamp": result["reassessed_at"], - "updated_at": result["reassessed_at"], - "type": "自选策略" if is_wl else "持仓策略", - "source": old_entry.get("source", "auto"), # manual/auto,继承旧标记 - "sector_context": sector_ctx_str, # 市场上下文:行业表现+大盘宽度 - "stock_category": result.get("stock_category", "中短线"), # 组合监测用 - "position_advice": result.get("position_advice", "中等仓位"), - "time_horizon": result.get("time_horizon", "2周~3月"), - } - new_entry["trigger"] = trig - # created_at: 首次创建时设置,后续 preserve - old_entry = existing_decisions.get(code, {}) - if old_entry.get("created_at"): - new_entry["created_at"] = old_entry["created_at"] - else: - new_entry["created_at"] = result["reassessed_at"] - # 保留 last_reassessed_price(per_stock_reassess 维护的防抖字段) - if old_entry.get("last_reassessed_price"): - new_entry["last_reassessed_price"] = old_entry["last_reassessed_price"] - # 自选股也写止盈位(用于RR校验),但标签用"目标参考"非"止盈" - new_entry["take_profit"] = result.get("take_profit") - - # --- 变更追踪 --- - old_action = old_entry.get("action", "") - old_stop = old_entry.get("stop_loss") - old_target = old_entry.get("take_profit") - - # 构建旧策略摘要和变更理由 - update_reason = "" - changelog_entry = None - - if old_action and old_action != result["action"]: - # 策略有变化 → 记录变更 - old_summary = old_action - new_summary = result["action"] - - # 判断触发原因 - if abs(price - old_entry.get("price", price)) / max(price, 0.01) > 0.03: - trigger = f"价格变动({old_entry.get('price','?')}→{price})" - elif result.get("timing_signal") and result["timing_signal"] != old_entry.get("timing_signal", ""): - trigger = f"技术信号变化: {result['timing_signal']}" - else: - trigger = "技术面重评" - - # 格式化的变更理由(自选股只看止损,不看止盈) - diff_parts = [] - if old_stop and result["stop_loss"] != old_stop: - diff_parts.append(f"止损{old_stop}→{result['stop_loss']}") - if not is_wl and old_target and result.get("take_profit") and result["take_profit"] != old_target: - diff_parts.append(f"止盈{old_target}→{result['take_profit']}") - if diff_parts: - update_reason = f"{trigger}: {', '.join(diff_parts)} | {result.get('tech_snapshot','')[:60]}" - else: - update_reason = f"{trigger}: 策略文字调整" - - changelog_entry = { - "date": result["reassessed_at"], - "old_action": old_action, - "new_action": result["action"], - "reason": update_reason, - "trigger": trigger, - } - new_entry["updated_reason"] = update_reason - - elif not old_action: - # 首次创建策略 - update_reason = f"初始策略创建 | {result.get('tech_snapshot','')[:60]}" - changelog_entry = { - "date": result["reassessed_at"], - "old_action": "", - "new_action": result["action"], - "reason": update_reason, - "trigger": "初始创建", - } - - # 合并changelog - old_changelog = old_entry.get("changelog", []) if old_entry else [] - if changelog_entry: - new_entry["changelog"] = old_changelog + [changelog_entry] - else: - new_entry["changelog"] = old_changelog - - # 保留执行记录 - if old_entry and old_entry.get("execution"): - new_entry["execution"] = old_entry["execution"] - elif stock.get("analysis", {}).get("status") == "executing": - new_entry["execution"] = { - "status": "executing", - "entry_price": cost if cost else 0, - "shares": shares, - "notes": "", - } - - # --- 自动标记 current_recommend --- - # 只在真正执行中的持仓才自动推荐:execution.status 为 executing 或 partial_exit - exec_status = old_entry.get("execution", {}).get("status", "") if old_entry else "" - is_active = exec_status in ("executing", "partial_exit") - - profit_pct = (price - cost) / cost * 100 if cost else 0 - is_deep_loss_stock = profit_pct < -20 - rr = result.get("rr_ratio", 0) - ts = result.get("timing_signal", "") - note = result.get("action_note", "") - - # 计算是否在/接近买入区 - entry_low_val = result.get("entry_low", 0) - entry_high_val = result.get("entry_high", 0) - in_buy_zone = (entry_low_val > 0 and entry_high_val > 0 and - entry_low_val <= price <= entry_high_val) - near_buy_zone_low = (entry_low_val > 0 and - price >= entry_low_val * 0.98 and - price <= entry_high_val) - - # 推荐条件:必须是执行中的持仓 + 基本面条件达标 - is_recommendable = ( - is_active - and not is_deep_loss_stock - and rr >= 1.5 - and ts != "neutral" - and "不建议" not in note - ) - if is_recommendable: - new_entry["tag"] = "current_recommend" - else: - # 不清除 active_manual(用户手动标记),只清除自动推荐的 - old_tag = old_entry.get("tag", "") if old_entry else "" - if old_tag != "active_manual": - new_entry.pop("tag", None) - - decisions.append(new_entry) - - except Exception as e: - results.append({"code": code, "name": name, "error": str(e)}) - errors += 1 - if stdout: - print(f" ❌ {name}({code}): {e}") - - # 写回数据文件 — 保留现有字段(现金、总资产等)不丢 - try: - existing_pf = mo_data.read_portfolio() - except Exception: - existing_pf = {} - # 保留 price/change_pct — price_monitor 维护的实时价,regenerate_all 不应清除 - _existing_holdings_map = {} - for _h in existing_pf.get('holdings', []): - if _h.get('code'): - _existing_holdings_map[_h['code']] = _h - _new_holdings = pf.get("holdings", []) - for _h in _new_holdings: - _code = _h.get('code') - if _code and _code in _existing_holdings_map: - _old = _existing_holdings_map[_code] - _h['price'] = _old.get('price', 0) - _h['change_pct'] = _old.get('change_pct', 0) - existing_pf["holdings"] = _new_holdings - existing_pf["updated_at"] = datetime.now().strftime('%Y-%m-%d %H:%M') - - # ── Watchlist ↔ Holdings 双向自动迁移(2026-06-27 Dad要求)── - # ① 持仓已有 → 从自选移除(买入自动清除) - wl_codes = {s.get("code") for s in wl.get("stocks", []) if s.get("code")} - pf_codes = {h.get("code") for h in _new_holdings if h.get("code") and h.get("shares", 0) > 0} - removed_from_wl = [] - for h_code in wl_codes & pf_codes: - # 持仓>0且量够 → 自选移除 - wl["stocks"] = [s for s in wl.get("stocks", []) if s.get("code") != h_code] - removed_from_wl.append(h_code) - if removed_from_wl and stdout: - print(f" 自选→持仓自动移除: {', '.join(removed_from_wl)}") - - # ② 清仓/卖光 → 加回自选(只要仍有关注价值) - added_to_wl = [] - old_pf_codes = {_h.get("code") for _h in existing_pf.get("holdings", []) if _h.get("code")} - sold_codes = old_pf_codes - pf_codes # 曾持仓但现在没有(或不在了) - for sc in sold_codes: - # 已有自选就不重复加 - if sc in wl_codes: - continue - # 从现有decisions看是否有关注价值 - for d in decisions: - if d.get("code") == sc and d.get("entry_low") and d.get("entry_high"): - wl["stocks"].append({ - "code": sc, "name": d.get("name", sc), - "entry_low": d.get("entry_low"), "entry_high": d.get("entry_high"), - "stop_loss": d.get("stop_loss", 0), - "analysis": {"action": d.get("action", ""), "tech_snapshot": d.get("tech_snapshot", "")} - }) - added_to_wl.append(sc) - break - if added_to_wl and stdout: - print(f" 清仓→自选自动加入: {', '.join(added_to_wl)}") - - # DB 写入(替代 JSON dump — 强制币种约束) - try: - from mofin_db import get_conn, write_holdings_batch, write_portfolio_summary, write_watchlist_stock, write_holding_strategy - conn = get_conn() - write_holdings_batch(conn, existing_pf.get('holdings', [])) - write_portfolio_summary(conn, existing_pf) - for s in wl.get('stocks', []): - s.setdefault('currency', 'CNY') - write_watchlist_stock(conn, s) - for d in decisions: - write_holding_strategy(conn, d.get('code', ''), d.get('name', ''), d) - conn.close() - except Exception as e: - print(f" [DB写入失败] {e}", flush=True) - - # 记录策略→提示词版本关联 - if HAS_PROMPT_TRACKING: - try: - for d in decisions: - if d.get("code") and d.get("action"): - record_strategy_generation( - d["code"], d.get("name", ""), d.get("action", "") - ) - except Exception as e: - if stdout: - print(f" ⚠️ 提示词版本追踪失败: {e}", file=sys.stderr) - - # 刷新多周期缓存到磁盘 - try: - import multi_timeframe as _mtf - _mtf.flush_mtf_cache() - except Exception: - pass - - summary = {"total": total, "ok": ok, "errors": errors} - if stdout: - print(f"\n✅ 全量重评完成: {ok}/{total}成功, {errors}错误") - return summary - - -if __name__ == "__main__": - regenerate_all() diff --git a/scripts/strategy_review.py b/scripts/strategy_review.py index fe28348..26f5ab8 100644 --- a/scripts/strategy_review.py +++ b/scripts/strategy_review.py @@ -1,298 +1,297 @@ -#!/usr/bin/env python3 -"""strategy_review.py — 三层策略复盘 (no_agent) - -每层独立评估: -1. 信号层 — 买入/卖出/持有的timing对不对? -2. 执行层 — 止损/止盈设得合理吗? -3. 综合层 — 这波操作整体赚钱了吗? - -用法: - python3 scripts/strategy_review.py -""" - -import json, sqlite3, sys, time, urllib.request -from pathlib import Path -from datetime import datetime -from collections import Counter -from mo_data import read_portfolio, read_decisions, read_watchlist - -BASE = Path("/home/hmo/MoFin") -DATA = BASE / "data" -DB_PATH = DATA / "mofin.db" -DECISIONS_PATH = DATA / "decisions.json" - -# 失败模式定义(执行层) -EXEC_FAILURES = { - "stop_too_tight": {"label": "止损过紧", "fix": "放宽止损到强支撑×0.95,给价格波动留空间"}, - "tp_too_close": {"label": "止盈过近", "fix": "止盈放到更高阻力位,让利润奔跑"}, - "stop_too_loose": {"label": "止损过宽", "fix": "收紧止损,少亏当赢"}, - "tp_too_far": {"label": "止盈过远", "fix": "止盈靠近合理阻力位,提高兑现概率"}, -} - -# 失败模式定义(信号层) -SIGNAL_FAILURES = { - "wrong_direction": {"label": "方向看反", "fix": "检查多周期趋势判断逻辑"}, - "entry_too_early": {"label": "入场过早", "fix": "等缩量确认支撑再入,不追回调"}, - "bad_signal": {"label": "信号误判", "fix": "修正timing_signal合成权重"}, - "regime_mismatch": {"label": "情景错配", "fix": "加入市场情景过滤条件"}, -} - - -def fetch_price(code): - # DB 优先 - try: from mofin_db import get_price_from_db; p, _ = get_price_from_db(code); return p if p else 0 - except: pass - # Fallback: 腾讯 API - try: - prefix = "sh" if code.startswith(('60','68','51','56','50')) else "sz" if code.startswith(('00','30','15')) else "hk" - url = f"http://qt.gtimg.cn/q={prefix}{code}" - req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'}) - resp = urllib.request.urlopen(req, timeout=5).read().decode('gbk') - fld = resp.split('=')[1].strip().strip('"').strip(';').split('~') - return float(fld[3]) if len(fld) > 3 else 0 - except: - return 0 - - -def evaluate_strategy(s, price): - """三层评估单条策略,返回 (signal_verdict, exec_verdict, overall_verdict, detail)""" - code = s.get("code", "") - name = s.get("name", "") - sl = s.get("stop_loss", 0) or 0 - tp = s.get("take_profit", 0) or 0 - entry_low = s.get("entry_low", 0) or 0 - entry_high = s.get("entry_high", 0) or 0 - cost = s.get("cost", 0) or s.get("avg_price", 0) or 0 - signal = (s.get("timing_signal", "") or s.get("current", "") or "").lower() - created = s.get("created_at", "") or s.get("timestamp", "") - s_type = s.get("type", "") # 持仓策略/自选策略 - - if not created or not price: - return "skip", "skip", "skip", "数据不足" - - # 计算运行天数 - try: - days = (datetime.now() - datetime.fromisoformat(created)).days - except: - days = 0 - - # ─── 综合层:赚钱了吗? ─── - if cost > 0 and s_type == "持仓策略": - profit_pct = (price - cost) / cost * 100 - if profit_pct > 5: - overall = "盈利" - elif profit_pct > -5: - overall = "持平" - else: - overall = f"亏损{profit_pct:.0f}%" - elif tp > 0 and price >= tp: - overall = "触止盈" - elif sl > 0 and price <= sl: - overall = "触止损" - else: - overall = "持有中" - - # ─── 信号层:timing对不对? ─── - is_buy_signal = any(kw in signal for kw in ["买入", "加仓", "追涨", "可买"]) - is_sell_signal = any(kw in signal for kw in ["卖出", "减仓", "止损", "离场"]) - is_hold_signal = any(kw in signal for kw in ["持有", "观望", "等待", "持股"]) - - signal_verdict = "待定" - signal_fail = None - - if is_buy_signal or is_hold_signal: - if sl > 0 and price <= sl: - # 买入/持有信号下触发止损 → 信号方向可能错了 - signal_verdict = "存疑" - signal_fail = "wrong_direction" - elif tp > 0 and price >= tp * 0.95: - signal_verdict = "正确" - elif entry_low > 0 and price < entry_low * 0.85: - signal_verdict = "存疑" - signal_fail = "entry_too_early" - elif days > 30 and tp > 0 and price < entry_low: - signal_verdict = "存疑" - signal_fail = "wrong_direction" - else: - signal_verdict = "待定" - elif is_sell_signal: - if sl > 0 and price <= sl: - signal_verdict = "正确" - elif price > (cost or entry_low or 0) * 1.05: - signal_verdict = "存疑" - signal_fail = "bad_signal" - else: - signal_verdict = "待定" - else: - # 无明确信号 - if price > (entry_high or 0): - signal_verdict = "待定(价涨)" - elif sl > 0 and price <= sl * 1.05: - signal_verdict = "待定(近止损)" - else: - signal_verdict = "待定" - - # ─── 执行层:止损/止盈设得好不好? ─── - exec_verdict = "待定" - exec_fail = None - - # 取近期最高/最低价(判断卖飞/洗盘) - recent_high = 0 - recent_low = 0 - sl_recovery = False - if tp > 0 or sl > 0: - try: - prefix = "sh" if code.startswith(('60','68','51','56','50')) else "sz" if code.startswith(('00','30','15')) else "hk" - url = f"http://ifzq.gtimg.cn/appstock/app/fqkline/get?param={prefix}{code},day,,,60,qfq" - import subprocess as sp - r = sp.run(["curl", "-s", "--max-time", "3", url], capture_output=True, text=True, timeout=5) - if r.returncode == 0 and r.stdout: - data = json.loads(r.stdout) - day_key = 'qfqday' if prefix != 'hk' else 'day' - bars = data.get('data', {}).get(f'{prefix}{code}', {}).get(day_key, []) - if bars: - prices = [(float(b[2]), float(b[3]), b[0]) for b in bars if len(b) > 3] # (high, low, date) - recent_high = max(p[0] for p in prices) - recent_low = min(p[1] for p in prices) - # 检查止损触发后的走势:是否后来反弹了? - if sl > 0: - # 找出价格低于SL的K线 - below_sl = [p for p in prices if p[1] <= sl] - above_sl_later = [p for p in prices if p[1] > sl * 1.03] - if below_sl and above_sl_later: - # 曾跌破SL,但后来涨回去了 → 洗盘 - first_below = min(below_sl, key=lambda x: x[2]) - last_above = max(above_sl_later, key=lambda x: x[2]) - if last_above[2] > first_below[2]: - sl_recovery = True - except: - pass - - if sl > 0 and price <= sl: - if sl_recovery: - exec_verdict = "洗盘(触发后反弹)" - exec_fail = "stop_too_tight" - elif price >= sl * 0.95: - exec_verdict = "临界(差一点触发)" - exec_fail = "stop_too_tight" - else: - exec_verdict = "已触发" - elif tp > 0 and (price >= tp or recent_high >= tp): - # 止盈触发或曾触发过 - max_price = max(price, recent_high) - if max_price <= tp * 1.05: - exec_verdict = "已触发" - else: - overshoot = (max_price - tp) / tp * 100 - exec_verdict = f"卖飞({overshoot:.0f}%)" - exec_fail = "tp_too_close" - elif days > 45 and tp > 0 and price < entry_low: - exec_verdict = "存疑(久未达标)" - exec_fail = "tp_too_far" - elif sl > 0 and price >= entry_low and price <= entry_high: - exec_verdict = "持有中" - else: - exec_verdict = "待定" - - return signal_verdict, exec_verdict, overall, signal_fail, exec_fail - - -def review(): - start = time.time() - decisions = mo_data.read_decisions() - strategies = decisions.get("decisions", []) - - conn = sqlite3.connect(str(DB_PATH)) - - stats = {"correct": 0, "wrong": 0, "mixed": 0, "pending": 0, "total": 0} - signal_fails = Counter() - exec_fails = Counter() - detail_lines = [] - - for s in strategies: - if s.get("status") == "closed": - continue - stats["total"] += 1 - code = s.get("code", "") - name = s.get("name", "") - price = fetch_price(code) - if not price: - detail_lines.append(f" ⏭️ {name}({code}): 无行情") - stats["pending"] += 1 - continue - - sv, ev, overall, sf, ef = evaluate_strategy(s, price) - - # 综合评级 - if overall in ("盈利", "触止盈"): - if sv == "正确" or "存疑" not in sv: - stats["correct"] += 1 - else: - stats["mixed"] += 1 - elif overall in ("触止损",) and "存疑" in sv: - stats["wrong"] += 1 - elif "存疑" in sv or "存疑" in ev: - stats["wrong"] += 1 - elif overall in ("持有中", "持平"): - stats["mixed"] += 1 - else: - stats["pending"] += 1 - - # 记录失败模式 - if sf: - signal_fails[sf] += 1 - if ef: - exec_fails[ef] += 1 - - # 逐条摘要 - tags = [] - if overall in ("盈利", "触止盈"): - tags.append("✅") - elif overall == "触止损": - tags.append("❌") - else: - tags.append("⏳") - tags.append(f"信号:{sv}") - tags.append(f"执行:{ev}") - tags.append(f"整体:{overall}") - detail_lines.append(f" {' | '.join(tags)} {name}({code})") - - # 写入accuracy_stats - conn.execute( - "INSERT OR REPLACE INTO accuracy_stats (id, total_advice, correct, wrong, partial, pending, " - "accuracy_pct, updated_at) VALUES (1, ?, ?, ?, ?, ?, ?, ?)", - (stats["total"], stats["correct"], stats["wrong"], - stats["mixed"], stats["pending"], - round(stats["correct"] / max(stats["total"] - stats["pending"], 1) * 100, 1), - datetime.now().isoformat())) - conn.commit() - conn.close() - - # 输出 - total_eval = stats["total"] - stats["pending"] - accuracy = stats["correct"] / max(total_eval, 1) * 100 - - print(f"策略复盘 | {datetime.now().strftime('%Y-%m-%d')} | {stats['total']}条 | ({time.time()-start:.0f}s)") - print(f" ✅正确 {stats['correct']} | ❌错误 {stats['wrong']} | ⚠️部分 {stats['mixed']} | ⏳待定 {stats['pending']}") - print(f" 综合准确率: {accuracy:.1f}%") - - if signal_fails: - print(f"\n📡 信号层失败模式:") - for mode, cnt in signal_fails.most_common(): - info = SIGNAL_FAILURES.get(mode, {}) - print(f" {info.get('label', mode)}({cnt}次): {info.get('fix', '')}") - - if exec_fails: - print(f"\n🎯 执行层失败模式:") - for mode, cnt in exec_fails.most_common(): - info = EXEC_FAILURES.get(mode, {}) - print(f" {info.get('label', mode)}({cnt}次): {info.get('fix', '')}") - - if detail_lines: - print(f"\n逐条复盘:") - for line in detail_lines: - print(line) - - -if __name__ == "__main__": - review() +#!/usr/bin/env python3 +"""strategy_review.py — 三层策略复盘 (no_agent) + +每层独立评估: +1. 信号层 — 买入/卖出/持有的timing对不对? +2. 执行层 — 止损/止盈设得合理吗? +3. 综合层 — 这波操作整体赚钱了吗? + +用法: + python3 scripts/strategy_review.py +""" + +import json, sqlite3, sys, time, urllib.request +from pathlib import Path +from datetime import datetime +from collections import Counter +from mo_data import read_portfolio, read_decisions, read_watchlist + +BASE = Path("/home/hmo/MoFin") +DATA = BASE / "data" +DB_PATH = DATA / "mofin.db" + +# 失败模式定义(执行层) +EXEC_FAILURES = { + "stop_too_tight": {"label": "止损过紧", "fix": "放宽止损到强支撑×0.95,给价格波动留空间"}, + "tp_too_close": {"label": "止盈过近", "fix": "止盈放到更高阻力位,让利润奔跑"}, + "stop_too_loose": {"label": "止损过宽", "fix": "收紧止损,少亏当赢"}, + "tp_too_far": {"label": "止盈过远", "fix": "止盈靠近合理阻力位,提高兑现概率"}, +} + +# 失败模式定义(信号层) +SIGNAL_FAILURES = { + "wrong_direction": {"label": "方向看反", "fix": "检查多周期趋势判断逻辑"}, + "entry_too_early": {"label": "入场过早", "fix": "等缩量确认支撑再入,不追回调"}, + "bad_signal": {"label": "信号误判", "fix": "修正timing_signal合成权重"}, + "regime_mismatch": {"label": "情景错配", "fix": "加入市场情景过滤条件"}, +} + + +def fetch_price(code): + # DB 优先 + try: from mofin_db import get_price_from_db; p, _ = get_price_from_db(code); return p if p else 0 + except: pass + # Fallback: 腾讯 API + try: + prefix = "sh" if code.startswith(('60','68','51','56','50')) else "sz" if code.startswith(('00','30','15')) else "hk" + url = f"http://qt.gtimg.cn/q={prefix}{code}" + req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'}) + resp = urllib.request.urlopen(req, timeout=5).read().decode('gbk') + fld = resp.split('=')[1].strip().strip('"').strip(';').split('~') + return float(fld[3]) if len(fld) > 3 else 0 + except: + return 0 + + +def evaluate_strategy(s, price): + """三层评估单条策略,返回 (signal_verdict, exec_verdict, overall_verdict, detail)""" + code = s.get("code", "") + name = s.get("name", "") + sl = s.get("stop_loss", 0) or 0 + tp = s.get("take_profit", 0) or 0 + entry_low = s.get("entry_low", 0) or 0 + entry_high = s.get("entry_high", 0) or 0 + cost = s.get("cost", 0) or s.get("avg_price", 0) or 0 + signal = (s.get("timing_signal", "") or s.get("current", "") or "").lower() + created = s.get("created_at", "") or s.get("timestamp", "") + s_type = s.get("type", "") # 持仓策略/自选策略 + + if not created or not price: + return "skip", "skip", "skip", "数据不足" + + # 计算运行天数 + try: + days = (datetime.now() - datetime.fromisoformat(created)).days + except: + days = 0 + + # ─── 综合层:赚钱了吗? ─── + if cost > 0 and s_type == "持仓策略": + profit_pct = (price - cost) / cost * 100 + if profit_pct > 5: + overall = "盈利" + elif profit_pct > -5: + overall = "持平" + else: + overall = f"亏损{profit_pct:.0f}%" + elif tp > 0 and price >= tp: + overall = "触止盈" + elif sl > 0 and price <= sl: + overall = "触止损" + else: + overall = "持有中" + + # ─── 信号层:timing对不对? ─── + is_buy_signal = any(kw in signal for kw in ["买入", "加仓", "追涨", "可买"]) + is_sell_signal = any(kw in signal for kw in ["卖出", "减仓", "止损", "离场"]) + is_hold_signal = any(kw in signal for kw in ["持有", "观望", "等待", "持股"]) + + signal_verdict = "待定" + signal_fail = None + + if is_buy_signal or is_hold_signal: + if sl > 0 and price <= sl: + # 买入/持有信号下触发止损 → 信号方向可能错了 + signal_verdict = "存疑" + signal_fail = "wrong_direction" + elif tp > 0 and price >= tp * 0.95: + signal_verdict = "正确" + elif entry_low > 0 and price < entry_low * 0.85: + signal_verdict = "存疑" + signal_fail = "entry_too_early" + elif days > 30 and tp > 0 and price < entry_low: + signal_verdict = "存疑" + signal_fail = "wrong_direction" + else: + signal_verdict = "待定" + elif is_sell_signal: + if sl > 0 and price <= sl: + signal_verdict = "正确" + elif price > (cost or entry_low or 0) * 1.05: + signal_verdict = "存疑" + signal_fail = "bad_signal" + else: + signal_verdict = "待定" + else: + # 无明确信号 + if price > (entry_high or 0): + signal_verdict = "待定(价涨)" + elif sl > 0 and price <= sl * 1.05: + signal_verdict = "待定(近止损)" + else: + signal_verdict = "待定" + + # ─── 执行层:止损/止盈设得好不好? ─── + exec_verdict = "待定" + exec_fail = None + + # 取近期最高/最低价(判断卖飞/洗盘) + recent_high = 0 + recent_low = 0 + sl_recovery = False + if tp > 0 or sl > 0: + try: + prefix = "sh" if code.startswith(('60','68','51','56','50')) else "sz" if code.startswith(('00','30','15')) else "hk" + url = f"http://ifzq.gtimg.cn/appstock/app/fqkline/get?param={prefix}{code},day,,,60,qfq" + import subprocess as sp + r = sp.run(["curl", "-s", "--max-time", "3", url], capture_output=True, text=True, timeout=5) + if r.returncode == 0 and r.stdout: + data = json.loads(r.stdout) + day_key = 'qfqday' if prefix != 'hk' else 'day' + bars = data.get('data', {}).get(f'{prefix}{code}', {}).get(day_key, []) + if bars: + prices = [(float(b[2]), float(b[3]), b[0]) for b in bars if len(b) > 3] # (high, low, date) + recent_high = max(p[0] for p in prices) + recent_low = min(p[1] for p in prices) + # 检查止损触发后的走势:是否后来反弹了? + if sl > 0: + # 找出价格低于SL的K线 + below_sl = [p for p in prices if p[1] <= sl] + above_sl_later = [p for p in prices if p[1] > sl * 1.03] + if below_sl and above_sl_later: + # 曾跌破SL,但后来涨回去了 → 洗盘 + first_below = min(below_sl, key=lambda x: x[2]) + last_above = max(above_sl_later, key=lambda x: x[2]) + if last_above[2] > first_below[2]: + sl_recovery = True + except: + pass + + if sl > 0 and price <= sl: + if sl_recovery: + exec_verdict = "洗盘(触发后反弹)" + exec_fail = "stop_too_tight" + elif price >= sl * 0.95: + exec_verdict = "临界(差一点触发)" + exec_fail = "stop_too_tight" + else: + exec_verdict = "已触发" + elif tp > 0 and (price >= tp or recent_high >= tp): + # 止盈触发或曾触发过 + max_price = max(price, recent_high) + if max_price <= tp * 1.05: + exec_verdict = "已触发" + else: + overshoot = (max_price - tp) / tp * 100 + exec_verdict = f"卖飞({overshoot:.0f}%)" + exec_fail = "tp_too_close" + elif days > 45 and tp > 0 and price < entry_low: + exec_verdict = "存疑(久未达标)" + exec_fail = "tp_too_far" + elif sl > 0 and price >= entry_low and price <= entry_high: + exec_verdict = "持有中" + else: + exec_verdict = "待定" + + return signal_verdict, exec_verdict, overall, signal_fail, exec_fail + + +def review(): + start = time.time() + decisions = mo_data.read_decisions() + strategies = decisions.get("decisions", []) + + conn = sqlite3.connect(str(DB_PATH)) + + stats = {"correct": 0, "wrong": 0, "mixed": 0, "pending": 0, "total": 0} + signal_fails = Counter() + exec_fails = Counter() + detail_lines = [] + + for s in strategies: + if s.get("status") == "closed": + continue + stats["total"] += 1 + code = s.get("code", "") + name = s.get("name", "") + price = fetch_price(code) + if not price: + detail_lines.append(f" ⏭️ {name}({code}): 无行情") + stats["pending"] += 1 + continue + + sv, ev, overall, sf, ef = evaluate_strategy(s, price) + + # 综合评级 + if overall in ("盈利", "触止盈"): + if sv == "正确" or "存疑" not in sv: + stats["correct"] += 1 + else: + stats["mixed"] += 1 + elif overall in ("触止损",) and "存疑" in sv: + stats["wrong"] += 1 + elif "存疑" in sv or "存疑" in ev: + stats["wrong"] += 1 + elif overall in ("持有中", "持平"): + stats["mixed"] += 1 + else: + stats["pending"] += 1 + + # 记录失败模式 + if sf: + signal_fails[sf] += 1 + if ef: + exec_fails[ef] += 1 + + # 逐条摘要 + tags = [] + if overall in ("盈利", "触止盈"): + tags.append("✅") + elif overall == "触止损": + tags.append("❌") + else: + tags.append("⏳") + tags.append(f"信号:{sv}") + tags.append(f"执行:{ev}") + tags.append(f"整体:{overall}") + detail_lines.append(f" {' | '.join(tags)} {name}({code})") + + # 写入accuracy_stats + conn.execute( + "INSERT OR REPLACE INTO accuracy_stats (id, total_advice, correct, wrong, partial, pending, " + "accuracy_pct, updated_at) VALUES (1, ?, ?, ?, ?, ?, ?, ?)", + (stats["total"], stats["correct"], stats["wrong"], + stats["mixed"], stats["pending"], + round(stats["correct"] / max(stats["total"] - stats["pending"], 1) * 100, 1), + datetime.now().isoformat())) + conn.commit() + conn.close() + + # 输出 + total_eval = stats["total"] - stats["pending"] + accuracy = stats["correct"] / max(total_eval, 1) * 100 + + print(f"策略复盘 | {datetime.now().strftime('%Y-%m-%d')} | {stats['total']}条 | ({time.time()-start:.0f}s)") + print(f" ✅正确 {stats['correct']} | ❌错误 {stats['wrong']} | ⚠️部分 {stats['mixed']} | ⏳待定 {stats['pending']}") + print(f" 综合准确率: {accuracy:.1f}%") + + if signal_fails: + print(f"\n📡 信号层失败模式:") + for mode, cnt in signal_fails.most_common(): + info = SIGNAL_FAILURES.get(mode, {}) + print(f" {info.get('label', mode)}({cnt}次): {info.get('fix', '')}") + + if exec_fails: + print(f"\n🎯 执行层失败模式:") + for mode, cnt in exec_fails.most_common(): + info = EXEC_FAILURES.get(mode, {}) + print(f" {info.get('label', mode)}({cnt}次): {info.get('fix', '')}") + + if detail_lines: + print(f"\n逐条复盘:") + for line in detail_lines: + print(line) + + +if __name__ == "__main__": + review() diff --git a/scripts/xiaoguo_signal_consumer.py b/scripts/xiaoguo_signal_consumer.py index 47bd07f..59ea319 100644 --- a/scripts/xiaoguo_signal_consumer.py +++ b/scripts/xiaoguo_signal_consumer.py @@ -24,9 +24,6 @@ BASE = Path("/home/hmo/MoFin") DATA = BASE / "data" DB_PATH = DATA / "mofin.db" -# 自选池和决策文件 -WATCHLIST_PATH = DATA / "watchlist.json" -DECISIONS_PATH = DATA / "decisions.json" SIGNAL_MAX_AGE_HOURS = 4 # 只处理4小时内产生的信号 @@ -273,8 +270,7 @@ def main(): conn2.close() except Exception: pass - # [migrated to DB] — JSON cold backup removed - # WATCHLIST_PATH.write_text(json.dumps(wl, ensure_ascii=False, indent=2)) + except: pass elif action == "monitor": diff --git a/strategy_lifecycle.py b/strategy_lifecycle.py index 8883882..3281074 100644 --- a/strategy_lifecycle.py +++ b/strategy_lifecycle.py @@ -438,9 +438,6 @@ try: except ImportError: HAS_PROMPT_TRACKING = False -PORTFOLIO_PATH = "/home/hmo/web-dashboard/data/portfolio.json" -WATCHLIST_PATH = "/home/hmo/web-dashboard/data/watchlist.json" - def safe_json_load(path, default=None): """安全加载 JSON,遇到坏数据自动修复""" if not os.path.exists(path): @@ -2046,8 +2043,12 @@ def regenerate_all(stdout=True): pf = {"holdings": holdings} wl = {"stocks": wl_stocks} except Exception: - pf = safe_json_load(PORTFOLIO_PATH, {}) - wl = safe_json_load(WATCHLIST_PATH, {}) + try: + pf = read_portfolio() + wl = read_watchlist() + except Exception: + pf = {} + wl = {} all_stocks = {} for item in pf.get("holdings", []): @@ -2068,7 +2069,7 @@ def regenerate_all(stdout=True): # 加载现有 decisions.json 以便追踪变更 decisions_path = "/home/hmo/web-dashboard/data/decisions.json" try: - existing_decisions = {d["code"]: d for d in mo_data.read_decisions().get("decisions", []) if d.get("code")} + existing_decisions = {d["code"]: d for d in read_decisions().get("decisions", []) if d.get("code")} except: existing_decisions = {} @@ -2445,7 +2446,7 @@ def regenerate_all(stdout=True): # 写回数据文件 — 保留现有字段(现金、总资产等)不丢 try: - existing_pf = mo_data.read_portfolio() + existing_pf = read_portfolio() except Exception: existing_pf = {} # 保留 price/change_pct — price_monitor 维护的实时价,regenerate_all 不应清除 diff --git a/system_health_check.py b/system_health_check.py index 450e812..64ade3d 100644 --- a/system_health_check.py +++ b/system_health_check.py @@ -1,267 +1,321 @@ -#!/usr/bin/env python3 -"""system_health_check.py — MoFin 系统健康检查 - -每日运行,检查所有组件是否正常工作。 -输出报告,有问题才推送。 -""" -import json, os, sys, subprocess -from datetime import datetime, timedelta -from pathlib import Path - -DATA_DIR = Path("/home/hmo/web-dashboard/data") -DECISIONS_PATH = DATA_DIR / "decisions.json" -PORTFOLIO_PATH = DATA_DIR / "portfolio.json" -EVENTS_PATH = DATA_DIR / "price_events.json" -EVALUATION_PATH = DATA_DIR / "evaluation.json" -ACCURACY_PATH = DATA_DIR / "accuracy_stats.json" -CRON_JOBS = "/home/hmo/.hermes/profiles/position-analyst/cron/jobs.json" -POSITION_CRON = "/home/hmo/.hermes/profiles/position-analyst/cron/jobs.json" - -def check(ok, msg): - icon = "✅" if ok else "⚠️" - return f" {icon} {msg}" - -def load_json(path, default=None): - try: - with open(path) as f: - return json.load(f) - except: - return {} if default is None else default - -def check_cron_jobs(path, label): - issues = [] - try: - d = load_json(path, {"jobs": []}) - for j in d.get("jobs", []): - name = j.get("name", "?") - enabled = j.get("enabled", True) - last = j.get("last_run_at", "") - status = j.get("last_status", "") - if not enabled: - issues.append(f"{name} 已禁用") - elif not last: - issues.append(f"{name} 从未运行") - elif status != "ok": - issues.append(f"{name} 上次状态={status}") - return len(d.get("jobs", [])), issues - except: - return 0, ["无法读取"] - -def run(): - now = datetime.now() - issues = [] - ok_count = 0 - warn_count = 0 - - lines = [f"MoFin 系统健康检查 | {now.strftime('%Y-%m-%d %H:%M')}"] - lines.append("") - - # 1. 进程检查 - lines.append("【进程】") - procs = { - "mofin-dashboard": "mofin-dashboard", - "xmpp-zhiwei": "xmpp_zhiwei_bot", - "ejabberd": "ejabberd", - } - for name, pattern in procs.items(): - # 先查 systemd,再查 pgrep - r = subprocess.run(["systemctl", "is-active", f"{pattern}.service"], capture_output=True, text=True, timeout=5) - alive = r.stdout.strip() == "active" - if not alive: - r2 = subprocess.run(["pgrep", "-f", pattern], capture_output=True, timeout=5) - alive = r2.returncode == 0 - lines.append(check(alive, f"{name} {'运行中' if alive else '已停止'}")) - if not alive: issues.append(f"{name} 进程不存在"); warn_count += 1 - else: ok_count += 1 - - # 2. 端口检查 - lines.append("") - lines.append("【端口】") - ports = {"8899": "Dashboard", "5222": "ejabberd", "8643": "知微Gateway"} - for port, name in ports.items(): - r = subprocess.run(["ss", "-tlnp"], capture_output=True, text=True, timeout=5) - listening = f":{port}" in r.stdout - lines.append(check(listening, f"{name} :{port} {'监听中' if listening else '未监听'}")) - if not listening: issues.append(f"{name} 端口{port}未监听"); warn_count += 1 - else: ok_count += 1 - - # 3. 数据文件检查 - lines.append("") - lines.append("【数据文件】") - files = { - "portfolio.json": PORTFOLIO_PATH, - "watchlist.json": DATA_DIR / "watchlist.json", - "decisions.json": DECISIONS_PATH, - "market.json": DATA_DIR / "market.json", - "price_events.json": EVENTS_PATH, - "evaluation.json": EVALUATION_PATH, - "accuracy_stats.json": ACCURACY_PATH, - } - for name, path in files.items(): - exists = path.exists() - size = path.stat().st_size if exists else 0 - lines.append(check(exists and size > 10, f"{name} {'存在' if exists else '缺失'} ({size}B)")) - if not exists or size < 10: - issues.append(f"{name} 缺失或为空") - warn_count += 1 - else: - ok_count += 1 - - # 4. 价格事件统计 - lines.append("") - lines.append("【价格事件】") - try: - from mofin_db import get_conn, query_price_events, query_price_events_by_date - conn = get_conn() - ev_list = query_price_events(conn, limit=50000) - today_events = query_price_events_by_date(conn, now.strftime("%Y-%m-%d")) - conn.close() - except Exception: - events = load_json(EVENTS_PATH, {"events": []}) - ev_list = events.get("events", []) - today_events = [e for e in ev_list if e.get("date") == now.strftime("%Y-%m-%d")] - lines.append(check(len(ev_list) > 0, f"历史事件: {len(ev_list)}条")) - lines.append(check(len(today_events) > 0, f"今日事件: {len(today_events)}条")) - if len(ev_list) == 0: - issues.append("price_events 无事件记录,price_monitor可能未触发过") - warn_count += 1 - else: - ok_count += 1 - - # 5. 策略评估统计 - lines.append("") - lines.append("【策略评估】") - evals = load_json(EVALUATION_PATH, {"strategies": []}) - s_list = evals.get("strategies", []) - lines.append(check(len(s_list) > 0, f"已评估策略: {len(s_list)}条")) - if len(s_list) > 0: - avg = sum(s.get("score", 0) for s in s_list) / len(s_list) - lines.append(check(avg > 0, f"平均评分: {avg:.1f}/10")) - ok_count += 1 - else: - issues.append("evaluation.json 无评估数据") - warn_count += 1 - - # 6. 建议记录统计 - lines.append("") - lines.append("【建议记录】") - decisions = load_json(DECISIONS_PATH, {"decisions": []}) - total_advice = sum(len(d.get("advice_timeline", [])) for d in decisions.get("decisions", [])) - lines.append(check(total_advice > 0, f"建议记录: {total_advice}条")) - if total_advice == 0: - issues.append("所有策略建议记录为空") - warn_count += 1 - else: - ok_count += 1 - - # 7. Cron jobs - lines.append("") - lines.append("【Cron Jobs】") - cnt, cron_issues = check_cron_jobs(CRON_JOBS, "default") - lines.append(check(cnt > 0, f"default profile: {cnt}个job")) - for ci in cron_issues: - lines.append(f" ⚠️ {ci}") - warn_count += 1 - if cnt == 0: warn_count += 1 - cnt2, cron_issues2 = check_cron_jobs(POSITION_CRON, "position-analyst") - lines.append(check(cnt2 > 0, f"position-analyst: {cnt2}个job")) - for ci in cron_issues2: - lines.append(f" ⚠️ {ci}") - warn_count += 1 - if cnt2 == 0: warn_count += 1 - - # 8. 数据新鲜度 - lines.append("") - lines.append("【数据新鲜度】") - # 各数据文件的合理最大陈旧时间(小时) - freshness_thresholds = { - "portfolio.json": 24, # 每日有数据即可 - "decisions.json": 48, # 策略参数更新频率较低 - "multi_tf_cache.json": 24, # K线缓存每日更新 - "macro_context.json": 24, # 宏观数据每日2次 - "market.json": 48, # 行业数据每日更新 - "strategy_staleness_report.json": 24, # 时效性报告每日生成 - } - data_files = { - "portfolio.json": PORTFOLIO_PATH, - "decisions.json": DECISIONS_PATH, - "multi_tf_cache.json": DATA_DIR / "multi_tf_cache.json", - "macro_context.json": DATA_DIR / "macro_context.json", - "market.json": DATA_DIR / "market.json", - "strategy_staleness_report.json": DATA_DIR / "strategy_staleness_report.json", - } - for name, path in data_files.items(): - if not path.exists(): - lines.append(check(False, f"{name} 缺失")) - issues.append(f"{name} 文件缺失") - warn_count += 1 - continue - mtime = datetime.fromtimestamp(path.stat().st_mtime) - hours_ago = (now - mtime).total_seconds() / 3600 - threshold = freshness_thresholds.get(name, 24) - fresh = hours_ago < threshold - time_str = f"{hours_ago:.0f}h前" if hours_ago >= 1 else f"{hours_ago*60:.0f}分钟前" - lines.append(check(fresh, f"{name} 更新于 {time_str} (阈值{threshold}h)")) - if not fresh: - issues.append(f"{name} 超过{threshold}h未更新(最近更新:{time_str})") - warn_count += 1 - else: - ok_count += 1 - - # 数据管道组件检查 - lines.append("") - lines.append("【数据管道】") - pipe_checks = [ - ("再生器(regenerate_all)", r"strategy_lifecycle\.py"), - ("市场采集(market_watch)", r"market_watch\.py"), - ("宏观采集(macro)", r"macro_context_collector\.py"), - ] - for pname, ppattern in pipe_checks: - r = subprocess.run(["pgrep", "-f", ppattern], capture_output=True, timeout=5) - if r.returncode == 0: - lines.append(check(True, f"{pname} 进程存在")) - ok_count += 1 - else: - # no_agent脚本不常驻,不报warn - lines.append(" 📎 {} 无常驻进程(no_agent脚本按cron调度运行)".format(pname)) - - # 价格数据更新时间检查(盘中应有当日数据) - is_trading_day = now.weekday() < 5 # 周一到周五 - if is_trading_day and now.hour >= 9 and now.hour < 16: - if PORTFOLIO_PATH.exists(): - mtime = datetime.fromtimestamp(PORTFOLIO_PATH.stat().st_mtime) - hours_ago = (now - mtime).total_seconds() / 3600 - has_intraday_data = mtime.date() == now.date() - lines.append(check(has_intraday_data, f"盘中有当日价格数据 {'是' if has_intraday_data else '否'}(最近{mtime.strftime('%H:%M')})")) - if not has_intraday_data: - issues.append(f"盘中交易时段但portfolio.json无今日数据(最近更新{mtime.strftime('%m-%d %H:%M')})") - warn_count += 1 - else: - ok_count += 1 - - # 汇总 - total = ok_count + warn_count - lines.append("") - lines.append(f"总计: ✅ {ok_count}/{total} 正常 | ⚠️ {warn_count}/{total} 需关注") - if issues: - lines.append("") - lines.append("需关注项:") - for i, issue in enumerate(issues[:10], 1): - lines.append(f" {i}. {issue}") - - report = "\n".join(lines) - print(report) - - # 如果有问题,写入报告文件供推送 - if warn_count > 0: - report_path = Path("/home/hmo/.hermes/profiles/position-analyst/cron/output/health") - report_path.mkdir(parents=True, exist_ok=True) - report_file = report_path / f"health_{now.strftime('%Y%m%d_%H%M')}.md" - report_file.write_text(f"# MoFin 系统健康检查\n\n{report}") - print(f"\n报告已写入 {report_file}") - else: - print("\n[SILENT] 一切正常") - - -if __name__ == "__main__": - run() +#!/usr/bin/env python3 +"""system_health_check.py — MoFin 系统健康检查 + +每日运行,检查所有组件是否正常工作。 +输出报告,有问题才推送。 +""" +import json, os, sys, subprocess +from datetime import datetime, timedelta +from pathlib import Path + +DATA_DIR = Path("/home/hmo/web-dashboard/data") +EVENTS_PATH = DATA_DIR / "price_events.json" +EVALUATION_PATH = DATA_DIR / "evaluation.json" +ACCURACY_PATH = DATA_DIR / "accuracy_stats.json" +CRON_JOBS = "/home/hmo/.hermes/profiles/position-analyst/cron/jobs.json" +POSITION_CRON = "/home/hmo/.hermes/profiles/position-analyst/cron/jobs.json" + +def check(ok, msg): + icon = "✅" if ok else "⚠️" + return f" {icon} {msg}" + +def load_json(path, default=None): + try: + with open(path) as f: + return json.load(f) + except: + return {} if default is None else default + +def check_cron_jobs(path, label): + issues = [] + try: + d = load_json(path, {"jobs": []}) + for j in d.get("jobs", []): + name = j.get("name", "?") + enabled = j.get("enabled", True) + last = j.get("last_run_at", "") + status = j.get("last_status", "") + if not enabled: + issues.append(f"{name} 已禁用") + elif not last: + issues.append(f"{name} 从未运行") + elif status != "ok": + issues.append(f"{name} 上次状态={status}") + return len(d.get("jobs", [])), issues + except: + return 0, ["无法读取"] + +def run(): + now = datetime.now() + issues = [] + ok_count = 0 + warn_count = 0 + + lines = [f"MoFin 系统健康检查 | {now.strftime('%Y-%m-%d %H:%M')}"] + lines.append("") + + # 1. 进程检查 + lines.append("【进程】") + procs = { + "mofin-dashboard": "mofin-dashboard", + "xmpp-zhiwei": "xmpp_zhiwei_bot", + "ejabberd": "ejabberd", + } + for name, pattern in procs.items(): + # 先查 systemd,再查 pgrep + r = subprocess.run(["systemctl", "is-active", f"{pattern}.service"], capture_output=True, text=True, timeout=5) + alive = r.stdout.strip() == "active" + if not alive: + r2 = subprocess.run(["pgrep", "-f", pattern], capture_output=True, timeout=5) + alive = r2.returncode == 0 + lines.append(check(alive, f"{name} {'运行中' if alive else '已停止'}")) + if not alive: issues.append(f"{name} 进程不存在"); warn_count += 1 + else: ok_count += 1 + + # 2. 端口检查 + lines.append("") + lines.append("【端口】") + ports = {"8899": "Dashboard", "5222": "ejabberd", "8643": "知微Gateway"} + for port, name in ports.items(): + r = subprocess.run(["ss", "-tlnp"], capture_output=True, text=True, timeout=5) + listening = f":{port}" in r.stdout + lines.append(check(listening, f"{name} :{port} {'监听中' if listening else '未监听'}")) + if not listening: issues.append(f"{name} 端口{port}未监听"); warn_count += 1 + else: ok_count += 1 + + # 3. 数据文件检查 + lines.append("") + lines.append("【数据文件】") + # DB 优先:从 SQLite 查询代替 JSON 文件检查 + try: + from mo_data import read_portfolio, read_decisions, read_watchlist + pf = read_portfolio() + lines.append(check(len(pf.get("holdings", [])) > 0, f"portfolio.json DB记录: {len(pf.get('holdings', []))}条")) + ok_count += 1 + wl = read_watchlist() + lines.append(check(len(wl.get("stocks", [])) > 0, f"watchlist.json DB记录: {len(wl.get('stocks', []))}条")) + ok_count += 1 + dec = read_decisions() + lines.append(check(len(dec.get("decisions", [])) > 0, f"decisions.json DB记录: {len(dec.get('decisions', []))}条")) + ok_count += 1 + except Exception: + lines.append(check(False, "MoFin DB 数据读取失败")) + warn_count += 3 + + # 仍为 JSON 文件的检查 + files = { + "market.json": DATA_DIR / "market.json", + "price_events.json": EVENTS_PATH, + "evaluation.json": EVALUATION_PATH, + "accuracy_stats.json": ACCURACY_PATH, + } + for name, path in files.items(): + exists = path.exists() + size = path.stat().st_size if exists else 0 + lines.append(check(exists and size > 10, f"{name} {'存在' if exists else '缺失'} ({size}B)")) + if not exists or size < 10: + issues.append(f"{name} 缺失或为空") + warn_count += 1 + else: + ok_count += 1 + + # 4. 价格事件统计 + lines.append("") + lines.append("【价格事件】") + try: + from mofin_db import get_conn, query_price_events, query_price_events_by_date + conn = get_conn() + ev_list = query_price_events(conn, limit=50000) + today_events = query_price_events_by_date(conn, now.strftime("%Y-%m-%d")) + conn.close() + except Exception: + events = load_json(EVENTS_PATH, {"events": []}) + ev_list = events.get("events", []) + today_events = [e for e in ev_list if e.get("date") == now.strftime("%Y-%m-%d")] + lines.append(check(len(ev_list) > 0, f"历史事件: {len(ev_list)}条")) + lines.append(check(len(today_events) > 0, f"今日事件: {len(today_events)}条")) + if len(ev_list) == 0: + issues.append("price_events 无事件记录,price_monitor可能未触发过") + warn_count += 1 + else: + ok_count += 1 + + # 5. 策略评估统计 + lines.append("") + lines.append("【策略评估】") + evals = load_json(EVALUATION_PATH, {"strategies": []}) + s_list = evals.get("strategies", []) + lines.append(check(len(s_list) > 0, f"已评估策略: {len(s_list)}条")) + if len(s_list) > 0: + avg = sum(s.get("score", 0) for s in s_list) / len(s_list) + lines.append(check(avg > 0, f"平均评分: {avg:.1f}/10")) + ok_count += 1 + else: + issues.append("evaluation.json 无评估数据") + warn_count += 1 + + # 6. 建议记录统计 + lines.append("") + lines.append("【建议记录】") + try: + from mo_data import read_decisions + dec = read_decisions() + total_advice = sum(len(d.get("advice_timeline", [])) for d in dec.get("decisions", [])) + except Exception: + dec = {"decisions": []} + total_advice = 0 + lines.append(check(total_advice > 0, f"建议记录: {total_advice}条")) + if total_advice == 0: + issues.append("所有策略建议记录为空") + warn_count += 1 + else: + ok_count += 1 + + # 7. Cron jobs + lines.append("") + lines.append("【Cron Jobs】") + cnt, cron_issues = check_cron_jobs(CRON_JOBS, "default") + lines.append(check(cnt > 0, f"default profile: {cnt}个job")) + for ci in cron_issues: + lines.append(f" ⚠️ {ci}") + warn_count += 1 + if cnt == 0: warn_count += 1 + cnt2, cron_issues2 = check_cron_jobs(POSITION_CRON, "position-analyst") + lines.append(check(cnt2 > 0, f"position-analyst: {cnt2}个job")) + for ci in cron_issues2: + lines.append(f" ⚠️ {ci}") + warn_count += 1 + if cnt2 == 0: warn_count += 1 + + # 8. 数据新鲜度 + lines.append("") + lines.append("【数据新鲜度】") + # DB 优先:从 SQLite 查最新更新时间 + try: + from mofin_db import get_conn + conn = get_conn() + db_checks = { + "portfolio (DB)": ("SELECT MAX(updated_at) FROM holdings", 24), + "decisions (DB)": ("SELECT MAX(updated_at) FROM holding_strategies WHERE status IN ('active','updated')", 48), + } + for name, (sql, threshold) in db_checks.items(): + row = conn.execute(sql).fetchone() + ts = row[0] if row and row[0] else None + if not ts: + lines.append(check(False, f"{name} 无更新时间戳")) + issues.append(f"{name} 无更新时间戳") + warn_count += 1 + continue + mtime = datetime.strptime(ts[:19], "%Y-%m-%d %H:%M:%S") if len(ts) >= 19 else datetime.strptime(ts[:10], "%Y-%m-%d") + hours_ago = (now - mtime).total_seconds() / 3600 + fresh = hours_ago < threshold + time_str = f"{hours_ago:.0f}h前" if hours_ago >= 1 else f"{hours_ago*60:.0f}分钟前" + lines.append(check(fresh, f"{name} 更新于 {time_str} (阈值{threshold}h)")) + if not fresh: + issues.append(f"{name} 超过{threshold}h未更新(最近更新:{time_str})") + warn_count += 1 + else: + ok_count += 1 + conn.close() + except Exception: + lines.append(check(False, "DB 数据新鲜度检查失败")) + + # JSON 文件新鲜度(仅限尚未迁移到 DB 的) + freshness_thresholds = { + "multi_tf_cache.json": 24, # K线缓存每日更新 + "macro_context.json": 24, # 宏观数据每日2次 + "market.json": 48, # 行业数据每日更新 + "strategy_staleness_report.json": 24, # 时效性报告每日生成 + } + data_files = { + "multi_tf_cache.json": DATA_DIR / "multi_tf_cache.json", + "macro_context.json": DATA_DIR / "macro_context.json", + "market.json": DATA_DIR / "market.json", + "strategy_staleness_report.json": DATA_DIR / "strategy_staleness_report.json", + } + for name, path in data_files.items(): + if not path.exists(): + lines.append(check(False, f"{name} 缺失")) + issues.append(f"{name} 文件缺失") + warn_count += 1 + continue + mtime = datetime.fromtimestamp(path.stat().st_mtime) + hours_ago = (now - mtime).total_seconds() / 3600 + threshold = freshness_thresholds.get(name, 24) + fresh = hours_ago < threshold + time_str = f"{hours_ago:.0f}h前" if hours_ago >= 1 else f"{hours_ago*60:.0f}分钟前" + lines.append(check(fresh, f"{name} 更新于 {time_str} (阈值{threshold}h)")) + if not fresh: + issues.append(f"{name} 超过{threshold}h未更新(最近更新:{time_str})") + warn_count += 1 + else: + ok_count += 1 + + # 数据管道组件检查 + lines.append("") + lines.append("【数据管道】") + pipe_checks = [ + ("再生器(regenerate_all)", r"strategy_lifecycle\.py"), + ("市场采集(market_watch)", r"market_watch\.py"), + ("宏观采集(macro)", r"macro_context_collector\.py"), + ] + for pname, ppattern in pipe_checks: + r = subprocess.run(["pgrep", "-f", ppattern], capture_output=True, timeout=5) + if r.returncode == 0: + lines.append(check(True, f"{pname} 进程存在")) + ok_count += 1 + else: + # no_agent脚本不常驻,不报warn + lines.append(" 📎 {} 无常驻进程(no_agent脚本按cron调度运行)".format(pname)) + + # 价格数据更新时间检查(盘中应有当日数据) + is_trading_day = now.weekday() < 5 # 周一到周五 + if is_trading_day and now.hour >= 9 and now.hour < 16: + try: + from mofin_db import get_conn + conn = get_conn() + row = conn.execute("SELECT MAX(updated_at) FROM holdings WHERE is_active=1").fetchone() + conn.close() + ts = row[0] if row and row[0] else None + if ts: + mtime = datetime.strptime(ts[:19], "%Y-%m-%d %H:%M:%S") if len(ts) >= 19 else datetime.strptime(ts[:10], "%Y-%m-%d") + has_intraday_data = mtime.date() == now.date() + lines.append(check(has_intraday_data, f"盘中有当日价格数据 {'是' if has_intraday_data else '否'}(最近{mtime.strftime('%H:%M')})")) + if not has_intraday_data: + issues.append(f"盘中交易时段但DB holdings无今日数据(最近更新{mtime.strftime('%m-%d %H:%M')})") + warn_count += 1 + else: + ok_count += 1 + else: + lines.append(check(False, "盘中DB holdings无价格更新记录")) + warn_count += 1 + except Exception: + lines.append(check(False, "盘中DB价格数据检查失败")) + warn_count += 1 + + # 汇总 + total = ok_count + warn_count + lines.append("") + lines.append(f"总计: ✅ {ok_count}/{total} 正常 | ⚠️ {warn_count}/{total} 需关注") + if issues: + lines.append("") + lines.append("需关注项:") + for i, issue in enumerate(issues[:10], 1): + lines.append(f" {i}. {issue}") + + report = "\n".join(lines) + print(report) + + # 如果有问题,写入报告文件供推送 + if warn_count > 0: + report_path = Path("/home/hmo/.hermes/profiles/position-analyst/cron/output/health") + report_path.mkdir(parents=True, exist_ok=True) + report_file = report_path / f"health_{now.strftime('%Y%m%d_%H%M')}.md" + report_file.write_text(f"# MoFin 系统健康检查\n\n{report}") + print(f"\n报告已写入 {report_file}") + else: + print("\n[SILENT] 一切正常") + + +if __name__ == "__main__": + run()