commit 9b9c37002ad230a95a849168ff79ecbb936f5300 Author: 知微 Date: Fri Jun 12 22:54:51 2026 +0800 Initial: MoFin 持仓分析与策略管理系统 核心模块: - 策略生命周期管理 (strategy_lifecycle.py) - 技术分析引擎 (technical_analysis.py) - 双维度策略评估 (strategy_evaluator.py) - 实时行情获取 (get_realtime_prices.py) - Web Dashboard (server.py, :8899) 提示词版本管理: - prompt_manager 模块 — 统一管理所有知微提示词 - 8个提示词共24个版本已录入 - 策略→提示词版本关联追踪 - Dashboard「提示词」Tab 数据源增强: - 服务端 POST /api/update/realtime 端点已就绪 - clients/tdx-relay/ — 小小莫在Windows上开发的通达信中继 - 解决港股15分钟延迟问题 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e56b26f --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ +*.egg + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# 环境 +.env +venv/ +.venv/ +env/ + +# 运行时数据(全部忽略,个别用git add -f加入) +data/ + +# 上传文件 +uploads/ + +# 客户端本地配置 +clients/tdx-relay/src/relay/config_local.py + +# 日志 +*.log + +# 系统 +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..c4f4a35 --- /dev/null +++ b/README.md @@ -0,0 +1,118 @@ +# MoFin — 持仓分析与策略管理系统 + +## 概述 + +MoFin(Mo Fin/莫Fin)是知微的持仓分析与策略管理系统。提供实时行情监控、策略生命周期管理、自动盯盘推送、知识萃取等功能。 + +## 目录结构 + +``` +MoFin/ +├── src/ # 核心源码(Linux 服务器端) +│ ├── server.py # Flask Web Dashboard (port 8899) +│ ├── strategy_lifecycle.py # 策略生命周期管理(核心引擎) +│ ├── technical_analysis.py # 技术分析模块(枢轴点/支撑压力) +│ ├── strategy_evaluator.py # 策略双维度评估 +│ ├── strategy_feedback.py # 策略反馈引擎 +│ ├── advice_reconciliation.py # 建议对齐工具 +│ ├── check_key_levels.py # 关键价位检查 +│ ├── get_realtime_prices.py # 腾讯API实时行情 +│ ├── bulk_strategy_regenerate.py # 批量策略重生成 +│ ├── post_mortem.py # 事后分析 +│ ├── market_watch.py # 市场数据采集 +│ ├── system_health_check.py # 系统健康检查 +│ ├── regenerate_strategies.py # 策略重生成 +│ └── prompt_manager/ # 📝 提示词版本管理模块 +│ ├── registry.py # 注册表CRUD + 版本管理 +│ ├── tracking.py # 策略→提示词版本关联 +│ ├── analytics.py # 版本有效性分析引擎 +│ └── dashboard_views.py # Dashboard API路由 +├── clients/ # 客户端程序(在其他机器上运行) +│ └── tdx-relay/ # 🚀 通达信行情中继 +│ ├── docs/TDX_DEV_GUIDE.md # 开发指南(给小小莫) +│ ├── src/relay/ # 中继源代码 +│ │ ├── tdx_client.py # opentdx 连接封装(待小小莫实现) +│ │ ├── config.py # 配置(招商证券IP等) +│ │ └── pusher.py # 推送数据到MoFin API +│ ├── scripts/ +│ │ ├── run_relay.py # 运行入口 +│ │ └── install_deps.bat # Windows依赖安装 +│ └── tests/ +│ ├── test_tdx_connect.py # 通达信连接测试 +│ └── test_push.py # MoFin API推送测试 +├── data/ # 运行时数据(JSON) +│ ├── portfolio.json # 持仓数据 +│ ├── watchlist.json # 自选股 +│ ├── decisions.json # 策略决策库 +│ ├── evaluation.json # 评估数据 +│ ├── accuracy_stats.json # 准确率统计 +│ ├── prompts/ # 📝 提示词版本数据 +│ │ ├── registry.json # 注册表 +│ │ ├── versions/ # 版本内容文件 +│ │ └── associations.json # 策略关联记录 +│ └── ...(其他运行时数据) +├── docs/ # 文档 +│ ├── EXPERT_SYSTEM_DESIGN.md # 专家系统设计说明书 +│ ├── SYSTEM_ARCHITECTURE.md # 系统架构文档 +│ └── PROMPT_MANAGER.md # 📝 提示词管理模块说明 +└── README.md # 本文件 +``` + +## 数据流 + +``` + ┌────────────────────────────────────────┐ + │ 小小莫 (Windows) │ + │ 通达信中继 → POST /api/update/realtime │ + └────────────────┬───────────────────────┘ + │ 实时港股行情 + ▼ +┌─── 腾讯API (实时A股) ──→ MoFin Dashboard (:8899) ──→ 知微 → XMPP → 老爸 +└─── 通达信中继 (实时港股) ──↗ + │ + ▼ + price_monitor.py → 价格偏离检测 + │ + ▼ + strategy_lifecycle.py → 策略评估 + │ + ▼ + cron_to_xmpp.py → XMPP推送 +``` + +## 数据源说明 + +| 数据 | 来源 | 实时性 | 备注 | +|------|------|--------|------| +| A股行情 | 腾讯API (qt.gtimg.cn) | 实时 | 稳定运行 | +| 港股行情 | 通达信中继 → 招商证券主站 | **实时** | 解决原15分钟延迟 | +| K线/技术面 | 腾讯API + 新浪API | 日线级 | 收盘后补充 | +| 策略数据 | strategy_lifecycle.py | 按需 | 技术面驱动 | + +## 中继系统(tdx-relay) + +小小莫在 Windows 上运行 `clients/tdx-relay/` 下的程序,利用招商证券(通达信内核)的行情主站获取实时港股行情,推送到 MoFin Dashboard。 + +详见:[clients/tdx-relay/docs/TDX_DEV_GUIDE.md](clients/tdx-relay/docs/TDX_DEV_GUIDE.md) + +## Cron 任务清单 + +| 任务 | 频率 | 说明 | +|------|------|------| +| price_monitor.py | 每1分钟 | 价格偏离监控 | +| market_watch.py | 每30分钟 | 市场数据采集 | +| system_health_check.py | 每日9:00 | 系统健康检查 | +| cron_to_xmpp.py | 每1分钟 | 报告→XMPP推送 | +| sync_dashboard.py | 每小时 | 数据同步 | +| bulk_strategy_regenerate.py | 收盘后 | 全量策略重评 | +| strategy_evaluator.py | 每日21:00 | 双维度评估 | +| advice_reconciliation.py | 周六20:00 | 建议对账 | +| 知识萃取 | 16:30 | 经验日志写入 | +| 快速盯盘 | 每15分钟(交易时段) | 盘面监控 | +| 持仓复查 | 周四20:00 | 全面复查 | + +## 提示词版本管理 + +MoFin 内置了提示词版本管理系统,所有知微用到的提示词(策略生成规则、盯盘prompt、报告格式等)都在 `prompt_manager` 中统一管理,每个版本关联生成的策略,并统计哪个版本最有效。 + +详见 `docs/PROMPT_MANAGER.md`,Dashboard 中也有「提示词」Tab。 diff --git a/clients/tdx-relay/docs/TDX_DEV_GUIDE.md b/clients/tdx-relay/docs/TDX_DEV_GUIDE.md new file mode 100644 index 0000000..395fe66 --- /dev/null +++ b/clients/tdx-relay/docs/TDX_DEV_GUIDE.md @@ -0,0 +1,191 @@ +# 通达信行情中继 — 开发指南 + +> 写给小小莫。在 Windows 上用 Python 直连招商证券的通达信行情主站,获取实时港股行情,推送到知微的 MoFin Dashboard。 + +--- + +## 一、背景 + +知微的 MoFin 系统目前用腾讯免费 API 获取行情。A 股是实时的,但港股有 **15 分钟延迟**。 + +你在 Windows 上的招商证券客户端(通达信内核)是买了港股实时数据的。我们可以通过 Python 直连招商证券的行情主站,拿到**真正实时的港股行情**,然后推送到知微的 Dashboard。 + +## 二、技术方案 + +``` +招商证券客户端 (通信设置里的IP:7709) + │ + ▼ + opentdx (Python库) + │ + ▼ + tdx_relay.py (你写) + │ POST /api/update/realtime + ▼ + MoFin Dashboard (192.168.1.246:8899) +``` + +推荐用 **opentdx**(pytdx 的升级版): + +```bash +pip install opentdx requests +``` + +## 三、第一步:拿行情主站IP + +打开招商证券 PC 客户端: + +1. **菜单 → 选项 → 通信设置** +2. 你会看到一列行情主站,类似:`招商证券深圳主站 113.105.73.88:7709` +3. 记下来,填到 `src/relay/config.py` 里 + +或者在命令行找: + +```cmd +tasklist | findstr "zhsh" # 找招商证券进程PID +netstat -ano | findstr "7709" # 看连接的IP +``` + +## 四、CLI 验证连通性 + +装好 opentdx 后,先用命令行测试: + +```bash +# 测试 A 股 +opentdx quote "SZ 000001" + +# 测试港股(用你的服务器IP替换) +opentdx g-quote "HK_MAIN_BOARD 00700" --server 113.105.73.88:7709 +``` + +> opentdx 的完整命令列表:`opentdx doc`(交互式文档) + +## 五、Python 代码示例 + +### 连接 + 获取港股行情 + +```python +from opentdx.tdxClient import TdxClient +from opentdx.const import EX_MARKET + +# 方式一:自动选最快服务器 +with TdxClient() as client: + # A股 + a_quotes = client.stock_quotes([(0, '000001')]) + + # 港股 ⭐ + hk_quotes = client.goods_quotes([ + (EX_MARKET.HK_MAIN_BOARD, '00700'), # 腾讯 + (EX_MARKET.HK_MAIN_BOARD, '09988'), # 阿里 + ]) + for q in hk_quotes: + print(f"{q['code']}: {q['price']} {q['change_pct']}%") +``` + +> ⚠️ `opentdx` 的具体 API 以 `opentdx doc` 为准。 +> GitHub: https://github.com/acb6104/opentdx + +### 推送到 MoFin + +用现成的工具类: + +```python +from relay.pusher import MoFinPusher + +pusher = MoFinPusher("http://192.168.1.246:8899") +result = pusher.push([ + { + "code": "00700", + "name": "腾讯控股", + "price": 463.6, + "change_pct": 1.55, + "high": 468.0, + "low": 460.2, + "open": 462.0, + "volume": 25000000, + "timestamp": "2026-06-12 14:30:00" + } +]) +print(result) # {"status": "ok", "updated": 1} +``` + +## 六、你的任务清单 + +### 阶段一:环境 + 连通性 +- [ ] 装 Python 3.10+(如果没有的话) +- [ ] `pip install opentdx requests` +- [ ] 运行 `opentdx doc` 看看接口 +- [ ] 从招商证券通信设置拿到行情主站IP +- [ ] 用 CLI 测试港股:`opentdx g-quote "HK_MAIN_BOARD 00700" --server :7709` + +### 阶段二:港股行情获取 +- [ ] 实现 `tdx_client.py` 的连接和港股查询 +- [ ] 验证单只港股(腾讯00700)数据正确 +- [ ] 验证批量港股(持仓列表) +- [ ] ⚠️ **对比招商证券客户端价格,确认数据准确**(红线) + +### 阶段三:数据推送 +- [ ] 实现 `run_relay.py` 主循环 +- [ ] 测试推送一条数据到 MoFin Dashboard +- [ ] 全量推送所有持仓港股 + +### 阶段四:自动化 +- [ ] 设置 Windows 定时任务(每15~30秒运行一次) + +## 七、注意事项 + +### 数据准确性(⚠️红线) +```python +# 从通达信拿到的价格 和 招商证券客户端显示的现价 +# 两者必须一致! +tdx_price = 463.6 +assert abs(tdx_price - 招商证券_显示价格) < 0.01 +``` + +### A股不要动 +A 股继续走腾讯 API,已经是实时的。**本项目只解决港股延迟。** + +### 字段映射 + +| 含义 | 腾讯API索引 | 通达信字段 | +|------|------------|-----------| +| 当前价 | fields[3] | price | +| 昨收 | fields[4] | last_close | +| 今开 | fields[5] | open | +| 最高 | fields[33] | high | +| 最低 | fields[34] | low | +| 涨跌幅 | fields[32] | change_pct | + +### 回退方案 +通达信连不上时自动回退腾讯 API(当前方案),不中断行情更新。 + +```python +try: + data = tdx_client.get_quotes(codes) +except Exception: + data = tencent_api.get_quotes(codes) # 回退 +``` + +### 连接稳定性 +opentdx 内置心跳,但网络不稳时需要重连: + +```python +def safe_get(client, codes, retries=3): + for i in range(retries): + try: + return client.goods_quotes(codes) + except (ConnectionError, TimeoutError): + client.disconnect() + time.sleep(2) + client.connect(ip, port) + return None # 回退腾讯API +``` + +## 八、参考 + +| 资源 | 地址 | +|------|------| +| opentdx GitHub | https://github.com/acb6104/opentdx | +| opentdx PyPI | `pip install opentdx` | +| MoFin Dashboard | http://192.168.1.246:8899 | +| 架构文档 | 见本目录上级 `docs/` | diff --git a/clients/tdx-relay/requirements.txt b/clients/tdx-relay/requirements.txt new file mode 100644 index 0000000..49fc7d5 --- /dev/null +++ b/clients/tdx-relay/requirements.txt @@ -0,0 +1,2 @@ +opentdx>=0.1.0 +requests>=2.25.0 diff --git a/clients/tdx-relay/scripts/install_deps.bat b/clients/tdx-relay/scripts/install_deps.bat new file mode 100644 index 0000000..04fc8ac --- /dev/null +++ b/clients/tdx-relay/scripts/install_deps.bat @@ -0,0 +1,13 @@ +@echo off +echo MoFin TDX Relay - 依赖安装 +pip install opentdx +if %ERRORLEVEL% NEQ 0 ( + echo ❌ opentdx 安装失败 + pause + exit /b 1 +) +pip install requests +echo ✅ 安装完成 +echo 下一步:编辑 src/relay/config.py 填入招商证券IP +echo 然后运行:python tests/test_tdx_connect.py +pause diff --git a/clients/tdx-relay/scripts/run_relay.py b/clients/tdx-relay/scripts/run_relay.py new file mode 100644 index 0000000..ac245c7 --- /dev/null +++ b/clients/tdx-relay/scripts/run_relay.py @@ -0,0 +1,62 @@ +"""运行入口 — 循环获取通达信行情并推送 MoFin + +小小莫:完成 tdx_client.py 后运行此脚本。 +""" + +import time +import logging +from relay.tdx_client import TDXClient +from relay.pusher import MoFinPusher +from relay.config import MARKET_SERVERS, MOFIN_URL, PUSH_INTERVAL, HK_STOCKS + +logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") +log = logging.getLogger(__name__) + + +def find_working_server() -> tuple: + """自动检测可用的通达信服务器""" + for name, ip, port in MARKET_SERVERS: + try: + client = TDXClient() + client.connect(ip, port) + data = client.get_hk_quote(31, "00700") + client.close() + if data and data.get("price"): + log.info(f"✅ {name} ({ip}:{port}) 可用") + return (ip, port) + except Exception as e: + log.warning(f"❌ {name} ({ip}:{port}): {e}") + return (None, None) + + +def main(): + log.info("🚀 TDX Relay 启动") + log.info(f"目标: {MOFIN_URL} | 港股: {len(HK_STOCKS)}只 | 间隔: {PUSH_INTERVAL}s") + + ip, port = find_working_server() + if not ip: + log.error("没有可用服务器") + return + + pusher = MoFinPusher(MOFIN_URL) + client = TDXClient() + try: + client.connect(ip, port) + while True: + try: + hk_data = client.get_hk_quotes() + if hk_data: + r = pusher.push(hk_data) + log.info(f"推送 {len(hk_data)} 只: {r.get('status')}") + time.sleep(PUSH_INTERVAL) + except (ConnectionError, TimeoutError): + log.warning("断连,重试...") + client.close() + time.sleep(3) + client.connect(ip, port) + finally: + client.close() + + +if __name__ == "__main__": + main() diff --git a/clients/tdx-relay/src/relay/__init__.py b/clients/tdx-relay/src/relay/__init__.py new file mode 100644 index 0000000..c173f14 --- /dev/null +++ b/clients/tdx-relay/src/relay/__init__.py @@ -0,0 +1 @@ +"""tdx-relay package — 通达信行情中继""" diff --git a/clients/tdx-relay/src/relay/config.py b/clients/tdx-relay/src/relay/config.py new file mode 100644 index 0000000..86f5970 --- /dev/null +++ b/clients/tdx-relay/src/relay/config.py @@ -0,0 +1,39 @@ +"""配置管理 — 请在 Windows 上填好招商证券行情主站IP后运行""" + +# 招商证券行情主站列表 +# 打开招商证券PC客户端 → 通信设置 查看 +# 格式: (名称, IP, 端口) +MARKET_SERVERS = [ + # 示例(替换为你的实际IP): + # ("招商证券深圳主站", "113.105.73.88", 7709), + # ("招商证券上海主站", "211.154.53.106", 7709), + # TODO: 小小莫替换为实际IP +] + +# MoFin Dashboard 地址 +MOFIN_URL = "http://192.168.1.246:8899" + +# 推送频率(秒) +PUSH_INTERVAL = 15 + +# 港股列表(市场代码, 股票代码) +# 港股主板 market_code = 31 +HK_STOCKS = [ + (31, "00700"), # 腾讯控股 + (31, "09988"), # 阿里巴巴-W + (31, "00981"), # 中芯国际 + (31, "01211"), # 比亚迪股份 + (31, "01888"), # 建滔积层板 + (31, "02318"), # 中国平安 + (31, "02359"), # 药明康德 + (31, "02388"), # 中银香港 + (31, "02628"), # 中国人寿 + (31, "06160"), # 百济神州 + (31, "06869"), # 长飞光纤 + (31, "09868"), # 小鹏汽车-W + (31, "01070"), # TCL电子 + (31, "01088"), # 中国神华 + (31, "00968"), # 信义光能 + (31, "02202"), # 万科企业 + (31, "01478"), # 丘钛科技 +] diff --git a/clients/tdx-relay/src/relay/pusher.py b/clients/tdx-relay/src/relay/pusher.py new file mode 100644 index 0000000..26701da --- /dev/null +++ b/clients/tdx-relay/src/relay/pusher.py @@ -0,0 +1,40 @@ +"""数据推送器 — 推送实时行情到 MoFin Dashboard""" + +import json +import time +import urllib.request +import urllib.error + + +class MoFinPusher: + """推送实时行情到 MoFin Dashboard API""" + + def __init__(self, base_url: str = "http://192.168.1.246:8899"): + self.base_url = base_url.rstrip("/") + self.endpoint = f"{self.base_url}/api/update/realtime" + + def push(self, stocks: list) -> dict: + """推送一批实时行情 + + Args: + stocks: [{"code", "price", "change_pct", ...}, ...] + + Returns: + {"status": "ok", "updated": N, "timestamp": "..."} + """ + payload = json.dumps({ + "stocks": stocks, + "source": "tdx_relay", + }).encode("utf-8") + + req = urllib.request.Request( + self.endpoint, + data=payload, + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=10) as resp: + return json.loads(resp.read().decode("utf-8")) + except (urllib.error.URLError, urllib.error.HTTPError, OSError) as e: + return {"status": "error", "message": str(e)} diff --git a/clients/tdx-relay/src/relay/tdx_client.py b/clients/tdx-relay/src/relay/tdx_client.py new file mode 100644 index 0000000..7c63082 --- /dev/null +++ b/clients/tdx-relay/src/relay/tdx_client.py @@ -0,0 +1,62 @@ +"""TDX 行情客户端 — 连接通达信行情服务器获取实时数据 + +小小莫: +1. pip install opentdx +2. 在 config.py 中填入招商证券行情主站IP +3. 实现下面的方法 +4. 运行 test_tdx_connect.py 验证 +""" + +# ═══════════════════════════════════════════ +# 配置区 — 请替换为你的招商证券行情主站IP +# ═══════════════════════════════════════════ +from .config import MARKET_SERVERS, HK_STOCKS + + +class TDXClient: + """通达信行情客户端封装 + + 用法: + client = TDXClient() + client.connect("113.105.73.88", 7709) + data = client.get_hk_quotes() + client.close() + """ + + def __init__(self): + self.client = None + + def connect(self, ip: str, port: int = 7709): + """连接到通达信行情服务器""" + # TODO: 小小莫实现 + # from opentdx.client.macExtendedClient import MacExtendedClient + # self.client = MacExtendedClient() + # self.client.connect(ip, port) + raise NotImplementedError("由小小莫实现") + + def get_hk_quote(self, market_code: int, code: str) -> dict: + """获取单只港股实时报价 + + Args: + market_code: 港股主板=31 + code: 如 "00700" + + Returns: + {"code": "00700", "name": "腾讯控股", "price": 463.6, + "change_pct": 1.55, "high": 468.0, "low": 460.2, ...} + """ + raise NotImplementedError("由小小莫实现") + + def get_hk_quotes(self, codes: list = None) -> list: + """批量获取港股实时报价""" + if codes is None: + codes = [(31, code) for _, code in HK_STOCKS] + raise NotImplementedError("由小小莫实现") + + def close(self): + if self.client: + try: + self.client.disconnect() + except Exception: + pass + self.client = None diff --git a/clients/tdx-relay/tests/test_push.py b/clients/tdx-relay/tests/test_push.py new file mode 100644 index 0000000..c997ba8 --- /dev/null +++ b/clients/tdx-relay/tests/test_push.py @@ -0,0 +1,23 @@ +"""MoFin API 推送测试""" + +import sys, os +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) +from relay.pusher import MoFinPusher + +pusher = MoFinPusher() +result = pusher.push([{ + "code": "00700", + "name": "腾讯控股", + "price": 463.6, + "change_pct": 1.55, + "high": 468.0, + "low": 460.2, + "open": 462.0, + "volume": 25000000, + "timestamp": "2026-06-12 14:30:00", +}]) +print(f"推送结果: {result}") +if result.get("status") == "ok": + print("✅ 推送成功") +else: + print(f"❌ 失败: {result.get('message')}") diff --git a/clients/tdx-relay/tests/test_tdx_connect.py b/clients/tdx-relay/tests/test_tdx_connect.py new file mode 100644 index 0000000..d4c17f4 --- /dev/null +++ b/clients/tdx-relay/tests/test_tdx_connect.py @@ -0,0 +1,49 @@ +"""通达信连接测试 — 验证能否连上招商证券服务器 + +用法: + python tests/test_tdx_connect.py +""" + +import sys +import os +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + +try: + import opentdx + print(f"✅ opentdx {opentdx.__version__}") +except ImportError: + print("❌ 请先 pip install opentdx") + sys.exit(1) + + +def test_server(ip, port, name="未知"): + print(f"\n🔄 {name}: {ip}:{port}") + try: + from opentdx.tdxClient import TdxClient + from opentdx.const import EX_MARKET + + with TdxClient() as client: + # A股 + aq = client.stock_quotes([(0, '000001')]) + if aq: + print(f" ✅ A股 平安银行: {aq[0].get('price', '?')}") + + # 港股 + hk = client.goods_quotes([(EX_MARKET.HK_MAIN_BOARD, '00700')]) + if hk: + q = hk[0] + print(f" ✅ 港股 腾讯: {q.get('price', '?')} ({q.get('change_pct', '?')}%)") + else: + print(" ❌ 港股无数据") + except Exception as e: + print(f" ❌ {e}") + + +if __name__ == "__main__": + from relay.config import MARKET_SERVERS + if MARKET_SERVERS: + for name, ip, port in MARKET_SERVERS: + test_server(ip, port, name) + else: + print("⚠️ config.py 中 MARKET_SERVERS 为空") + print("请在招商证券客户端 → 通信设置 查看IP后填入") diff --git a/data/prompts/registry.json b/data/prompts/registry.json new file mode 100644 index 0000000..f578c01 --- /dev/null +++ b/data/prompts/registry.json @@ -0,0 +1,407 @@ +{ + "prompts": [ + { + "id": "strategy-generation", + "name": "策略生成规则", + "description": "用于生成买入区/止损/止盈的技术面策略规则集,嵌入 strategy_lifecycle.py 的 reassess_strategy() 函数", + "category": "strategy", + "locations": [ + "/home/hmo/web-dashboard/strategy_lifecycle.py", + "/home/hmo/projects/MoFin/src/strategy_lifecycle.py", + "finance/price-range-monitor SKILL.md" + ], + "versions": [ + { + "version": "v1", + "label": "初始机械百分比", + "created_at": "2026-06-09T08:00:00", + "changelog": "初始版本,基于固定百分比(±5~10%)计算买入区/止损/止盈,无技术面支撑", + "content": "策略生成规则 v1(初始机械百分比)\n\n止损 = 成本 × 0.85(-15%)\n止盈 = 成本 × 1.20(+20%)\n买入区 = 现价 × 0.90 ~ 现价 × 1.05\n\n无技术面分析,纯百分比计算。\n", + "content_path": "/home/hmo/projects/MoFin/data/prompts/versions/strategy-generation-v1.md", + "author": "知微", + "status": "deprecated", + "tags": [ + "机械百分比" + ] + }, + { + "version": "v2", + "label": "技术面支撑压力位 v1", + "created_at": "2026-06-11T10:00:00", + "changelog": "从机械百分比改为基于 technical_analysis.py 的支撑/压力位计算,止损放强支撑,止盈放强压力", + "content": "策略生成规则 v2(技术面支撑压力位 v1)\n\n1. 止损 = 强支撑(strong_support),约5-8%跌幅\n2. 止盈 = 强压力(strong_resist)\n3. 买入区 = 弱支撑(ws) ~ 弱压力(wr)\n4. 新买入/已持仓统一策略\n5. 无R/R校验\n", + "content_path": "/home/hmo/projects/MoFin/data/prompts/versions/strategy-generation-v2.md", + "author": "知微", + "status": "deprecated", + "tags": [ + "技术面", + "支撑压力" + ] + }, + { + "version": "v2.1", + "label": "技术面 + R/R 校验", + "created_at": "2026-06-12T10:00:00", + "changelog": "新增R/R≥2.0校验+4%最小波幅保护。修复比亚迪A单日振幅±0.5%导致R/R=0.8的问题。R/R不满足时尝试多级阻力位上调止盈", + "content": "策略生成规则 v2.1(技术面 + R/R 校验)\n\n1. 止损 = 强支撑(新买入用弱支撑)\n2. 止盈 = 强压力(多级阻力位尝试满足R/R)\n3. 买入区 = 弱支撑~弱支撑×1.05\n4. R/R ≥ 2.0 校验(新买入推荐)\n5. 4% 最小波幅保护(单日振幅<2%时)\n6. 盈亏比不满足时:弱压→强压 逐级尝试\n", + "content_path": "/home/hmo/projects/MoFin/data/prompts/versions/strategy-generation-v2.1.md", + "author": "知微", + "status": "deprecated", + "tags": [ + "技术面", + "R/R", + "最小波幅" + ] + }, + { + "version": "v2.2", + "label": "止损三级分离 + 移动止损", + "created_at": "2026-06-13T10:00:00", + "changelog": "止损分三级(新买入/已持仓/深套)。新买入用弱支撑,已持仓用强支撑,深套取强撑/85%最低。盈利>5%启用移动止损保护利润", + "content": "策略生成规则 v2.2(止损三级分离 + 移动止损)\n\n| 场景 | 止损位置 | 逻辑 |\n|------|---------|------|\n| 新买入(cost=0) | 弱支撑(weak_support) | 入场失败小亏走人 |\n| 已持仓(profit≥-20%) | 强支撑(strong_support) | 趋势坏了才走 |\n| 深套(profit<-20%) | min(强支撑,...", + "content_path": "/home/hmo/projects/MoFin/data/prompts/versions/strategy-generation-v2.2.md", + "author": "知微", + "status": "deprecated", + "tags": [ + "技术面", + "R/R", + "移动止损", + "三级止损" + ] + }, + { + "version": "v2.3", + "label": "买入区 R/R 约束 + 时机四象限", + "created_at": "2026-06-13T16:00:00", + "changelog": "买入区自身增加R/R约束(entry_high满足1:1.5),新增买入时机四象限模型(放量跌不入/缩量回踩入/放量突破追/缩量反弹不追),趋势位置检测扩展有效区间", + "content": "策略生成规则 v2.3\n\n买入区 R/R 约束:\n- entry_high ≤ (target + min_rr × stop) / (1 + min_rr)\n- 新买入 min_rr=1.5,已持仓 min_rr=1.0\n- 坍缩保护:R/R约束导致买入区消失时标记\"不建议\"\n\n买入时机四象限:\n| 场景 | 操作 |\n|------|------|\n| ①放量跌入买入区 | ❌ 不买 |\n| ...", + "content_path": "/home/hmo/projects/MoFin/data/prompts/versions/strategy-generation-v2.3.md", + "author": "知微", + "status": "deprecated", + "tags": [ + "技术面", + "R/R", + "买入时机", + "趋势位置" + ] + }, + { + "version": "v2.4", + "label": "R/R阈值差异化 + 止损最小距离", + "created_at": "2026-06-13T18:00:00", + "changelog": "R/R阈值差异化(新买入≥1.5/2.0/2.0三级,已持仓≥0.5/1.5两级),止损最小距离3%保护(正常持仓不被短线波动触发),买入区坍缩逻辑优化", + "content": "策略生成规则 v2.4(当前活跃版本)\n\nR/R阈值差异化:\n| 场景 | 阈值 |\n|------|------|\n| 新买入 <1.5 | ❌ 不建议买入 |\n| 新买入 1.5~2.0 | ⚠️ 谨慎买入 |\n| 新买入 ≥2.0 | ✅ 正常 |\n| 已持仓 <0.5 | ⚠️ 盈亏比极低 |\n| 已持仓 0.5~1.5 | ⚠️ 不建议加仓 |\n| 已持仓 ≥1.5 | ✅ 无标记 |\n...", + "content_path": "/home/hmo/projects/MoFin/data/prompts/versions/strategy-generation-v2.4.md", + "author": "知微", + "status": "active", + "tags": [ + "技术面", + "R/R阈值差异化", + "止损最小距离" + ] + } + ], + "created_at": "2026-06-09T08:00:00", + "updated_at": "2026-06-12T22:54:26.119522", + "current_version": "v2.4" + }, + { + "id": "quick-scan", + "name": "快速盯盘", + "description": "交易时段每15分钟跑一次的盘中行情监控报告 prompt", + "category": "scan", + "locations": [ + "cron job: 62a2ba59f7ff", + "finance/price-range-monitor SKILL.md" + ], + "versions": [ + { + "version": "v1", + "label": "初始版本", + "created_at": "2026-06-10T09:00:00", + "changelog": "初始版本,包含三段式报告格式", + "content": "快速盯盘 v1:三段式报告格式,每15分钟运行。包含重点推荐操作/风险关注/其余持仓。", + "content_path": "/home/hmo/projects/MoFin/data/prompts/versions/quick-scan-v1.md", + "author": "知微", + "status": "deprecated", + "tags": [ + "三段式" + ] + }, + { + "version": "v2", + "label": "字数限制+数据纪律", + "created_at": "2026-06-11T10:00:00", + "changelog": "新增≤300字严格限制,要求查新闻原因(>±3%),禁止模糊词", + "content": "快速盯盘 v2:字数≤300字,涨跌>±3%必须查新闻,禁止模糊词(可关注/可考虑/建议观察)。", + "content_path": "/home/hmo/projects/MoFin/data/prompts/versions/quick-scan-v2.md", + "author": "知微", + "status": "deprecated", + "tags": [ + "字数限制", + "数据纪律" + ] + }, + { + "version": "v3", + "label": "买入时机+唯一动词", + "created_at": "2026-06-12T14:00:00", + "changelog": "新增买入时机四象限要求,每只推荐必须带唯一动作动词+数量+价格,仓位必写", + "content": "快速盯盘 v3(当前):三段式≤300字,每只推荐必须带唯一动作动词+数量+价格,仓位%必写,禁止选择题。买入时机四象限:放量跌不入/缩量回踩入/放量突破追/缩量反弹不追。", + "content_path": "/home/hmo/projects/MoFin/data/prompts/versions/quick-scan-v3.md", + "author": "知微", + "status": "active", + "tags": [ + "唯一动词", + "买入时机" + ] + } + ], + "created_at": "2026-06-10T09:00:00", + "updated_at": "2026-06-12T22:54:26.121489", + "current_version": "v3" + }, + { + "id": "evaluation-daily", + "name": "策略评估日报", + "description": "每日21:00自动运行的策略评估+反馈闭环 prompt", + "category": "evaluation", + "locations": [ + "cron job: 9d1236d8a07f", + "finance/strategy-evaluation SKILL.md" + ], + "versions": [ + { + "version": "v1", + "label": "初始版本", + "created_at": "2026-06-09T21:00:00", + "changelog": "初始版本,运行strategy_evaluator.py并输出评估报告", + "content": "策略评估 v1:运行评估脚本 → 读取 evaluation.json → 输出双维度评估报告。", + "content_path": "/home/hmo/projects/MoFin/data/prompts/versions/evaluation-daily-v1.md", + "author": "知微", + "status": "deprecated", + "tags": [ + "双维度评估" + ] + }, + { + "version": "v2", + "label": "反馈闭环+信号识别", + "created_at": "2026-06-11T21:00:00", + "changelog": "新增反馈闭环机制:识别6种策略信号(买入区从未触发/频繁止损/价格远超止盈/理论实际差距/连续正确/连续错误),自动生成调整建议", + "content": "策略评估 v2(当前):\n1. 运行 strategy_evaluator.py\n2. 读 evaluation.json + accuracy_stats.json\n3. 识别6种信号并生成调整建议\n4. 固化经验到 knowledge-log\n5. 无变化输出 SILENT 抑制推送\n", + "content_path": "/home/hmo/projects/MoFin/data/prompts/versions/evaluation-daily-v2.md", + "author": "知微", + "status": "active", + "tags": [ + "反馈闭环", + "信号识别" + ] + } + ], + "created_at": "2026-06-09T21:00:00", + "updated_at": "2026-06-12T22:54:26.123163", + "current_version": "v2" + }, + { + "id": "knowledge-extraction", + "name": "知识萃取", + "description": "每日16:30从当日分析中提炼可复用知识,写入 analyst-knowledge-log.md", + "category": "knowledge", + "locations": [ + "cron job: e27e2e92ed80", + "finance/analyst-knowledge SKILL.md" + ], + "versions": [ + { + "version": "v1", + "label": "初始版本", + "created_at": "2026-06-11T16:30:00", + "changelog": "初始版本,从 decisions.json 和 evaluation.json 中提炼经验写入知识日志", + "content": "知识萃取 v1(当前):\n1. 读 decisions.json 的 changelog 和 evaluation\n2. 读当天分析输出的 anomaly 信号\n3. 提炼 1-3 条可复用知识\n4. 写入 /home/hmo/Obsidian/knowledge/finance/analyst-knowledge-log.md\n5. 长期有效的规律 → memory add\n", + "content_path": "/home/hmo/projects/MoFin/data/prompts/versions/knowledge-extraction-v1.md", + "author": "知微", + "status": "active", + "tags": [ + "知识沉淀" + ] + } + ], + "created_at": "2026-06-11T16:30:00", + "updated_at": "2026-06-12T22:54:26.124308", + "current_version": "v1" + }, + { + "id": "portfolio-review", + "name": "持仓复查", + "description": "每周四20:00进行全面持仓基本面+技术面复查", + "category": "review", + "locations": [ + "cron job: 5dde4e1a42ce", + "finance/price-range-monitor SKILL.md" + ], + "versions": [ + { + "version": "v1", + "label": "初始版本", + "created_at": "2026-06-11T20:00:00", + "changelog": "初始版本,逐个过所有持仓的营收/利润/PE/PB/ROE/技术面/研报/新闻", + "content": "持仓复查 v1(当前):\n1. 所有持仓个股逐个过:营收趋势、利润、利润率、PE/PB/ROE/负债率\n2. 技术面:支撑位、压力位、均线形态\n3. 最新研报目标价\n4. 近期重大新闻/催化剂\n5. 标记异常信号\n", + "content_path": "/home/hmo/projects/MoFin/data/prompts/versions/portfolio-review-v1.md", + "author": "知微", + "status": "active", + "tags": [ + "全面复查" + ] + } + ], + "created_at": "2026-06-11T20:00:00", + "updated_at": "2026-06-12T22:54:26.125447", + "current_version": "v1" + }, + { + "id": "system-health-check", + "name": "系统健康检查", + "description": "每日9:00检查MoFin所有核心组件是否正常运行", + "category": "health", + "locations": [ + "cron job: 37c02f4d7df9", + "cron job: 88d6753bde11", + "/home/hmo/web-dashboard/system_health_check.py" + ], + "versions": [ + { + "version": "v1", + "label": "初始版本", + "created_at": "2026-06-09T09:00:00", + "changelog": "初始版本,检查进程/端口/数据文件", + "content": "健康检查 v1:检查 mofin-dashboard/xmpp-zhiwei/ejabberd 进程,8899/5222/8643端口,数据文件存在性。", + "content_path": "/home/hmo/projects/MoFin/data/prompts/versions/system-health-check-v1.md", + "author": "知微", + "status": "deprecated", + "tags": [ + "进程", + "端口" + ] + }, + { + "version": "v2", + "label": "18项全面检查", + "created_at": "2026-06-10T09:00:00", + "changelog": "扩展为18项检查:进程+端口+数据文件+价格事件+策略评估+建议记录+cron jobs+数据新鲜度", + "content": "健康检查 v2(当前):18项检查\n- 进程:mofin-dashboard, xmpp-zhiwei, ejabberd\n- 端口:8899, 5222, 8643\n- 数据:portfolio/watchlist/decisions/market/price_events/evaluation/accuracy_stats\n- 活动:价格事件数/策略评估数/建议记录数\n- Cron: 两个pr...", + "content_path": "/home/hmo/projects/MoFin/data/prompts/versions/system-health-check-v2.md", + "author": "知微", + "status": "active", + "tags": [ + "18项检查" + ] + } + ], + "created_at": "2026-06-09T09:00:00", + "updated_at": "2026-06-12T22:54:26.127525", + "current_version": "v2" + }, + { + "id": "report-format", + "name": "报告格式规范", + "description": "所有分析报告的三段式输出格式规则,嵌入 price-range-monitor SKILL.md 和 cron 提示词", + "category": "format", + "locations": [ + "finance/price-range-monitor SKILL.md", + "finance/price-range-monitor/references/report-format-final-2026-06-10.md", + "/home/hmo/Obsidian/knowledge/finance/zhiwei-analysis-rules.md" + ], + "versions": [ + { + "version": "v1", + "label": "初始格式", + "created_at": "2026-06-10T14:00:00", + "changelog": "初始三段式格式:重点推荐操作(≤3)/风险关注(≤3)/其余持仓", + "content": "三段式 v1:【重点推荐操作】≤3只 / 【风险关注】≤3只 / 【其余持仓】一行概括", + "content_path": "/home/hmo/projects/MoFin/data/prompts/versions/report-format-v1.md", + "author": "知微", + "status": "deprecated", + "tags": [ + "三段式" + ] + }, + { + "version": "v2", + "label": "字数限制+仓位必写", + "created_at": "2026-06-11T16:00:00", + "changelog": "新增≤800字限制,仓位%必写(现→建议),技术面四个数字必写,禁止模糊词", + "content": "三段式 v2:≤800字,仓位%必写,技术面四数字(强阻/弱阻/强撑/弱撑)必写,禁止模糊词/选择题。", + "content_path": "/home/hmo/projects/MoFin/data/prompts/versions/report-format-v2.md", + "author": "知微", + "status": "deprecated", + "tags": [ + "字数限制", + "仓位" + ] + }, + { + "version": "v3", + "label": "唯一动词+理由可验证", + "created_at": "2026-06-12T16:00:00", + "changelog": "每只推荐必须带唯一动作动词+数量+价格,理由必须可验证(用户原话:我要验证你是不是按我说的逻辑定策略)", + "content": "三段式 v3(当前):\n1. 【重点推荐操作】≤3只,理由不重复\n2. 每只:仓位(现→建议) + 技术面四数字 + 操作(唯一动词+数量+价) + 可验证理由\n3. 【风险关注】≤3只,距止损%+原因\n4. 【其余持仓】一行带过\n5. 全文≤600字\n6. 禁止:可关注/可考虑/建议观察/择机/试试\n7. A股在前港股在后\n", + "content_path": "/home/hmo/projects/MoFin/data/prompts/versions/report-format-v3.md", + "author": "知微", + "status": "active", + "tags": [ + "唯一动词", + "理由可验证" + ] + } + ], + "created_at": "2026-06-10T14:00:00", + "updated_at": "2026-06-12T22:54:26.132639", + "current_version": "v3" + }, + { + "id": "analysis-rules", + "name": "分析规则", + "description": "单股/行业分析流程规则:数据源纪律、A+H股处理、异常处理、买入时机四象限", + "category": "analysis", + "locations": [ + "finance/price-range-monitor SKILL.md", + "/home/hmo/Obsidian/knowledge/finance/analysis-rules.md", + "/home/hmo/Obsidian/knowledge/finance/zhiwei-analysis-rules.md" + ], + "versions": [ + { + "version": "v1", + "label": "初始规则", + "created_at": "2026-06-09T10:00:00", + "changelog": "初始分析规则:腾讯API数据源、港股字段映射、涨跌>3%查新闻", + "content": "分析规则 v1:腾讯API港股字段映射,涨跌>±3%必须查新闻,A+H股价差正常。", + "content_path": "/home/hmo/projects/MoFin/data/prompts/versions/analysis-rules-v1.md", + "author": "知微", + "status": "deprecated", + "tags": [ + "数据源" + ] + }, + { + "version": "v2", + "label": "日期纪律+预检查清单", + "created_at": "2026-06-12T16:00:00", + "changelog": "新增日期铁律(分析前先date)、预分析自查清单(时间戳/交易日/策略数据)、A股优先于港股", + "content": "分析规则 v2(当前):\n1. 日期纪律:分析前先 date 确认日期星期\n2. 数据源:腾讯API主(港股),新浪补充(A股昨收)\n3. 预检查:时间戳→交易日→策略数据\n4. A股优先于港股\n5. 涨跌>±3%查新闻\n6. A+H价差正常\n7. 买入时机四象限\n", + "content_path": "/home/hmo/projects/MoFin/data/prompts/versions/analysis-rules-v2.md", + "author": "知微", + "status": "active", + "tags": [ + "日期纪律", + "预检查" + ] + } + ], + "created_at": "2026-06-09T10:00:00", + "updated_at": "2026-06-12T22:54:26.135054", + "current_version": "v2" + } + ], + "updated_at": "2026-06-12T22:54:26.135269" +} \ No newline at end of file diff --git a/data/prompts/versions/analysis-rules-v1.md b/data/prompts/versions/analysis-rules-v1.md new file mode 100644 index 0000000..306ee0f --- /dev/null +++ b/data/prompts/versions/analysis-rules-v1.md @@ -0,0 +1 @@ +分析规则 v1:腾讯API港股字段映射,涨跌>±3%必须查新闻,A+H股价差正常。 \ No newline at end of file diff --git a/data/prompts/versions/analysis-rules-v2.md b/data/prompts/versions/analysis-rules-v2.md new file mode 100644 index 0000000..7367cb5 --- /dev/null +++ b/data/prompts/versions/analysis-rules-v2.md @@ -0,0 +1,8 @@ +分析规则 v2(当前): +1. 日期纪律:分析前先 date 确认日期星期 +2. 数据源:腾讯API主(港股),新浪补充(A股昨收) +3. 预检查:时间戳→交易日→策略数据 +4. A股优先于港股 +5. 涨跌>±3%查新闻 +6. A+H价差正常 +7. 买入时机四象限 diff --git a/data/prompts/versions/evaluation-daily-v1.md b/data/prompts/versions/evaluation-daily-v1.md new file mode 100644 index 0000000..9fa4d18 --- /dev/null +++ b/data/prompts/versions/evaluation-daily-v1.md @@ -0,0 +1 @@ +策略评估 v1:运行评估脚本 → 读取 evaluation.json → 输出双维度评估报告。 \ No newline at end of file diff --git a/data/prompts/versions/evaluation-daily-v2.md b/data/prompts/versions/evaluation-daily-v2.md new file mode 100644 index 0000000..eb23954 --- /dev/null +++ b/data/prompts/versions/evaluation-daily-v2.md @@ -0,0 +1,6 @@ +策略评估 v2(当前): +1. 运行 strategy_evaluator.py +2. 读 evaluation.json + accuracy_stats.json +3. 识别6种信号并生成调整建议 +4. 固化经验到 knowledge-log +5. 无变化输出 SILENT 抑制推送 diff --git a/data/prompts/versions/knowledge-extraction-v1.md b/data/prompts/versions/knowledge-extraction-v1.md new file mode 100644 index 0000000..7bab3da --- /dev/null +++ b/data/prompts/versions/knowledge-extraction-v1.md @@ -0,0 +1,6 @@ +知识萃取 v1(当前): +1. 读 decisions.json 的 changelog 和 evaluation +2. 读当天分析输出的 anomaly 信号 +3. 提炼 1-3 条可复用知识 +4. 写入 /home/hmo/Obsidian/knowledge/finance/analyst-knowledge-log.md +5. 长期有效的规律 → memory add diff --git a/data/prompts/versions/portfolio-review-v1.md b/data/prompts/versions/portfolio-review-v1.md new file mode 100644 index 0000000..272316e --- /dev/null +++ b/data/prompts/versions/portfolio-review-v1.md @@ -0,0 +1,6 @@ +持仓复查 v1(当前): +1. 所有持仓个股逐个过:营收趋势、利润、利润率、PE/PB/ROE/负债率 +2. 技术面:支撑位、压力位、均线形态 +3. 最新研报目标价 +4. 近期重大新闻/催化剂 +5. 标记异常信号 diff --git a/data/prompts/versions/quick-scan-v1.md b/data/prompts/versions/quick-scan-v1.md new file mode 100644 index 0000000..ec0fc24 --- /dev/null +++ b/data/prompts/versions/quick-scan-v1.md @@ -0,0 +1 @@ +快速盯盘 v1:三段式报告格式,每15分钟运行。包含重点推荐操作/风险关注/其余持仓。 \ No newline at end of file diff --git a/data/prompts/versions/quick-scan-v2.md b/data/prompts/versions/quick-scan-v2.md new file mode 100644 index 0000000..6ed73a7 --- /dev/null +++ b/data/prompts/versions/quick-scan-v2.md @@ -0,0 +1 @@ +快速盯盘 v2:字数≤300字,涨跌>±3%必须查新闻,禁止模糊词(可关注/可考虑/建议观察)。 \ No newline at end of file diff --git a/data/prompts/versions/quick-scan-v3.md b/data/prompts/versions/quick-scan-v3.md new file mode 100644 index 0000000..15e2c74 --- /dev/null +++ b/data/prompts/versions/quick-scan-v3.md @@ -0,0 +1 @@ +快速盯盘 v3(当前):三段式≤300字,每只推荐必须带唯一动作动词+数量+价格,仓位%必写,禁止选择题。买入时机四象限:放量跌不入/缩量回踩入/放量突破追/缩量反弹不追。 \ No newline at end of file diff --git a/data/prompts/versions/report-format-v1.md b/data/prompts/versions/report-format-v1.md new file mode 100644 index 0000000..1445800 --- /dev/null +++ b/data/prompts/versions/report-format-v1.md @@ -0,0 +1 @@ +三段式 v1:【重点推荐操作】≤3只 / 【风险关注】≤3只 / 【其余持仓】一行概括 \ No newline at end of file diff --git a/data/prompts/versions/report-format-v2.md b/data/prompts/versions/report-format-v2.md new file mode 100644 index 0000000..102b05c --- /dev/null +++ b/data/prompts/versions/report-format-v2.md @@ -0,0 +1 @@ +三段式 v2:≤800字,仓位%必写,技术面四数字(强阻/弱阻/强撑/弱撑)必写,禁止模糊词/选择题。 \ No newline at end of file diff --git a/data/prompts/versions/report-format-v3.md b/data/prompts/versions/report-format-v3.md new file mode 100644 index 0000000..ef126f7 --- /dev/null +++ b/data/prompts/versions/report-format-v3.md @@ -0,0 +1,8 @@ +三段式 v3(当前): +1. 【重点推荐操作】≤3只,理由不重复 +2. 每只:仓位(现→建议) + 技术面四数字 + 操作(唯一动词+数量+价) + 可验证理由 +3. 【风险关注】≤3只,距止损%+原因 +4. 【其余持仓】一行带过 +5. 全文≤600字 +6. 禁止:可关注/可考虑/建议观察/择机/试试 +7. A股在前港股在后 diff --git a/data/prompts/versions/strategy-generation-v1.md b/data/prompts/versions/strategy-generation-v1.md new file mode 100644 index 0000000..75a4300 --- /dev/null +++ b/data/prompts/versions/strategy-generation-v1.md @@ -0,0 +1,7 @@ +策略生成规则 v1(初始机械百分比) + +止损 = 成本 × 0.85(-15%) +止盈 = 成本 × 1.20(+20%) +买入区 = 现价 × 0.90 ~ 现价 × 1.05 + +无技术面分析,纯百分比计算。 diff --git a/data/prompts/versions/strategy-generation-v2.1.md b/data/prompts/versions/strategy-generation-v2.1.md new file mode 100644 index 0000000..020b186 --- /dev/null +++ b/data/prompts/versions/strategy-generation-v2.1.md @@ -0,0 +1,8 @@ +策略生成规则 v2.1(技术面 + R/R 校验) + +1. 止损 = 强支撑(新买入用弱支撑) +2. 止盈 = 强压力(多级阻力位尝试满足R/R) +3. 买入区 = 弱支撑~弱支撑×1.05 +4. R/R ≥ 2.0 校验(新买入推荐) +5. 4% 最小波幅保护(单日振幅<2%时) +6. 盈亏比不满足时:弱压→强压 逐级尝试 diff --git a/data/prompts/versions/strategy-generation-v2.2.md b/data/prompts/versions/strategy-generation-v2.2.md new file mode 100644 index 0000000..fb646c7 --- /dev/null +++ b/data/prompts/versions/strategy-generation-v2.2.md @@ -0,0 +1,12 @@ +策略生成规则 v2.2(止损三级分离 + 移动止损) + +| 场景 | 止损位置 | 逻辑 | +|------|---------|------| +| 新买入(cost=0) | 弱支撑(weak_support) | 入场失败小亏走人 | +| 已持仓(profit≥-20%) | 强支撑(strong_support) | 趋势坏了才走 | +| 深套(profit<-20%) | min(强支撑, 价×0.85) | 不轻易割 | + +盈利>5%:取 max(弱支撑, 成本线, 现价×0.95) 移动止损 + +买入区 R/R 约束:新买入≥1.5,已持仓≥1.0 +买入区宽度收紧:只围绕弱支撑,不扩展到弱压力 diff --git a/data/prompts/versions/strategy-generation-v2.3.md b/data/prompts/versions/strategy-generation-v2.3.md new file mode 100644 index 0000000..d2878cb --- /dev/null +++ b/data/prompts/versions/strategy-generation-v2.3.md @@ -0,0 +1,16 @@ +策略生成规则 v2.3 + +买入区 R/R 约束: +- entry_high ≤ (target + min_rr × stop) / (1 + min_rr) +- 新买入 min_rr=1.5,已持仓 min_rr=1.0 +- 坍缩保护:R/R约束导致买入区消失时标记"不建议" + +买入时机四象限: +| 场景 | 操作 | +|------|------| +| ①放量跌入买入区 | ❌ 不买 | +| ②缩量回踩弱支撑+放量反弹 | ✅ 买入 | +| ③放量突破压力位 | ✅ 追买 | +| ④缩量反弹到压力位 | ❌ 警惕 | + +趋势位置检测:股价>80%分位或<20%分位时自动扩展有效区间到价×8% diff --git a/data/prompts/versions/strategy-generation-v2.4.md b/data/prompts/versions/strategy-generation-v2.4.md new file mode 100644 index 0000000..6a9088d --- /dev/null +++ b/data/prompts/versions/strategy-generation-v2.4.md @@ -0,0 +1,16 @@ +策略生成规则 v2.4(当前活跃版本) + +R/R阈值差异化: +| 场景 | 阈值 | +|------|------| +| 新买入 <1.5 | ❌ 不建议买入 | +| 新买入 1.5~2.0 | ⚠️ 谨慎买入 | +| 新买入 ≥2.0 | ✅ 正常 | +| 已持仓 <0.5 | ⚠️ 盈亏比极低 | +| 已持仓 0.5~1.5 | ⚠️ 不建议加仓 | +| 已持仓 ≥1.5 | ✅ 无标记 | + +止损最小距离:现价到止损≥3%(非深套场景) +买入区坍缩:R/R约束导致entry_high 最后更新:2026-06-09 +> 维护人:莫荷(Hermes Agent) +> 铁律:任何系统改动必须先读本文档,改完必须同步更新 + +--- + +## 一、核心理念 + +``` +每份分析都成为下一次分析的养料 +每次建议都成为下一次建议的参考 +从内到外(知识→分析),从外到内(分析→沉淀)的持续闭环 +``` + +## 二、股票操作策略生命周期 + +``` + ┌─────────────────────────────┐ + │ 大环境+行业趋势研判 │ + │ (盘前热点扫描·每天8:30) │ + │ 板块扫描→发现新标的 │ + └──────────┬──────────────────┘ + │ 写入 watchlist.json + ▼ + ┌─────────────────────────────┐ + │ 自选股池 (watchlist.json) │ + │ │ + │ 🟢 可操作 — 价格在买入区内 │ + │ 🟡 关注 — 距触发3~5% │ + │ ⚪ 观察 — 距触发>5% │ + │ │ + │ 监控: │ + │ • 快速盯盘(每15分钟) │ + │ • 持仓情报-盘后(每天) │ + │ • 自选股体检(每周六) │ + └──────────┬──────────────────┘ + 进入买入区 │ 建仓 + ▼ + ┌─────────────────────────────┐ + │ 持仓股 (portfolio.json) │ + │ │ + │ 监控: │ + │ • 价格监控(每分钟·纯脚本) │ + │ • 快速盯盘(每15分钟·LLM) │ + │ • 持仓情报-盘中(每小时·LLM) │ + │ • 持仓情报-盘后(每天·LLM) │ + │ • 分析师-持仓复查(每周·LLM) │ + └──────────┬──────────────────┘ + 触发止损/止盈/清仓 │ + ▼ + ┌─────────────────────────────┐ + │ 回到自选股池 │ + │ (保留策略,等待下次机会) │ + └─────────────────────────────┘ +``` + +## 三、系统架构总览 + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ MoFin 专家系统 v2.0 │ +├──────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────┐ ┌──────────────────────────────┐ │ +│ │ 基础设施层(纯脚本) │ │ 分析层(LLM驱动) │ │ +│ │ 0配额消耗 │ │ 每次运行消耗1次配额 │ │ +│ │ │ │ │ │ +│ │ • 价格监控(1分钟) │ │ • 盘前热点扫描(8:30) │ │ +│ │ 腾讯批量API拉所有股票 │ │ → 持仓分析+板块扫描 │ │ +│ │ 写入portfolio/watchlist│ │ → 新机会发现→写入watchlist│ │ +│ │ 比对区间触发→输出 │ │ │ │ +│ │ │ │ • 集合竞价观察(9:28) │ │ +│ │ • 市场数据采集(30分钟) │ │ → A股竞价异常监控 │ │ +│ │ 板块数据→market.json │ │ │ │ +│ │ │ │ • 快速盯盘(每15分钟) │ │ +│ │ • 数据同步(8:55) │ │ → 持仓操作窗口+自选接近提醒 │ │ +│ │ update_data+server检查│ │ │ │ +│ │ │ │ • 持仓情报-盘中(每小时40分) │ │ +│ │ • XMPP中继(每分钟) │ │ → 详细盘中分析 │ │ +│ │ 扫输出目录→推知微 │ │ │ │ +│ │ │ │ • 持仓情报-盘后(20:00) │ │ +│ └─────────┬───────────────┘ │ → 完整复盘+自选回顾 │ │ +│ │ │ │ │ +│ ▼ │ • 分析师-持仓复查(周四20:00) │ │ +│ ┌─────────────────────────┐ │ → 基本面深度复查 │ │ +│ │ 数据层 │ │ │ │ +│ │ portfolio.json(持仓) │ │ • 自选股体检(周六20:00) │ │ +│ │ watchlist.json(自选) │ │ → 每只自选评估:保留/移除 │ │ +│ │ decisions.json(决策库) │ │ │ │ +│ │ market.json(板块数据) │ │ • 知微周复盘(周日22:00) │ │ +│ │ daily_reviews.json │ │ → 周度总结 │ │ +│ │ stock_profiles.json │ └──────────┬───────────────────┘ │ +│ └─────────┬───────────────┘ │ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ 推送层 │ │ +│ │ cron_to_xmpp.py(每分钟·纯脚本) │ │ +│ │ 扫描 ~/.hermes/cron/output/*/ 目录 │ │ +│ │ 发现新 .md 文件 → 提取正文 → XMPP推送【知微】 │ │ +│ │ 已推送的记在 .relay_journal.json,不重复推 │ │ +│ └──────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────┘ +``` + +## 四、完整 Cron Job 清单(14个) + +### 🟢 基础设施层(纯脚本,0配额消耗) + +| # | 名称 | 频率 | 脚本 | 输出 | 说明 | +|---|------|------|------|------|------| +| 1 | **价格监控-1分钟** | 工作日 每分钟(9-11/12/13-16) | `price_monitor.py` | 有触发→推送;无→SILENT | 腾讯批量API拉所有股票实时价,写入portfolio/watchlist,比对区间触发条件 | +| 2 | **市场数据采集** | 工作日 每30分钟 | `market_watch.py` | 成功→SILENT;失败→报错 | 东方财富API采集板块数据→market.json | +| 3 | **数据同步-dashboard** | 每天 8:55 | `sync_dashboard.py` | 有数据→推送;无→SILENT | 跑update_data.py+检查server,挂了自动重启 | +| 4 | **cron-推XMPP中继** | 工作日 每分钟(9-16) | `cron_to_xmpp.py` | 有新报告→推送;无→静默 | 扫所有job输出目录,推新报告到XMPP | +| 5 | **中继-16点收盘** | 工作日 16:00-16:10 | `cron_to_xmpp.py` | 同上 | 收盘时段推港股报告 | + +### 🔵 分析层(LLM驱动,每次1次配额) + +| # | 名称 | 频率 | 关键职责 | 价格获取方式 | +|---|------|------|---------|------------| +| 6 | **盘前热点扫描** | 工作日 8:30 | 持仓分析+**板块扫描→新机会发现→写入watchlist** | 读文件(price_monitor刷新) | +| 7 | **集合竞价观察** | 工作日 9:28 | A股竞价异常监控,无异常SILENT | A股curl竞价价+港股读文件 | +| 8 | **快速盯盘-15分钟** | 工作日 每15分钟 | 持仓操作窗口+**自选股接近买入区提醒**,无则SILENT | 读文件 | +| 9 | **持仓情报-盘中** | 工作日 每小时40分 | 详细盘中分析+异动搜因 | 读文件 | +| 10 | **持仓情报-盘后** | 工作日 20:00 | 完整复盘+**自选股回顾**+数据沉淀daily_reviews | 读文件 | +| 11 | **分析师-持仓复查** | 周四 20:00 | 每周基本面深度复查 | 读文件 | +| 12 | **自选股体检-每周** | **周六 20:00 🆕** | 每只自选评估:已建仓→移除、>30天未接近→建议移除、行业变化→更新理由 | 读文件+web_search | +| 13 | **知微周复盘** | 周日 22:00 | 周度总结 | 读文件 | +| 14 | **策略评估-每日** | 工作日 21:00 | 双维度评估36条策略,生成反馈闭环 | 读文件+LLM | 无变化→SILENT | +| 15 | **建议对账-每周** | 周六 20:00 | 对比建议vs实际持仓变化,算准确率 | 纯脚本 | 有数据→推送 | +| 16 | **策略评估-每周** | 周六 21:00 | 完整评分+趋势分析+策略参数调整 | 纯脚本 | 有数据→推送 | + +## 五、数据文件 + +| 文件 | 路径 | 作用 | 更新频率 | 更新者 | +|------|------|------|---------|--------| +| 持仓数据 | `data/portfolio.json` | 持仓快照+实时价 | 每分钟 | price_monitor | +| 自选数据 | `data/watchlist.json` | 自选股列表+买入区+实时价 | 每分钟 | price_monitor+盘前扫描+自选体检 | +| 决策库 | `data/decisions.json` | 策略+trigger+建议历史+评估数据 | 每次讨论后+每日评估 | LLM job+策略评估 | +| 每日复盘 | `data/daily_reviews.json` | 当日判断+修正沉淀 | 每日20:00 | 持仓情报-盘后 | +| 股票档案 | `data/stock_profiles.json` | 行业/业务/逻辑 | 发现新信息时 | LLM | +| 市场数据 | `data/market.json` | 板块涨跌+概念热度 | 每30分钟 | market_watch | +| 价格事件 | `data/price_events.json` | 价格触发记录(止盈/止损/买入区) | 每分钟 | price_monitor | +| 评估结果 | `data/evaluation.json` | 双维度评估+评分 | 每天21:00 | 策略评估 | +| 准确率统计 | `data/accuracy_stats.json` | 建议对账+准确率 | 每周六20:00 | 对账脚本 | +| 反馈闭环 | `data/strategy_feedback.json` | 反馈+调整建议+知识萃取 | 每天21:00 | 策略评估 | +| 区间偏离 | `~/.hermes/zone_breach.json` | 价格偏离标记 | 每分钟 | price_monitor | +| 触发状态 | `~/.hermes/price_trigger_state.json` | 每只股票各区间的进出状态 | 每分钟 | price_monitor | + +## 六、数据流转 + +``` +price_monitor(每分钟) + │ 腾讯批量API 1次请求 + │ + ├──→ portfolio.json (持仓实时价) + ├──→ watchlist.json (自选实时价) + ├──→ 比对decisions.json的trigger区间 + ├──→ 有触发 → 输出 → 中继 → 知微 + └──→ 无触发 → [SILENT] → 丢弃 + +market_watch(每30分钟) + │ 东方财富API + └──→ market.json (板块数据) + +盘前热点扫描(每天8:30) + │ 读 market.json → 分析强势板块 + │ 发现新标的 → 写入 watchlist.json + └──→ 输出报告 → 中继 → 知微 + +快速盯盘(每15分钟) + │ 读 portfolio.json + watchlist.json + │ 持仓操作窗口 + 自选接近买入区 + └──→ 有发现 → 输出 → 中继 → 知微 + +持仓情报-盘后(每天20:00) + │ 读 portfolio.json + watchlist.json + │ 持仓回顾 + 自选回顾 + └──→ 写入 daily_reviews.json + └──→ 输出报告 → 中继 → 知微 + +自选股体检(每周六20:00) + │ 读 watchlist.json + portfolio.json + │ 每只评估:保留/移除/更新 + └──→ 输出报告 → 中继 → 知微 +``` + +## 七、报告推送策略 + +| 报告 | 推送 | 频率 | 静默规则 | +|------|------|------|---------| +| 价格监控 | ✅ 有触发才推 | 每分钟 | 无触发→[SILENT] | +| 盘前扫描 | ✅ | 每日1次 | 有发现才写 | +| 集合竞价 | ✅ 有异常才推 | 每日1次 | 无异常→SILENT | +| 快速盯盘 | ✅ 有发现才推 | 每15分钟 | 无操作窗口+无自选接近→SILENT | +| 持仓情报-盘中 | ✅ 有发现才推 | 每小时40分 | 无操作窗口→合并后停止 | +| 持仓情报-盘后 | ✅ | 每日20:00 | 有实质内容才写 | +| 分析师复查 | ✅ | 每周四 | 有发现才写 | +| 自选股体检 | ✅ | 每周六 | 有建议才写 | +| 知微周复盘 | ✅ | 每周日 | 有实质内容才写 | +| 市场数据采集 | ❌ 不推 | 每30分钟 | 只写文件 | +| 数据同步 | ✅ 有数据才推 | 每天8:55 | 无更新→SILENT | + +## 八、关键设计决策 + +| 方面 | 决策 | 原因 | +|------|------|------| +| 价格获取 | 腾讯批量API `qt.gtimg.cn/q=sh600110,sz000001,...` | 1次请求拉所有股票,无频率限制 | +| 价格监控类型 | 纯脚本(`no_agent=True`) | 不需要LLM,0配额消耗 | +| 中继类型 | 纯脚本(`no_agent=True`) | 文件扫描+XMPP推送,不需要LLM | +| 市场数据采集 | 纯脚本(`no_agent=True`) | 只写文件不输出,不需要LLM | +| 数据同步 | 纯脚本(`sync_dashboard.py`) | 包装脚本含server检查,不需要LLM | +| LLM job价格来源 | 读文件(price_monitor每1分钟刷新) | 不再逐个curl,节省配额 | +| 新机会发现 | 合并到盘前热点扫描(每天) | 每天分析板块数据,发现新标的写入watchlist | +| 自选股进出管理 | 自选股体检(每周六) | 系统评估+提请确认,不自动删除 | +| 午休 | 价格监控跳过12:05-12:55(保留12:00) | A股午休无行情 | + +## 九、配额消耗估算(一个上午) + +| 组件 | 运行次数 | 配额消耗 | +|------|---------|---------| +| 价格监控 | ~240次(每分钟) | **0** (纯脚本) | +| 市场数据采集 | ~10次 | **0** (纯脚本) | +| 数据同步 | 1次 | **0** (纯脚本) | +| XMPP中继 | ~240次 | **0** (纯脚本) | +| 盘前热点扫描 | 1次 | **1次** | +| 集合竞价 | 1次 | **1次** | +| 快速盯盘 | ~20次 | **~20次** (有操作窗口才跑,无则SILENT不消耗) | +| 持仓情报-盘中 | ~6次 | **~6次** | +| 策略评估-每日 | 1次 | **1次** | +| **总计** | | **~29次 LLM调用** | + +--- + +## 十、策略评估反馈闭环架构 + +### 核心流程 + +``` +price_monitor(每分钟·纯脚本) + │ 腾讯批量API拉所有股票实时价 + │ 比对策略区间 → 触发 → 记录到 price_events.json + │ + ├──→ 价格触发事件持久化(price_events.json) + │ event_type: entry_zone / stop_loss / take_profit + │ code, name, price, trigger_value, timestamp + │ + ▼ +strategy_evaluator(每天21:00·纯脚本) + │ 读 decisions.json + price_events.json + portfolio.json + │ 双维度评估每只股票: + │ 阶段一(策略制定→价格达标): + │ - 理论:价格是否达到过止盈/止损/买入区 + │ - 实际:持仓盈亏、建议执行情况 + │ 阶段二(价格回落→新止损验证): + │ - 新止损是否被后续走势验证 + │ 写入 decisions.json 的 evaluation 字段 + │ + ├──→ 写入 evaluation.json + │ + ▼ +strategy_feedback(每天21:00·纯脚本,评估后自动) + │ 读 evaluation.json + │ 自动决策: + │ - 价格达到止盈 → 标记阶段一成功,萃取经验 + │ - 跌破止损 → 标记阶段一失败,生成新区间 + │ - 14天无触发 → 标记 stale,建议重评 + │ - 准确率持续下降 → 收紧区间宽度 + │ 写入 strategy_feedback.json + │ + ├──→ 写入 strategy_feedback.json + │ + ▼ +每日评估 cron(每天21:00·LLM驱动) + │ 读 strategy_feedback.json + │ 处理反馈 → 更新策略 → 输出日报 + │ 无变化 → [SILENT] + │ + ▼ +盘后知识萃取(每天16:30·LLM驱动) + │ 读 feedback 中的 knowledge 字段 + │ 写入 analyst-knowledge-log.md + │ + ▼ +建议对账(每周六20:00·纯脚本) + │ 对比 advice_timeline vs portfolio.json + │ 算准确率 → 写入 accuracy_stats.json + │ + ▼ +策略评估-每周(每周六21:00·纯脚本) + │ 完整评分 → 趋势分析 → 策略参数调整 + │ 写入 evaluation.json + strategy_feedback.json +``` + +### 双维度评估模型 + +| 维度 | 阶段一 | 阶段二 | +|------|--------|--------| +| 理论(策略规划) | 价格是否达到止盈/止损/买入区?何时?理论盈亏? | 新止损是否被后续走势验证? | +| 实际(用户执行) | 实际持仓盈亏?建议是否被采纳? | 用户是否按新止损操作?实际损失? | + +### 反馈引擎自动决策规则 + +| 检测条件 | 自动动作 | +|----------|----------| +| 价格达到止盈位 | 标记阶段一成功,萃取经验到知识日志,创建新自选策略 | +| 跌破止损位 | 标记阶段一失败,分析原因,生成新止损/买入区间 | +| 策略14天未触发任何区间 | 标记为 stale,建议重新评估基本面和技术面 | +| 准确率持续下降(<50%) | 收紧策略区间宽度(10%→8%) | +| 准确率持续上升(>80%) | 放宽策略区间宽度(10%→12%) | + +--- + +## 十、v2.0 更新说明(2026-06-09) + +本次更新基于知微与莫荷、老爸的深入讨论,对系统做了全面重构。以下记录讨论过程中的关键意图、决策和待办事项。 + +### 本次改动驱动因素 + +1. **火山引擎(volcengine) 429配额超限** → 触发对 provider 配置和 fallback 链的全面梳理 +2. **发现价格监控逐个curl拉取价格** → 效率极低且浪费配额,触发了对价格获取方式的全面改造 +3. **知微反馈"问了不在回答长篇报告"** → 触发了对 session 管理、system prompt、对话识别机制的修复 +4. **老爸指出持仓和自选股之间的动态生命周期关系** → 触发了对股票操作策略完整生命周期的重新设计 + +### 已完成的改造 + +**基础设施层:** +- 价格监控从 LLM驱动(每5分钟) 改为 纯脚本(每1分钟),使用腾讯批量API一次拉所有股票 +- XMPP中继从 LLM驱动 改为 纯脚本(每1分钟) +- 市场数据采集从 LLM驱动 改为 纯脚本 +- 数据同步从 LLM驱动 改为 纯脚本(含server检查包装脚本) +- 所有LLM job的价格获取从"逐个curl拉取"改为"直接读文件(price_monitor每1分钟刷新)" + +**股票生命周期完善:** +- 新机会发现:合并到盘前热点扫描(每天8:30),板块扫描→发现新标的→写入watchlist +- 自选接近提醒:快速盯盘(每15分钟)新增自选接近买入区(<5%)单独提醒 +- 自选回顾:持仓情报-盘后(每天20:00)新增自选股回顾板块 +- 自选体检:新增每周六20:00的自动评估+建议移除/保留 + +**provider配置统一:** +- 莫荷(默认profile):默认ocg-old(旧key) → fallback ocg-new(新key) → volcengine(火山) +- 知微(position-analyst):默认volcengine(火山) → fallback ocg-old(旧key) → ocg-new(新key) +- 火山已恢复可用 + +**对话机制修复:** +- 知微SOUL新增对话上下文识别规则(打招呼vs分析请求vs模糊提问) +- 知微SOUL新增系统自动追加消息(bg-review)说明 +- position-analyst启用压缩(protect_last_n=200, hygiene_hard_message_limit=400) + +**策略评估反馈闭环(v2.0 深夜追加):** +- 价格事件持久化:price_monitor每分钟记录价格触发事件到price_events.json +- 双维度评估:strategy_evaluator每天评估36条策略,分阶段一(策略制定→价格达标)和阶段二(价格回落→新止损验证),每个阶段同时记录理论盈亏和实际盈亏 +- 反馈引擎:strategy_feedback自动检测止盈/止损/stale策略,生成调整建议,萃取知识到知识日志 +- Dashboard评估tab:展示评分、理论vs实际盈亏对比、反馈闭环卡片 +- 建议对账:advice_reconciliation.py每周对比建议vs实际持仓变化算准确率 +- 36条策略全部填充建议记录,平均分4.8/10 + +### 设计原则/意图 + +1. **知微是分析师,不是决策者** — 她出建议,老爸决定是否执行,是否执行的唯一真相来源是截图/持仓更新 +2. **莫荷是参谋,不是执行者** — 莫荷出报告和建议,不做交易操作 +3. **建议≠事实** — 不能假设建议=被执行,需要等持仓更新来验证 +4. **持仓/自选动态循环** — 持仓清仓→自动转自选保留策略,自选建仓→自动转为持仓管理 +5. **自选股进出需人工确认** — 系统可以建议移除,但不能自动删除,需老爸确认 +6. **轻对话,重分析** — 不是每条消息都需要全面分析报告,区分打招呼和干活 + +### 尚未完成/待定事项 + +1. **建议自动记录+对账闭环** — 莫荷发出的建议自动记入decisions.json的advice_timeline,每周对比实际持仓变化,统计准确率(✅ 已完成:/api/advice/record + advice_reconciliation.py + accuracy_stats.json) +2. **MoFin Dashboard整合** — server.py应整合所有数据源,包括待确认决策、准确率统计等(✅ 已完成:决策库tab + 评估tab + 反馈闭环tab) +3. **知微XMPP通信通道优化** — 当前xmpp_zhiwei_bot.py断线重连机制有缺陷(Event().wait阻塞),需修复(✅ 已完成:watchdog循环每10秒检查连接状态) +4. **session跨profile检索** — 知微无法搜到莫荷CLI session的内容,需手动转发(✅ 已完成:session_search profile参数可用) +5. **策略双维度评估体系** — 理论策略vs实际执行,分阶段跟踪,自动反馈闭环(✅ 已完成:strategy_evaluator.py + strategy_feedback.py + 每日/每周cron) +6. **价格事件持久化** — price_monitor每分钟记录价格触发事件供回溯评估(✅ 已完成:price_events.json + record_event()) diff --git a/docs/PROMPT_MANAGER.md b/docs/PROMPT_MANAGER.md new file mode 100644 index 0000000..72f08c3 --- /dev/null +++ b/docs/PROMPT_MANAGER.md @@ -0,0 +1,110 @@ +# MoFin 提示词管理系统 + +## 概述 + +知微(持仓分析师)用到的所有提示词(prompts)的统一管理模块。提供: + +1. **集中注册表** — 所有提示词分类管理,可查阅历史版本 +2. **版本管理** — 每次修改记录版本号/变更日志/完整内容 +3. **策略关联** — 每只股票的策略关联生成它的提示词版本 +4. **统计分析** — 按提示词版本聚合策略评估结果,找出最有效的版本 + +## 模块架构 + +``` +prompt_manager/ +├── __init__.py # 模块入口,导出主要函数 +├── models.py # 数据模型(PromptDef, PromptVersion, StrategyLink) +├── registry.py # 注册表CRUD + 版本管理 +├── tracking.py # 策略→提示词版本关联记录 +├── analytics.py # 按版本聚合分析引擎 +├── dashboard_views.py # Dashboard API路由 + 前端HTML +└── init_registry.py # 初始化脚本(录入所有现有提示词版本) +``` + +## 数据文件 + +``` +data/prompts/ +├── registry.json # 注册表(所有提示词元数据+版本记录) +├── versions/ # 每个版本的完整内容文件 +│ ├── strategy-generation-v1.md +│ ├── strategy-generation-v2.md +│ └── ... +└── associations.json # 策略→提示词版本关联记录 +``` + +## 提示词分类 + +| 分类 | 说明 | 当前数量 | +|------|------|---------| +| strategy | 策略生成规则 | 6个版本 | +| scan | 快速盯盘 | 3个版本 | +| evaluation | 策略评估 | 2个版本 | +| knowledge | 知识萃取 | 1个版本 | +| review | 持仓复查 | 1个版本 | +| health | 系统健康检查 | 2个版本 | +| format | 报告格式规范 | 3个版本 | +| analysis | 分析规则 | 2个版本 | + +## 使用方式 + +### 查看提示词 + +通过 MoFin Dashboard (http://192.168.1.246:8899) →「提示词」Tab 查看。 + +### 添加新版本 + +```python +from prompt_manager.registry import add_version +from prompt_manager.models import PromptVersion + +add_version("strategy-generation", PromptVersion( + version="v2.5", + label="新特性描述", + created_at="2026-06-15T10:00:00", + changelog="变更说明", + content="完整提示词内容...", + status="active", + tags=["标签1", "标签2"], +)) +``` + +### 切换活跃版本 + +```python +from prompt_manager.registry import set_active_version +set_active_version("strategy-generation", "v2.5") +``` + +### 查看版本有效性 + +```python +from prompt_manager.analytics import generate_report +print(generate_report()) +``` + +或通过 Dashboard → 提示词 →「📊 版本有效性报告」按钮。 + +### 策略关联 + +策略生成时自动记录(`strategy_lifecycle.py` 的 `regenerate_all()` 已集成): + +```python +from prompt_manager.tracking import record_strategy_generation +record_strategy_generation("000700", "模塑科技", "盈利持有 | 损13.97 | 盈15.27") +``` + +## 版本有效性分析逻辑 + +1. `strategy_lifecycle.py` 生成策略时 → `record_strategy_generation()` 记录关联 +2. `strategy_evaluator.py` 评估策略时 → 写入 decisions.json 的 evaluation 字段 +3. `analytics.py` 读取两者 → 按 `prompt_id@version` 聚合 +4. 聚合指标:策略总数、达到止盈数、跌破止损数、成功率和平均盈亏比 + +## 演化历史 + +- **2026-06-09**: v1 机械百分比(止损-15%/止盈+20%) +- **2026-06-11**: v2 技术面支撑压力位 +- **2026-06-12**: v2.1 R/R校验+最小波幅;v2.2 止损三级分离+移动止损 +- **2026-06-13**: v2.3 买入区R/R约束+时机四象限;v2.4 阈值差异化+止损最小距离 diff --git a/docs/SYSTEM_ARCHITECTURE.md b/docs/SYSTEM_ARCHITECTURE.md new file mode 100644 index 0000000..de0095b --- /dev/null +++ b/docs/SYSTEM_ARCHITECTURE.md @@ -0,0 +1,221 @@ +# 莫荷系统架构文档 — 完整总览 + +> 最后更新:2026-06-11 +> 维护人:莫荷(Hermes Agent) +> 铁律:任何系统改动必须先读本文档,改完必须同步更新 + +--- + +## 一、系统总览 + +``` +┌──────────────────────────────────────────────────────────────┐ +│ Linux 192.168.1.246 │ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ 默认gateway │ │ 知微gateway │ │ 小果gateway │ │ +│ │ :8642 │ │ :8643 │ │ :8645 │ │ +│ │ 微信+XMPP │ │ position- │ │ xiaoguo │ │ +│ │ mohe网关 │ │ analyst │ │ profile │ │ +│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ state.db (SQLite) │ │ +│ │ sessions / messages / FTS5 / compression_locks │ │ +│ │ 消息存储:全量保存,永不删除 │ │ +│ │ 上下文加载:最多200条,永不压缩 │ │ +│ └─────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ xmpp_bot │ │ xmpp_ │ │ xmpp_ │ │ +│ │ mohe │ │ zhiwei_bot │ │ xiaoguo_bot │ │ +│ │ mohe@yoin │ │ zhiwei@yoin │ │ xiaoguo@ │ │ +│ │ .fun │ │ .fun │ │ yoin.fun │ │ +│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ +│ │ │ │ │ +│ └──────┬─────────┴────────┬───────┘ │ +│ ▼ ▼ │ +│ ┌──────────────┐ ┌──────────────┐ │ +│ │ ejabberd │ │ 内核组 │ │ +│ │ Docker │ │ coregroup@ │ │ +│ │ port 5222 │ │ conference │ │ +│ └──────────────┘ │ .yoin.fun │ │ +│ └──────────────┘ │ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ 价格监控 │ │ cron调度器 │ │ Obsidian │ │ +│ │ 1分钟·纯脚本 │ │ 14个jobs │ │ 知识库 │ │ +│ └─────────────┘ └─────────────┘ │ :8890 │ │ +│ └─────────────┘ │ +└──────────────────────────────────────────────────────────────┘ + │ │ + ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ +│ Windows 192.168 │ │ Mac 192.168.1 │ +│ .1.16 │ │ .122 │ +│ 小小莫(wechat) │ │ 小果(oMLX) │ +│ OpenCode :4096 │ │ Qwen3.6-27B │ +│ 微信通道 :5801 │ │ :18003 │ +└─────────────────┘ └─────────────────┘ +``` + +## 二、Gateway 一览 + +| 端口 | 名称 | Profile | PID(当前) | 用途 | +|------|------|---------|-----------|------| +| 8642 | 默认gateway | 默认 | 1925504 | 微信小荷 + XMPP mohe | +| 8643 | 知微gateway | position-analyst | 1913506 | 知微分析 | +| 8645 | 小果gateway | xiaoguo | 1925602 | 小果Mac端 | +| 8646 | mohe gateway | mohe | 1620276 | mohe独立网关 | + +每个gateway共用 `/home/hmo/hermes-agent/hermes_state.py` 里的 `get_messages_as_conversation()` — **LIMIT 200硬截断**。 + +## 三、XMPP Bot 架构 + +### 3.1 Bot 列表 + +| Bot | JID | 服务名 | 脚本路径 | 接入gateway | +|-----|-----|--------|---------|------------| +| 莫荷 | mohe@yoin.fun | xmpp-bot | /home/hmo/xmpp_bot.py | :8642 | +| 知微 | zhiwei@yoin.fun | xmpp-zhiwei | /home/hmo/xmpp_zhiwei_bot.py | :8643 | +| 小果 | xiaoguo@yoin.fun | xmpp-xiaoguo | /home/hmo/xmpp_xiaoguo_bot.py | :8645 | + +### 3.2 连接管理(2026-06-11 修复) + +**禁用** `auto_reconnect = True`(与手动重连环冲突,导致"Replaced by new connection"循环) +**禁用** `xep_0199` ping 保活(ejabberd不支持,导致ping超时→误判断线) + +**重连机制**: +- 主循环每15秒检查 `is_connected()` +- 断线后指数退避重连:1s → 2s → 4s → ... → 60s max +- 重连后自动重新加入 MUC(内核组 coregroup@conference.yoin.fun) + +**历史问题**: +- 2026-06-08: bot断线后无法自动重连,session膨胀到3700条/26M tokens +- 2026-06-10: auto_reconnect导致10个重复连接 +- 2026-06-11: 修复auto_reconnect冲突 + API key拼写错误 + +### 3.3 群聊规则 + +Bot只回复内核组中来自 `hmo` 或 `xxm` 的消息。私聊只回复 `hmo@yoin.fun`。 + +## 四、Session 管理 —— 核心设计(2026-06-10 最终方案) + +### 4.1 方案:硬截断200条 + 永不压缩 + +```python +# hermes_state.py → get_messages_as_conversation() +SELECT id, role, content, ... +FROM ( + SELECT id, role, content, ... + FROM messages WHERE session_id = ? + AND active = 1 + ORDER BY id DESC LIMIT 200 ← 只取最近200条 +) ORDER BY id ASC ← 按正序排回 +``` + +### 4.2 Compression 配置(所有profile统一) + +```yaml +compression: + enabled: false ← 永久关闭 + threshold: 0.99 + protect_last_n: 200 + hygiene_hard_message_limit: 100000 +``` + +### 4.3 效果 + +| 指标 | 之前 | 之后 | +|------|------|------| +| 每次请求token | 26M(全量加载) | ~22K(200条) | +| 上下文窗口用量 | 2500% | 2.2% | +| 响应时间 | 10分钟+超时 | 10-20秒 | +| 内容丢失 | 压缩丢细节 | 永不丢失 | +| 旧消息可查 | 压缩后摘要 | 全量DB可搜 | + +### 4.4 Session 列表(当前) + +| Session ID | 消息数 | 用途 | +|-----------|--------|------| +| sisyphus | 9504 | 微信(旧session,已重建) | +| xmpp-mohe | 3705 | XMPP mohe(旧session) | +| xmpp-mohe-v2 | ~200 | XMPP mohe(新session,LIMIT 200) | +| xmpp-zhiwei | 2241 | 知微 | +| 20260610_090241_2235fb | ~900 | 当前CLI会话 | + +## 五、Provider 链(2026-06-10 最终版) + +| Agent | 默认 | Fallback 1 | Fallback 2 | Fallback 3 | +|-------|------|-----------|-----------|-----------| +| **我(CLI)** | ocg-new | ocg-old | volcengine | - | +| **mohe gateway** | ocg-new | ocg-old | volcengine | - | +| **知微** | ocg-old | ocg-new | volcengine(cred池) | - | +| **小果** | volcengine | ocg-old | ocg-new | oMLX(本地Mac) | + +**当前实际状态(2026-06-11):** +- ocg-new: ✅ 可用(当前会话走这个) +- ocg-old: ⚠️ 返回403但gateway cred pool缓存了有效key +- volcengine: ❌ 周配额已尽,6月15日周一恢复 + +## 六、SOUL.md 关键规则(2026-06-10 最终版) + +位置:`/home/hmo/.hermes/profiles/default/SOUL.md` + +### 沟通方式 +- 对老爸:直接、不加修饰 +- 反驳时:**必须带证据**(日志、数据、代码、截图)。不是为了显得聪明而反驳 +- 听指令:用户明确说"闭嘴""停"时立即停止,不继续分析不解释 + +### 行动铁律 — 讲证据 +1. 发现问题 → 2. 收集证据(至少两条独立证据) → 3. 验证假设 → 4. 只改对的 → 5. 改完验证 +- 禁止猜根因、没有证据就动手、猜用户意图、多个改动同时做 + +### 授权边界 +- ✅ 直接行动:读文件、查日志、搜知识库、分析数据、提建议 +- ⚠️ 问清楚再做:改系统配置、重启服务、清数据、写文件 +- ❌ 必须等批准:不可逆删除、修改API key、改provider链、清session + +## 七、知识库(Obsidian) + +路径:`/home/hmo/Obsidian/` +HTTP API:`:8890`(只读) + +结构: +``` +Obsidian/ +├── raw/ — 原始资料(只追加只读) +├── knowledge/ — 加工笔记(tech/finance/ai/psychology/education/life) +├── index.md — 全库索引 +├── SCHEMA.md — 操作规则 +└── log.md — 更新日志 +``` + +## 八、MoFin 股票系统 + +详见 `EXPERT_SYSTEM_DESIGN.md`,核心: +- 14个cron jobs(5个纯脚本+9个LLM) +- 价格监控每1分钟腾讯批量API +- XMPP中继推送报告 + +## 九、近期改动日志 + +### 2026-06-11 +- LIMIT 200硬截断 + 关闭所有compression +- SOUL.md 最终版定稿 +- XMPP bot重连逻辑修复(删除auto_reconnect + ping保活) +- API key typo修复(知微bot `hermess123` → `hermes123`) +- 小果provider链:volc → ocg-old → ocg-new → oMLX +- 默认provider链:ocg-new → ocg-old → volcengine + +### 2026-06-10 +- 重建SOUL.md(讲证据+授权边界+责任闭环) +- 发现并清除orphaned compression flag +- 多个gateway反复重启,systemd服务冲突 +- Windows wechat_agent API key不匹配 + +### 2026-06-09 +- 知微SOUL新增对话识别规则 +- position-analyst 启用压缩 +- 价格监控全面改造(纯脚本+腾讯批量API) diff --git a/scripts/cron_to_xmpp.py b/scripts/cron_to_xmpp.py new file mode 100644 index 0000000..f1d7c4c --- /dev/null +++ b/scripts/cron_to_xmpp.py @@ -0,0 +1,354 @@ +#!/usr/bin/env python3 +"""cron_to_xmpp.py — 智能cron报告推送 + +只推送LLM驱动的分析报告(有实质内容),不推送纯脚本输出。 +关键规则: +1. 跳过 no_agent 脚本的输出(价格监控、数据同步等机器数据) +2. 跳过自己的输出目录(30908cdc44a8),避免循环推送 +3. 正文太短(<20字)或只有 [SILENT] 的不推 +4. 超时自动跳过,不影响后续 +""" +import json +import subprocess +import re +import sys +from datetime import datetime +from pathlib import Path + +# 使用绝对路径,不受 profile 环境变量影响 +REAL_HOME = Path("/home/hmo") + +# 扫描目录 +CRON_DIRS = [ + REAL_HOME / ".hermes" / "cron" / "output", + REAL_HOME / ".hermes" / "profiles" / "position-analyst" / "cron" / "output", +] +JOURNAL = REAL_HOME / ".hermes" / "cron" / ".relay_journal.json" +SILENT_STATS = REAL_HOME / ".hermes" / "cron" / ".silent_daily_count.json" +MAX_AGE_HOURS = 6 # 只推送6小时内的报告,防止清journal后爆历史 + + +def load_no_agent_job_ids(): + """从两个profile的jobs.json中读取所有no_agent=true的job ID""" + ids = set() + for jobs_path in [ + REAL_HOME / ".hermes" / "cron" / "jobs.json", + REAL_HOME / ".hermes" / "profiles" / "position-analyst" / "cron" / "jobs.json", + ]: + try: + with open(jobs_path) as f: + data = json.load(f) + for j in data.get("jobs", []): + if j.get("no_agent"): + ids.add(j["id"]) + except: + pass + return ids + + +# 硬编码保底(如果 jobs.json 读不到) +SKIP_DIRS = { + "30908cdc44a8", # cron-推XMPP中继自身输出 + "health", # 健康检查输出 +} + +FROM = "zhiwei@yoin.fun" +TO = "hmo@yoin.fun" + + +def load_journal(): + try: + return set(json.loads(JOURNAL.read_text())) + except: + return set() + + +def save_journal(entries): + JOURNAL.write_text(json.dumps(sorted(entries))) + + +def is_pure_script_output(content): + """判断文件是否是纯脚本的机器输出(不是LLM报告)""" + # LLM报告的特征:有 ## Response 节(包含agent的回复) + if "## Response" in content: + return False + # 以 # Cron Job: 开头但没有 ## Response 的可能是脚本输出 + if content.startswith("# Cron Job:"): + return True + # 价格监控的触发输出 + if content.startswith("🔔") and "⏱" in content: + return True + # 健康检查报告 + if "MoFin 系统健康检查" in content: + return True + # [SILENT] 标记一概不拦 — 用户想看到报告结构,不想被静默 + # 移除 [SILENT] 过滤,让报告始终送达 + # 结构化数据标签(价格监控的机器数据) + if "" in content: + return True + # no_agent 脚本的输出特征(Hermes自动添加的header) + if "**Mode:** no_agent (script)" in content: + return True + return False + + +def validate_report_body(body): + """质量检查 — 不拦截,返回改进建议""" + issues = [] + text = body.strip() + + if "重点推荐操作" not in text: + issues.append("缺少【重点推荐操作】区域(如无需操作可写「无」)") + + if "风险关注" not in text: + issues.append("缺少【风险关注】区域(如无风险可写「无」)") + + if len(text) > 600: + issues.append(f"报告偏长({len(text)}字),建议压缩到600字以内") + + fuzzy = re.findall(r"可关注|可考虑|建议观察|试试|谨慎关注|择机|根据情况", text) + if fuzzy: + issues.append(f"含模糊词: {', '.join(set(fuzzy))},建议替换为明确操作指令") + + if re.search(r"如果.*就.*如果.*就|若.*则.*若.*则", text): + issues.append("含选择题句式,建议只给一个确定建议") + + return issues + + +def send_feedback(issues, job_name): + """发送质量反馈给知微自己""" + from xml.sax.saxutils import escape + feedback = f"[自我反馈] 报告质量检查发现以下问题,下次注意:\n" + "\n".join(f"• {i}" for i in issues) + safe = escape(feedback) + stanza = ( + f"" + f"{safe}" + ) + try: + subprocess.run( + ["docker", "exec", "ejabberd", "ejabberdctl", + "send_stanza", FROM, FROM, stanza], + capture_output=True, timeout=10, text=True, + ) + except: + pass + + +def extract_body(path): + content = path.read_text(encoding="utf-8", errors="replace") + + if is_pure_script_output(content): + return None + + parts = content.split("## Response") + body = parts[1].strip() if len(parts) > 1 else content.strip() + body = re.sub(r'^#.*?\n', '', body, flags=re.MULTILINE).strip() + body = re.sub(r'\n?\s*.*?\s*', '', body, flags=re.DOTALL).strip() + body = re.sub(r'\*\*(.*?)\*\*', r'\1', body) + + # 去掉agent的思考过程("Now let me...", "Let me...", "Now I have..."等开头) + body = re.sub(r'^(Now let me|Let me|I need|I will|First let me|First,? I|Now I have|Here.i|I.ll|I.m ).*?\n\n', '', body, flags=re.DOTALL).strip() + # 去掉末尾的思考尾巴 + body = re.sub(r'\n\s*(Now I|This |I have |I used |The report|The data).*?$', '', body, flags=re.DOTALL).strip() + # 如果只剩"好的"、"收到"等短回应,丢弃 + if re.match(r'^[\u4e00-\u9fff,。]{1,10}$', body): + return None + + if not body or len(body) < 20: + return None + + # 再次检查正文中是否包含 [SILENT] + if "[SILENT]" in body: + return None + + return body + + +def send(body): + from xml.sax.saxutils import escape + safe = escape(f"【知微】{body}") + stanza = ( + f"" + f"{safe}" + ) + # 重试3次 + for attempt in range(3): + try: + r = subprocess.run( + ["docker", "exec", "ejabberd", "ejabberdctl", + "send_stanza", FROM, TO, stanza], + capture_output=True, timeout=10, text=True, + ) + if r.stderr and "error" in r.stderr.lower(): + print(f"send error (attempt {attempt+1}): {r.stderr.strip()[:100]}", file=sys.stderr) + if attempt < 2: + continue + return False + return r.returncode == 0 + except subprocess.TimeoutExpired: + print(f"send timeout (attempt {attempt+1})", file=sys.stderr) + if attempt < 2: + continue + return False + except Exception as e: + print(f"send err (attempt {attempt+1}): {e}", file=sys.stderr) + if attempt < 2: + continue + return False + return False + + +def validate_format(body): + """格式检查 — 只记录不拦截,标记改进点""" + text = body.strip() + issues = [] + + # 必含区域检查 + has_key = "重点推荐操作" in text + has_risk = "风险关注" in text + has_rest = "其余持仓" in text or "今日关注" in text + if not has_key: + issues.append("缺【重点推荐操作】区域") + if not has_risk: + issues.append("缺【风险关注】区域") + + # 超长提醒 + if len(text) > 600: + issues.append(f"报告偏长({len(text)}字),建议压缩到600字内") + + # 模糊词提醒 + fuzzy = re.findall(r"可关注|可考虑|建议观察|试试|谨慎关注|择机|根据情况", text) + if fuzzy: + issues.append(f"含模糊词({', '.join(list(set(fuzzy))[:3])}),应给唯一结论") + + # 选择题句式提醒 + if re.search(r"如果.*就|若.*则|可以.*也可以", text): + issues.append("含选择题句式,应给唯一建议") + + return text, issues # 始终通过,issues 为空就是干净 + + +def load_silent_stats(): + """加载当日静默统计""" + try: + return json.loads(SILENT_STATS.read_text()) + except: + return {"date": "", "silent": 0, "short": 0, "script": 0} + + +def save_silent_stats(stats): + SILENT_STATS.write_text(json.dumps(stats)) + + +def send_silent_summary(stats): + """发送当日静默报告汇总""" + parts = [] + if stats.get("silent", 0) > 0: + parts.append(f"静默[SILENT] {stats['silent']}次") + if stats.get("short", 0) > 0: + parts.append(f"过短(<20字) {stats['short']}次") + if stats.get("script", 0) > 0: + parts.append(f"脚本输出 {stats['script']}次") + + if not parts: + body = "【每日汇总】今日所有cron报告已正常送达,无被拦截的报告。" + else: + body = "【每日汇总】今日以下cron报告未送达(已拦截):\n" + "\n".join(f"• {p}" for p in parts) + "\n\n无操作信号的报告正常静默,有操作信号的都已送达。" + + send(body) + + +def scan(): + processed = load_journal() + new = set() + n_pushed = 0 + n_silent = 0 + n_short = 0 + n_script = 0 + no_agent_ids = load_no_agent_job_ids() + skip_all = SKIP_DIRS | no_agent_ids + + for cron_dir in CRON_DIRS: + if not cron_dir.exists(): + continue + + for d in sorted(cron_dir.iterdir()): + if not d.is_dir(): + continue + if d.name in skip_all: + continue + + for f in sorted(d.iterdir()): + if f.suffix != ".md": + continue + key = str(f.resolve()) + if key in processed or key in new: + continue + new.add(key) + + # 跳过超过MAX_AGE_HOURS小时的旧文件 + age_hours = (datetime.now() - datetime.fromtimestamp(f.stat().st_mtime)).total_seconds() / 3600 + if age_hours > MAX_AGE_HOURS: + continue + + content = f.read_text(encoding="utf-8", errors="replace") + + # 提前判断脚本输出 + if is_pure_script_output(content): + n_script += 1 + continue + + parts = content.split("## Response") + body = parts[1].strip() if len(parts) > 1 else content.strip() + body = re.sub(r'^#.*?\n', '', body, flags=re.MULTILINE).strip() + body = re.sub(r'\n?\s*.*?\s*', '', body, flags=re.DOTALL).strip() + body = re.sub(r'\*\*(.*?)\*\*', r'\1', body) + + if not body or len(body) < 20: + n_short += 1 + continue + + # SILENT → 拦截,记数 + if "[SILENT]" in body: + n_silent += 1 + continue + + # 格式校验 — 记录改进点,不拦截 + ok_body, issues = validate_format(body) + + n_pushed += 1 + ok_sent = send(body) + if not ok_sent: + print(f" {d.name}: send failed", file=sys.stderr) + if issues: + print(f" {d.name}/{f.name}: 改进建议: {'; '.join(issues)}", file=sys.stderr) + + if new: + save_journal(processed | new) + + # 保存当日汇总到文件(供16:30汇总用) + today = datetime.now().strftime("%Y-%m-%d") + stats = load_silent_stats() + if stats.get("date") != today: + stats = {"date": today, "silent": 0, "short": 0, "script": 0} + stats["silent"] += n_silent + stats["short"] += n_short + stats["script"] += n_script + save_silent_stats(stats) + + # 16:30~16:35 发送当日汇总(收盘后) + now = datetime.now() + hhmm = now.hour * 60 + now.minute + if 990 <= hhmm <= 995: # 16:30~16:35 + send_silent_summary(stats) + + log = f"推送{n_pushed}份,静默拦截{n_silent}份,过短{n_short}份,跳过脚本{n_script}份" + print(log, file=sys.stderr) + return n_pushed + + +if __name__ == "__main__": + scan() diff --git a/scripts/market_watch.py b/scripts/market_watch.py new file mode 100644 index 0000000..f00328c --- /dev/null +++ b/scripts/market_watch.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""market_watch.py — 行業熱點 + 市場洞察數據採集,寫入 dashboard data/market.json""" + +import json +import urllib.request +from datetime import datetime +from pathlib import Path + +DATA_DIR = Path(__file__).parent / "data" + +# ── 後端A:東方財富 push2 API(首選) ── + +def _fetch_em(url): + """通用 EM API 請求""" + req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}) + resp = urllib.request.urlopen(req, timeout=10) + return json.loads(resp.read().decode("utf-8")) + +def fetch_sector_em(): + """東方財富行業板塊""" + try: + data = _fetch_em("https://push2.eastmoney.com/api/qt/clist/get?pn=1&pz=15&po=1&np=1&fields=f2,f3,f4,f12,f14&fs=m:90+t:2") + return [{"name": i["f14"], "code": i["f12"], "price": i.get("f2", 0), "change": i.get("f3", 0)} + for i in data.get("data", {}).get("diff", [])] + except Exception as e: + return None + +def fetch_concept_em(): + """東方財富概念板塊""" + try: + data = _fetch_em("https://push2.eastmoney.com/api/qt/clist/get?pn=1&pz=10&po=1&np=1&fields=f2,f3,f4,f12,f14&fs=m:90+t:3") + return [{"name": i["f14"], "code": i["f12"], "change": i.get("f3", 0)} + for i in data.get("data", {}).get("diff", [])] + except Exception as e: + return None + +# ── 後端B:同花順 THS(降級) ── + +def fetch_sector_ths(): + """THS 行業板塊(含漲跌家數、資金流向)""" + try: + import akshare as ak + df = ak.stock_board_industry_summary_ths() + return [{ + "name": r["板块"], "code": "", "price": 0, + "change": float(r.get("涨跌幅", 0)), + "up_count": int(r.get("上涨家数", 0)), + "down_count": int(r.get("下跌家数", 0)), + "net_inflow": float(r.get("净流入", 0)), + } for _, r in df.iterrows()] + except Exception as e: + print(f"⚠️ THS行業失敗: {e}") + return [] + +def fetch_concept_ths(): + """THS 概念板塊(僅名稱,無實時漲跌)""" + try: + import akshare as ak + df = ak.stock_board_concept_name_ths() + return [{"name": r["name"], "code": str(r["code"]), "change": 0} + for _, r in df.head(15).iterrows()] + except Exception as e: + print(f"⚠️ THS概念失敗: {e}") + return [] + +# ── 主流程 ── + +def get_market_mood(sectors): + if not sectors: + return "unknown" + ratio = sum(1 for s in sectors if s.get("change", 0) > 0) / len(sectors) + return "bullish" if ratio > 0.7 else "neutral" if ratio > 0.4 else "bearish" + +def main(): + sectors = fetch_sector_em() + if sectors is None: + sectors = fetch_sector_ths() + + concepts = fetch_concept_em() + if concepts is None: + concepts = fetch_concept_ths() + + sorted_sectors = sorted(sectors, key=lambda s: s.get("change", 0), reverse=True) + top_gainers = [s for s in sorted_sectors if s.get("change", 0) > 0][:5] + top_losers = [s for s in reversed(sorted_sectors) if s.get("change", 0) < 0][:3] + + market_data = { + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"), + "total_sectors": len(sectors), + "up_ratio": round(sum(1 for s in sectors if s.get("change", 0) > 0) / max(len(sectors), 1) * 100, 1), + "mood": get_market_mood(sectors), + "top_gainers": top_gainers, + "top_losers": top_losers, + "sectors": sectors, + "concepts": concepts, + } + + DATA_DIR.mkdir(parents=True, exist_ok=True) + with open(DATA_DIR / "market.json", "w", encoding="utf-8") as f: + json.dump(market_data, f, ensure_ascii=False, indent=2) + + # 安静:数据只写文件,不打印到stdout,避免cron输出被推送 + +if __name__ == "__main__": + main() diff --git a/scripts/price_monitor.py b/scripts/price_monitor.py new file mode 100644 index 0000000..8f8181c --- /dev/null +++ b/scripts/price_monitor.py @@ -0,0 +1,342 @@ +#!/usr/bin/env python3 +"""price_monitor.py — 高频价格监控脚本(批量版) +规则:进入区间报一次,离开区间报一次,中间不重复。 +每次运行时一次性刷新所有持仓+自选股的实时价。 +""" +import json +import urllib.request +import os +import sys +import time +from datetime import datetime + +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" + +UA = "Mozilla/5.0" + +# ── 批量拉取价格 ────────────────────────────────────────────────────────── + +def fetch_all_prices(codes): + """腾讯批量行情API:一次请求拉取所有股票(A股+港股) + A股:sh600110 / sz000001 + 港股:hk00700 + 返回 {code: (price, change)} + """ + if not codes: + return {} + + # 构建批量查询串 + symbols = [] + code_map = {} # symbol -> original_code + for code in codes: + code_s = str(code).strip() + if len(code_s) == 6: + # A股:沪市以5/6/9开头,深市以0/3开头 + if code_s.startswith(('5', '6', '9')): + sym = f"sh{code_s}" + else: + sym = f"sz{code_s}" + else: + sym = f"hk{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"⚠️ 批量拉取失败: {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: + # 格式: v_sh600110="1~诺德股份~600110~11.84~11.90~..." + 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 + results[orig_code] = (price, change) + except (ValueError, IndexError): + continue + + return results + + +def refresh_data_prices(): + """一次性刷新portfolio.json和watchlist.json的所有实时价""" + all_codes = set() + + # 收集所有需要拉取的代码 + try: + pf = json.load(open(PORTFOLIO_PATH)) + for s in pf.get('holdings', []): + all_codes.add(s['code']) + except: + pf = {"holdings": []} + + try: + wl = json.load(open(WATCHLIST_PATH)) + for s in wl.get('stocks', []): + all_codes.add(s['code']) + except: + wl = {"stocks": []} + + if not all_codes: + return 0 + + # 一次性批量拉取 + prices = fetch_all_prices(list(all_codes)) + updated = 0 + + # 更新portfolio(只在价格变化时写入,避免触发文件变更通知) + changed = False + for s in pf.get('holdings', []): + if s['code'] in prices: + price, _ = prices[s['code']] + if price > 0: + old = s.get('price', 0) + if abs(old - price) > 0.001: + s['price'] = round(price, 2) + updated += 1 + changed = True + if changed: + json.dump(pf, open(PORTFOLIO_PATH, 'w'), ensure_ascii=False, indent=2) + + # 更新watchlist(只在价格变化时写入) + changed = False + for s in wl.get('stocks', []): + if s['code'] in prices: + price, _ = prices[s['code']] + if price > 0: + old = s.get('price', 0) + if abs(old - price) > 0.001: + s['price'] = round(price, 2) + updated += 1 + changed = True + if changed: + wl['updated_at'] = datetime.now().isoformat() + json.dump(wl, open(WATCHLIST_PATH, 'w'), ensure_ascii=False, indent=2) + + return updated + + +# ── 区间偏离检测 ────────────────────────────────────────────────────────── + +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""" + 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) + + +def get_trigger_zones(trigger): + """返回该trigger所有可监控的区间列表,跳过已执行的batch""" + zones = [] + for key, label in [ + ("entry_zone", "加仓区间"), + ("batch1_price", "试仓区间"), + ("batch2_price", "加仓区间"), + ("take_profit_zone", "止盈区间"), + ("watch_low", "关注区间"), + ("watch_high", "减仓区间"), + ("watch_break", "止损区间") + ]: + status_key = key.replace("_price", "_status") + if status_key in trigger and trigger[status_key] == "executed": + continue + val = trigger.get(key, "") + if val and "~" in val: + try: + parts = val.split("~") + lo, hi = float(parts[0]), float(parts[1]) + zones.append((key, label, lo, hi)) + except: + pass + sl = trigger.get("stop_loss", "") + if sl: + try: + sl_price = float(sl) if isinstance(sl, (int, float)) else float(sl) + zones.append(("stop_loss", "止损", 0, sl_price)) + except: + pass + return zones + + +def run_once(round_label=""): + """执行一轮完整的监控流程""" + label = f" [{round_label}]" if round_label else "" + start = time.time() + + # === 第一步:一次性刷新所有价格 === + refreshed = refresh_data_prices() + + # === 第二步:检查触发条件 === + try: + with open(DECISIONS_PATH) as f: + dec = json.load(f) + except: + print(f"❌{label} 无法读取decisions.json", file=sys.stderr) + return + + active = [d for d in dec.get("decisions", []) if d.get("status") == "active"] + state = load_state() + outputs = [] + state_updated = False + + # 收集所有需要检查的代码 + check_codes = set() + for d in active: + trig = d.get("trigger", {}) + if trig: + check_codes.add(d["code"]) + + # 批量拉取这些股票的价格 + prices = fetch_all_prices(list(check_codes)) + + for d in active: + code = d["code"] + trig = d.get("trigger", {}) + if not trig: + continue + + zones = get_trigger_zones(trig) + 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": + outputs.append(f"⚠️ {name}({code}) {price} → 跌破止损{hi}!") + record_event(code, name, "stop_loss", price, str(hi)) + else: + extra = "" + if "_price" in key: + batch_shares = trig.get(key.replace("_price", "_shares"), "") + action = trig.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 = trig.get("take_profit_action", "") + if act: + extra = f"({act})" + outputs.append(f"⚡ {name}({code}) {price} → 进入{label}{lo}~{hi}{extra}") + 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 + + # === 第三步:输出 === + now_str = datetime.now().strftime("%H:%M:%S") + elapsed = time.time() - start + + if outputs: + print(f"\n🔔 {now_str}{label}") + for o in outputs: + print(o) + print(f"\n{json.dumps({'type':'价格监控','time':now_str,'triggers':outputs}, ensure_ascii=False)}") + else: + # 无触发时 SILENT(中继不推送) + print(f"[SILENT]{label} 价格正常 | {refreshed}只已刷新 | {elapsed:.1f}s") + + if state_updated: + save_state(state) + + # 输出耗时 + print(f"⏱{label} {elapsed:.1f}s", flush=True) + + +def main(): + """每cron触发跑一轮""" + run_once() + + +if __name__ == "__main__": + main() diff --git a/scripts/sync_dashboard.py b/scripts/sync_dashboard.py new file mode 100644 index 0000000..263554a --- /dev/null +++ b/scripts/sync_dashboard.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""sync_dashboard.py — 数据同步包装脚本 +1. 运行 update_data.py +2. 检查 server 是否正常 +3. server 挂了就重启 +""" +import subprocess +import sys +import os + +DASHBOARD_DIR = "/home/hmo/web-dashboard" +SERVER_PORT = 8899 + +def run_update(): + """运行 update_data.py,返回 stdout""" + try: + r = subprocess.run( + ["python3", "update_data.py"], + cwd=DASHBOARD_DIR, + capture_output=True, text=True, timeout=60, + ) + return r.stdout.strip() if r.returncode == 0 else None + except Exception as e: + print(f"❌ update_data.py 执行失败: {e}", file=sys.stderr) + return None + +def check_server(): + """检查 server 是否正常""" + import urllib.request + try: + req = urllib.request.Request(f"http://localhost:{SERVER_PORT}", + headers={"User-Agent": "Mozilla/5.0"}) + with urllib.request.urlopen(req, timeout=5) as r: + return r.status == 200 + except: + return False + +def restart_server(): + """重启 server""" + try: + subprocess.run( + ["python3", "server.py", str(SERVER_PORT)], + cwd=DASHBOARD_DIR, + capture_output=True, timeout=10, + ) + return True + except Exception as e: + print(f"❌ server 重启失败: {e}", file=sys.stderr) + return False + +def main(): + output = run_update() + server_ok = check_server() + + if output: + # update_data.py 有输出 → 转发 + print(output) + if not server_ok: + print("⚠️ 注意:server 似乎未响应,但数据已更新") + else: + if server_ok: + # 无输出 + server 正常 → SILENT + print("[SILENT] 数据无更新,server运行正常") + else: + # server 挂了 → 重启 + print("⚠️ server 无响应,尝试重启...") + if restart_server(): + print("✅ server 已重启") + # 再检查一次 + if check_server(): + print("✅ server 恢复运行") + else: + print("❌ server 仍未响应,需人工检查") + else: + print("❌ server 重启失败") + +if __name__ == "__main__": + main() diff --git a/scripts/system_health_check.py b/scripts/system_health_check.py new file mode 100644 index 0000000..5f33999 --- /dev/null +++ b/scripts/system_health_check.py @@ -0,0 +1,206 @@ +#!/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/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("【价格事件】") + 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.json 无事件记录,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("【数据新鲜度】") + for name, path in [("portfolio.json", PORTFOLIO_PATH), ("decisions.json", DECISIONS_PATH)]: + if path.exists(): + mtime = datetime.fromtimestamp(path.stat().st_mtime) + hours_ago = (now - mtime).total_seconds() / 3600 + fresh = hours_ago < 24 + lines.append(check(fresh, f"{name} 更新于 {hours_ago:.0f}小时前")) + if not fresh: + issues.append(f"{name} 超过24小时未更新") + 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() diff --git a/scripts/update_data.py b/scripts/update_data.py new file mode 100644 index 0000000..a0014d7 --- /dev/null +++ b/scripts/update_data.py @@ -0,0 +1,317 @@ +#!/usr/bin/env python3 +"""update_data.py — 解析cron输出,更新dashboard数据层""" + +import json +import os +import re +from datetime import datetime +from pathlib import Path + +DATA_DIR = Path(__file__).parent / "data" + + +def _save(name, data): + path = DATA_DIR / name + os.makedirs(path.parent, exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + +def parse_report(markdown_text, source_file=None): + """解析cron输出的markdown报告,提取结构化数据""" + report = { + "title": "", + "type": "未知", + "created_at": datetime.now().isoformat(), + "summary": "", + "content": markdown_text, + "stocks_mentioned": [], + "structured": None, # 结构化数据优先 + } + + lines = markdown_text.split("\n") + + # 提取标题 + for line in lines: + m = re.match(r"^#\s+(.+)", line) + if m: + report["title"] = m.group(1).strip() + break + m = re.match(r"^📊\s+(.+)", line) + if m: + report["title"] = m.group(1).strip() + break + + # 判断类型 + if "盘中" in report["title"]: + report["type"] = "盘中" + elif "盘后" in report["title"] or "复盘" in report["title"]: + report["type"] = "盘后" + elif "盯盘" in report["title"]: + report["type"] = "盯盘" + elif "扫描" in report["title"]: + report["type"] = "盘前" + + # ★ 优先提取结构化JSON(如果知微输出了的话) + struct_match = re.search(r'\s*(\{.*?\})\s*', markdown_text, re.DOTALL) + if struct_match: + try: + parsed = json.loads(struct_match.group(1)) + report["structured"] = parsed + # 从结构化数据中直接取stock codes + codes = set() + for h in parsed.get("holdings", []): + c = h.get("code", "") + if c: + codes.add(c) + report["stocks_mentioned"] = sorted(codes) + except (json.JSONDecodeError, Exception) as e: + pass # JSON解析失败→走NLP兜底 + + # 摘要(前3非空行) + body_lines = [l.strip() for l in lines if l.strip() and not l.strip().startswith("#") and not l.strip().startswith("##")] + report["summary"] = "\n".join(body_lines[:5])[:200] + + # NLP兜底(仅当结构化数据没取到code时) + if not report["stocks_mentioned"]: + codes = set(re.findall(r'\b\d{6}\b', markdown_text)) + hk_codes = set(re.findall(r'\b\d{5}\b', markdown_text)) + report["stocks_mentioned"] = sorted(codes | hk_codes) + + return report + + +def import_cron_outputs(): + """从cron输出目录导入最新报告""" + cron_dir = Path.home() / ".hermes" / "cron" / "output" + reports_dir = DATA_DIR / "reports" + os.makedirs(reports_dir, exist_ok=True) + + count = 0 + if not cron_dir.exists(): + return count + + for job_dir in sorted(cron_dir.iterdir()): + if not job_dir.is_dir(): + continue + for f in sorted(job_dir.iterdir(), reverse=True)[:5]: # 每个job最近5个 + if f.suffix != ".md": + continue + # Skip if already imported + export_name = f"cron_{job_dir.name}_{f.stem}.json" + if (reports_dir / export_name).exists(): + continue + + content = f.read_text(encoding="utf-8", errors="replace") + report = parse_report(content, source_file=str(f)) + + # Extract response section + resp_match = re.search(r"## Response\n+(.*)", content, re.DOTALL) + if resp_match: + resp = resp_match.group(1).strip() + if resp == "[SILENT]": + continue # Skip SILENT reports + + report["_id"] = export_name.replace(".json", "") + _save(f"reports/{export_name}", report) + count += 1 + + return count + + +def extract_stock_mentions(): + """从报告中提取个股操作建议""" + reports_dir = DATA_DIR / "reports" + stocks_dir = DATA_DIR / "stocks" + os.makedirs(stocks_dir, exist_ok=True) + + stock_data = {} + + for f in sorted(reports_dir.iterdir()): + if f.suffix != ".json": + continue + try: + report = json.loads(f.read_text(encoding="utf-8")) + except: + continue + + content = report.get("content", "") + codes = report.get("stocks_mentioned", []) + + for code in codes: + if code not in stock_data: + stock_data[code] = {"code": code, "history": []} + + # Try to extract recommendation from content + # Look for patterns like "建议|止盈|止损|补仓|持有" + pattern = re.compile( + rf'.*?({code}).*?(建议|止盈|止损|补仓|持有|减仓|加仓|卖出|买入).*?(?:\n|$)', + re.IGNORECASE, + ) + for m in pattern.finditer(content): + stock_data[code]["history"].append({ + "time": report.get("created_at", ""), + "content": m.group(0).strip()[:100], + "report_id": report.get("_id", ""), + }) + + for code, data in stock_data.items(): + _save(f"stocks/{code}.json", data) + + return len(stock_data) + + +def sync_to_decisions(): + """将个股建议同步到决策库(advice_timeline),自动去重""" + decisions_path = DATA_DIR / "decisions.json" + if not decisions_path.exists(): + return 0 + + decisions = json.loads(decisions_path.read_text(encoding="utf-8")) + stocks_dir = DATA_DIR / "stocks" + synced = 0 + + for f in sorted(stocks_dir.iterdir()): + if f.suffix != ".json": + continue + try: + stock = json.loads(f.read_text(encoding="utf-8")) + except: + continue + + code = stock.get("code", "") + history = stock.get("history", []) + if not code or not history: + continue + + # 找决策库中是否有此股 + existing = None + for d in decisions["decisions"]: + if d["code"] == code: + existing = d + break + + if not existing: + # 无决策记录→生成inactive记录 + existing = { + "code": code, + "name": stock.get("name", ""), + "timestamp": datetime.now().isoformat(), + "type": "历史建议汇总", + "current": "自动从update_data同步", + "status": "inactive", + "updated_by": "system(update_data)", + "advice_timeline": [] + } + decisions["decisions"].append(existing) + + # 去重合并 + timeline = existing.setdefault("advice_timeline", []) + existing_keys = {(e["date"], e["direction"], e["summary"]) for e in timeline + if "date" in e and "direction" in e and "summary" in e} + + new_count = 0 + for entry in history: + content = entry.get("content", "") + # 判断方向 + direction = "其他" + if any(w in content for w in ["买入", "加仓", "入场", "🟢", "可加", "可入"]): + direction = "买入" + elif any(w in content for w in ["卖出", "止盈", "减仓", "止损", "清仓", "🔴", "锁定利润"]): + direction = "卖出" + elif any(w in content for w in ["持有", "观望", "👀", "🤝", "暂持", "继续持有"]): + direction = "持有" + + if direction == "其他": + continue + + # 提取日期 + rid = entry.get("report_id", "") + m = re.search(r'(\d{4}-\d{2}-\d{2})', rid) + date = m.group(1) if m else "unknown" + + key = (date, direction, content.strip()[:80]) + if key not in existing_keys: + existing_keys.add(key) + timeline.append({ + "date": date, + "direction": direction, + "summary": content.strip()[:120], + "report_id": rid + }) + new_count += 1 + + if new_count > 0: + # 按日期排序 + timeline.sort(key=lambda e: e.get("date", "")) + synced += new_count + + decisions_path.write_text( + json.dumps(decisions, ensure_ascii=False, indent=2), encoding="utf-8" + ) + return synced + + +def build_portfolio_from_obsidian(): + """读取Obsidian持仓数据,生成portfolio.json""" + import subprocess + # Attempt to read from Obsidian + obsidian_path = Path.home() / "Obsidian" / "knowledge" / "finance" + portfolio_file = obsidian_path / "dad-portfolio.md" + + holdings = [] + total_assets = 0 + stock_value = 0 + cash = 0 + + if portfolio_file.exists(): + content = portfolio_file.read_text(encoding="utf-8", errors="replace") + lines = content.split("\n") + + for line in lines: + m = re.match(r'\|.*?\|.*?(\d+)@(\d+\.?\d*)@.*?\|(\d+\.?\d*)%?\|', line) + if m: + # Parse holding lines from markdown table + parts = [p.strip() for p in line.split("|")] + if len(parts) >= 8: + name = parts[1] if len(parts) > 1 else "" + code = parts[2] if len(parts) > 2 else "" + if code: + holdings.append({ + "code": code, + "name": name, + "position_pct": 0, + "cost": 0, + "shares": 0, + "price": 0, + "change_pct": 0, + }) + + return { + "holdings": holdings, + "total_assets": total_assets, + "stock_value": stock_value, + "cash": cash, + "position_pct": 0, + "total_pnl": 0, + "updated_at": datetime.now().isoformat(), + } + + +if __name__ == "__main__": + count = import_cron_outputs() + if count > 0: + print(f"📥 新增报告: {count}篇") + + stocks = extract_stock_mentions() + if stocks > 0: + print(f"📊 个股数据: {stocks}条") + + synced = sync_to_decisions() + if synced > 0: + print(f"📋 决策库: 新增{synced}条建议") + + # 有实质更新才发汇总,否则安静 + if count > 0 or stocks > 0 or synced > 0: + print(f"✅ {datetime.now().strftime('%m/%d %H:%M')} 数据同步完成") + # 什么新数据都没有→安静,不输出任何内容 \ No newline at end of file diff --git a/src/advice_reconciliation.py b/src/advice_reconciliation.py new file mode 100644 index 0000000..58fd10d --- /dev/null +++ b/src/advice_reconciliation.py @@ -0,0 +1,223 @@ +#!/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() diff --git a/src/bulk_strategy_regenerate.py b/src/bulk_strategy_regenerate.py new file mode 100644 index 0000000..c28537f --- /dev/null +++ b/src/bulk_strategy_regenerate.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python3 +"""收盘后全量策略重评(no_agent 脚本)""" +import sys, json +sys.path.insert(0, "/home/hmo/web-dashboard") +from strategy_lifecycle import regenerate_all + +r = regenerate_all(stdout=False) +print(f"{json.dumps(r, ensure_ascii=False, indent=2)}") diff --git a/src/check_key_levels.py b/src/check_key_levels.py new file mode 100644 index 0000000..3d3801b --- /dev/null +++ b/src/check_key_levels.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +import json +import sys + +# 读取实时数据 +with open('data/temp_realtime.json', 'r') as f: + data = json.load(f) + +prices = data['prices'] +holdings = data['holdings'] + +print("=== 关键价位检查 ===") + +# 检查每个持仓的关键价位 +for holding in holdings: + code = holding['code'] + name = holding['name'] + current_price = holding['price'] + analysis = holding.get('analysis', {}) + + stop_loss = analysis.get('stop_loss') + take_profit = analysis.get('take_profit') + + if stop_loss and stop_loss != '': + try: + stop_loss_val = float(stop_loss) + distance_pct = (current_price - stop_loss_val) / stop_loss_val * 100 + if distance_pct < 5: # 距离止损不到5% + print(f"⚠️ {name}({code}) 现价{current_price} 距止损{stop_loss_val}仅{abs(distance_pct):.1f}%") + except: + pass + + if take_profit and take_profit != '': + try: + take_profit_val = float(take_profit) + distance_pct = (take_profit_val - current_price) / current_price * 100 + if distance_pct < 5: # 距离止盈不到5% + print(f"🎯 {name}({code}) 现价{current_price} 距止盈{take_profit_val}仅{abs(distance_pct):.1f}%") + except: + pass + +# 检查涨跌幅超过5%的股票 +print("\n=== 异动股票检查(涨跌幅>5%) ===") +for code, price_data in prices.items(): + change_pct = price_data['change_pct'] + if abs(change_pct) >= 5: + name = price_data['name'] + price = price_data['price'] + print(f"{'📈' if change_pct > 0 else '📉'} {name}({code}) 现价{price} {change_pct:+.2f}%") + +# 检查决策库中的操作区间 +print("\n=== 决策库操作区间检查 ===") +# 这里需要读取决策库,但数据太大,我们只检查几个关键股票 +key_stocks = ['06160', '600110', '688411', '01478'] + +for code in key_stocks: + if code in prices: + price_data = prices[code] + name = price_data['name'] + price = price_data['price'] + + # 根据历史回顾判断 + if code == '06160': + print(f"🔵 {name}({code}) 现价{price} → 两批试仓已完成,止损160安全,目标175/185") + elif code == '600110': + print(f"🔵 {name}({code}) 现价{price} → 6月4日已按11.5~11.8加仓,现价仍在区间内") + elif code == '688411': + print(f"⚠️ {name}({code}) 现价{price} → 大涨10.87%,追踪止盈290接近,注意风险") + elif code == '01478': + print(f"⚠️ {name}({code}) 现价{price} → 反弹7.44%,深套股反弹至13~14可减仓") \ No newline at end of file diff --git a/src/cron_to_xmpp.py b/src/cron_to_xmpp.py new file mode 100644 index 0000000..f1d7c4c --- /dev/null +++ b/src/cron_to_xmpp.py @@ -0,0 +1,354 @@ +#!/usr/bin/env python3 +"""cron_to_xmpp.py — 智能cron报告推送 + +只推送LLM驱动的分析报告(有实质内容),不推送纯脚本输出。 +关键规则: +1. 跳过 no_agent 脚本的输出(价格监控、数据同步等机器数据) +2. 跳过自己的输出目录(30908cdc44a8),避免循环推送 +3. 正文太短(<20字)或只有 [SILENT] 的不推 +4. 超时自动跳过,不影响后续 +""" +import json +import subprocess +import re +import sys +from datetime import datetime +from pathlib import Path + +# 使用绝对路径,不受 profile 环境变量影响 +REAL_HOME = Path("/home/hmo") + +# 扫描目录 +CRON_DIRS = [ + REAL_HOME / ".hermes" / "cron" / "output", + REAL_HOME / ".hermes" / "profiles" / "position-analyst" / "cron" / "output", +] +JOURNAL = REAL_HOME / ".hermes" / "cron" / ".relay_journal.json" +SILENT_STATS = REAL_HOME / ".hermes" / "cron" / ".silent_daily_count.json" +MAX_AGE_HOURS = 6 # 只推送6小时内的报告,防止清journal后爆历史 + + +def load_no_agent_job_ids(): + """从两个profile的jobs.json中读取所有no_agent=true的job ID""" + ids = set() + for jobs_path in [ + REAL_HOME / ".hermes" / "cron" / "jobs.json", + REAL_HOME / ".hermes" / "profiles" / "position-analyst" / "cron" / "jobs.json", + ]: + try: + with open(jobs_path) as f: + data = json.load(f) + for j in data.get("jobs", []): + if j.get("no_agent"): + ids.add(j["id"]) + except: + pass + return ids + + +# 硬编码保底(如果 jobs.json 读不到) +SKIP_DIRS = { + "30908cdc44a8", # cron-推XMPP中继自身输出 + "health", # 健康检查输出 +} + +FROM = "zhiwei@yoin.fun" +TO = "hmo@yoin.fun" + + +def load_journal(): + try: + return set(json.loads(JOURNAL.read_text())) + except: + return set() + + +def save_journal(entries): + JOURNAL.write_text(json.dumps(sorted(entries))) + + +def is_pure_script_output(content): + """判断文件是否是纯脚本的机器输出(不是LLM报告)""" + # LLM报告的特征:有 ## Response 节(包含agent的回复) + if "## Response" in content: + return False + # 以 # Cron Job: 开头但没有 ## Response 的可能是脚本输出 + if content.startswith("# Cron Job:"): + return True + # 价格监控的触发输出 + if content.startswith("🔔") and "⏱" in content: + return True + # 健康检查报告 + if "MoFin 系统健康检查" in content: + return True + # [SILENT] 标记一概不拦 — 用户想看到报告结构,不想被静默 + # 移除 [SILENT] 过滤,让报告始终送达 + # 结构化数据标签(价格监控的机器数据) + if "" in content: + return True + # no_agent 脚本的输出特征(Hermes自动添加的header) + if "**Mode:** no_agent (script)" in content: + return True + return False + + +def validate_report_body(body): + """质量检查 — 不拦截,返回改进建议""" + issues = [] + text = body.strip() + + if "重点推荐操作" not in text: + issues.append("缺少【重点推荐操作】区域(如无需操作可写「无」)") + + if "风险关注" not in text: + issues.append("缺少【风险关注】区域(如无风险可写「无」)") + + if len(text) > 600: + issues.append(f"报告偏长({len(text)}字),建议压缩到600字以内") + + fuzzy = re.findall(r"可关注|可考虑|建议观察|试试|谨慎关注|择机|根据情况", text) + if fuzzy: + issues.append(f"含模糊词: {', '.join(set(fuzzy))},建议替换为明确操作指令") + + if re.search(r"如果.*就.*如果.*就|若.*则.*若.*则", text): + issues.append("含选择题句式,建议只给一个确定建议") + + return issues + + +def send_feedback(issues, job_name): + """发送质量反馈给知微自己""" + from xml.sax.saxutils import escape + feedback = f"[自我反馈] 报告质量检查发现以下问题,下次注意:\n" + "\n".join(f"• {i}" for i in issues) + safe = escape(feedback) + stanza = ( + f"" + f"{safe}" + ) + try: + subprocess.run( + ["docker", "exec", "ejabberd", "ejabberdctl", + "send_stanza", FROM, FROM, stanza], + capture_output=True, timeout=10, text=True, + ) + except: + pass + + +def extract_body(path): + content = path.read_text(encoding="utf-8", errors="replace") + + if is_pure_script_output(content): + return None + + parts = content.split("## Response") + body = parts[1].strip() if len(parts) > 1 else content.strip() + body = re.sub(r'^#.*?\n', '', body, flags=re.MULTILINE).strip() + body = re.sub(r'\n?\s*.*?\s*', '', body, flags=re.DOTALL).strip() + body = re.sub(r'\*\*(.*?)\*\*', r'\1', body) + + # 去掉agent的思考过程("Now let me...", "Let me...", "Now I have..."等开头) + body = re.sub(r'^(Now let me|Let me|I need|I will|First let me|First,? I|Now I have|Here.i|I.ll|I.m ).*?\n\n', '', body, flags=re.DOTALL).strip() + # 去掉末尾的思考尾巴 + body = re.sub(r'\n\s*(Now I|This |I have |I used |The report|The data).*?$', '', body, flags=re.DOTALL).strip() + # 如果只剩"好的"、"收到"等短回应,丢弃 + if re.match(r'^[\u4e00-\u9fff,。]{1,10}$', body): + return None + + if not body or len(body) < 20: + return None + + # 再次检查正文中是否包含 [SILENT] + if "[SILENT]" in body: + return None + + return body + + +def send(body): + from xml.sax.saxutils import escape + safe = escape(f"【知微】{body}") + stanza = ( + f"" + f"{safe}" + ) + # 重试3次 + for attempt in range(3): + try: + r = subprocess.run( + ["docker", "exec", "ejabberd", "ejabberdctl", + "send_stanza", FROM, TO, stanza], + capture_output=True, timeout=10, text=True, + ) + if r.stderr and "error" in r.stderr.lower(): + print(f"send error (attempt {attempt+1}): {r.stderr.strip()[:100]}", file=sys.stderr) + if attempt < 2: + continue + return False + return r.returncode == 0 + except subprocess.TimeoutExpired: + print(f"send timeout (attempt {attempt+1})", file=sys.stderr) + if attempt < 2: + continue + return False + except Exception as e: + print(f"send err (attempt {attempt+1}): {e}", file=sys.stderr) + if attempt < 2: + continue + return False + return False + + +def validate_format(body): + """格式检查 — 只记录不拦截,标记改进点""" + text = body.strip() + issues = [] + + # 必含区域检查 + has_key = "重点推荐操作" in text + has_risk = "风险关注" in text + has_rest = "其余持仓" in text or "今日关注" in text + if not has_key: + issues.append("缺【重点推荐操作】区域") + if not has_risk: + issues.append("缺【风险关注】区域") + + # 超长提醒 + if len(text) > 600: + issues.append(f"报告偏长({len(text)}字),建议压缩到600字内") + + # 模糊词提醒 + fuzzy = re.findall(r"可关注|可考虑|建议观察|试试|谨慎关注|择机|根据情况", text) + if fuzzy: + issues.append(f"含模糊词({', '.join(list(set(fuzzy))[:3])}),应给唯一结论") + + # 选择题句式提醒 + if re.search(r"如果.*就|若.*则|可以.*也可以", text): + issues.append("含选择题句式,应给唯一建议") + + return text, issues # 始终通过,issues 为空就是干净 + + +def load_silent_stats(): + """加载当日静默统计""" + try: + return json.loads(SILENT_STATS.read_text()) + except: + return {"date": "", "silent": 0, "short": 0, "script": 0} + + +def save_silent_stats(stats): + SILENT_STATS.write_text(json.dumps(stats)) + + +def send_silent_summary(stats): + """发送当日静默报告汇总""" + parts = [] + if stats.get("silent", 0) > 0: + parts.append(f"静默[SILENT] {stats['silent']}次") + if stats.get("short", 0) > 0: + parts.append(f"过短(<20字) {stats['short']}次") + if stats.get("script", 0) > 0: + parts.append(f"脚本输出 {stats['script']}次") + + if not parts: + body = "【每日汇总】今日所有cron报告已正常送达,无被拦截的报告。" + else: + body = "【每日汇总】今日以下cron报告未送达(已拦截):\n" + "\n".join(f"• {p}" for p in parts) + "\n\n无操作信号的报告正常静默,有操作信号的都已送达。" + + send(body) + + +def scan(): + processed = load_journal() + new = set() + n_pushed = 0 + n_silent = 0 + n_short = 0 + n_script = 0 + no_agent_ids = load_no_agent_job_ids() + skip_all = SKIP_DIRS | no_agent_ids + + for cron_dir in CRON_DIRS: + if not cron_dir.exists(): + continue + + for d in sorted(cron_dir.iterdir()): + if not d.is_dir(): + continue + if d.name in skip_all: + continue + + for f in sorted(d.iterdir()): + if f.suffix != ".md": + continue + key = str(f.resolve()) + if key in processed or key in new: + continue + new.add(key) + + # 跳过超过MAX_AGE_HOURS小时的旧文件 + age_hours = (datetime.now() - datetime.fromtimestamp(f.stat().st_mtime)).total_seconds() / 3600 + if age_hours > MAX_AGE_HOURS: + continue + + content = f.read_text(encoding="utf-8", errors="replace") + + # 提前判断脚本输出 + if is_pure_script_output(content): + n_script += 1 + continue + + parts = content.split("## Response") + body = parts[1].strip() if len(parts) > 1 else content.strip() + body = re.sub(r'^#.*?\n', '', body, flags=re.MULTILINE).strip() + body = re.sub(r'\n?\s*.*?\s*', '', body, flags=re.DOTALL).strip() + body = re.sub(r'\*\*(.*?)\*\*', r'\1', body) + + if not body or len(body) < 20: + n_short += 1 + continue + + # SILENT → 拦截,记数 + if "[SILENT]" in body: + n_silent += 1 + continue + + # 格式校验 — 记录改进点,不拦截 + ok_body, issues = validate_format(body) + + n_pushed += 1 + ok_sent = send(body) + if not ok_sent: + print(f" {d.name}: send failed", file=sys.stderr) + if issues: + print(f" {d.name}/{f.name}: 改进建议: {'; '.join(issues)}", file=sys.stderr) + + if new: + save_journal(processed | new) + + # 保存当日汇总到文件(供16:30汇总用) + today = datetime.now().strftime("%Y-%m-%d") + stats = load_silent_stats() + if stats.get("date") != today: + stats = {"date": today, "silent": 0, "short": 0, "script": 0} + stats["silent"] += n_silent + stats["short"] += n_short + stats["script"] += n_script + save_silent_stats(stats) + + # 16:30~16:35 发送当日汇总(收盘后) + now = datetime.now() + hhmm = now.hour * 60 + now.minute + if 990 <= hhmm <= 995: # 16:30~16:35 + send_silent_summary(stats) + + log = f"推送{n_pushed}份,静默拦截{n_silent}份,过短{n_short}份,跳过脚本{n_script}份" + print(log, file=sys.stderr) + return n_pushed + + +if __name__ == "__main__": + scan() diff --git a/src/get_realtime_prices.py b/src/get_realtime_prices.py new file mode 100644 index 0000000..02812b3 --- /dev/null +++ b/src/get_realtime_prices.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +import json +import subprocess +import sys +import os +from datetime import datetime + +# 读取持仓数据 +with open('data/portfolio.json', 'r') as f: + portfolio = json.load(f) + +# 读取决策数据 +with open('data/decisions.json', 'r') as f: + decisions_data = json.load(f) + +# 获取所有持仓股票 +holdings = portfolio['holdings'] +active_holdings = [h for h in holdings if h.get('shares', 0) > 0] + +print(f"持仓股票数量: {len(active_holdings)}") + +# 获取实时价格 - 使用curl调用API +# 注意:这里需要根据实际情况调整API调用 +realtime_prices = {} + +for holding in active_holdings: + code = holding['code'] + name = holding['name'] + + # 判断市场类型 + # 港股代码5位数字(如01211),优先判断 + if len(code) == 5: # 港股 + market = 'hk' + price = holding['price'] + change_pct = holding.get('change_pct', 0) + realtime_prices[code] = { + 'name': name, + 'price': price, + 'change_pct': change_pct, + 'market': 'HK' + } + elif code.startswith('6'): # 沪市 + market = 'sh' + price = holding['price'] + change_pct = holding.get('change_pct', 0) + realtime_prices[code] = { + 'name': name, + 'price': price, + 'change_pct': change_pct, + 'market': 'A' + } + elif code.startswith('0') or code.startswith('3'): # 深市 + market = 'sz' + price = holding['price'] + change_pct = holding.get('change_pct', 0) + realtime_prices[code] = { + 'name': name, + 'price': price, + 'change_pct': change_pct, + 'market': 'A' + } + else: + # 其他类型 + price = holding['price'] + change_pct = holding.get('change_pct', 0) + realtime_prices[code] = { + 'name': name, + 'price': price, + 'change_pct': change_pct, + 'market': 'OTHER' + } + +# 获取活跃决策 +active_decisions = [d for d in decisions_data['decisions'] if d.get('status') == 'active'] + +print(f"活跃决策数量: {len(active_decisions)}") + +# 输出结果 +print("\n=== 实时价格数据 ===") +for code, data in realtime_prices.items(): + print(f"{code} {data['name']}: {data['price']} ({data['change_pct']:+.2f}%)") + +# 保存到临时文件供后续使用 +output_data = { + 'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + 'prices': realtime_prices, + 'holdings': active_holdings, + 'active_decisions': active_decisions +} + +with open('data/temp_realtime.json', 'w') as f: + json.dump(output_data, f, indent=2, ensure_ascii=False) + +print(f"\n数据已保存到 data/temp_realtime.json") \ No newline at end of file diff --git a/src/market_watch.py b/src/market_watch.py new file mode 100644 index 0000000..f00328c --- /dev/null +++ b/src/market_watch.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""market_watch.py — 行業熱點 + 市場洞察數據採集,寫入 dashboard data/market.json""" + +import json +import urllib.request +from datetime import datetime +from pathlib import Path + +DATA_DIR = Path(__file__).parent / "data" + +# ── 後端A:東方財富 push2 API(首選) ── + +def _fetch_em(url): + """通用 EM API 請求""" + req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}) + resp = urllib.request.urlopen(req, timeout=10) + return json.loads(resp.read().decode("utf-8")) + +def fetch_sector_em(): + """東方財富行業板塊""" + try: + data = _fetch_em("https://push2.eastmoney.com/api/qt/clist/get?pn=1&pz=15&po=1&np=1&fields=f2,f3,f4,f12,f14&fs=m:90+t:2") + return [{"name": i["f14"], "code": i["f12"], "price": i.get("f2", 0), "change": i.get("f3", 0)} + for i in data.get("data", {}).get("diff", [])] + except Exception as e: + return None + +def fetch_concept_em(): + """東方財富概念板塊""" + try: + data = _fetch_em("https://push2.eastmoney.com/api/qt/clist/get?pn=1&pz=10&po=1&np=1&fields=f2,f3,f4,f12,f14&fs=m:90+t:3") + return [{"name": i["f14"], "code": i["f12"], "change": i.get("f3", 0)} + for i in data.get("data", {}).get("diff", [])] + except Exception as e: + return None + +# ── 後端B:同花順 THS(降級) ── + +def fetch_sector_ths(): + """THS 行業板塊(含漲跌家數、資金流向)""" + try: + import akshare as ak + df = ak.stock_board_industry_summary_ths() + return [{ + "name": r["板块"], "code": "", "price": 0, + "change": float(r.get("涨跌幅", 0)), + "up_count": int(r.get("上涨家数", 0)), + "down_count": int(r.get("下跌家数", 0)), + "net_inflow": float(r.get("净流入", 0)), + } for _, r in df.iterrows()] + except Exception as e: + print(f"⚠️ THS行業失敗: {e}") + return [] + +def fetch_concept_ths(): + """THS 概念板塊(僅名稱,無實時漲跌)""" + try: + import akshare as ak + df = ak.stock_board_concept_name_ths() + return [{"name": r["name"], "code": str(r["code"]), "change": 0} + for _, r in df.head(15).iterrows()] + except Exception as e: + print(f"⚠️ THS概念失敗: {e}") + return [] + +# ── 主流程 ── + +def get_market_mood(sectors): + if not sectors: + return "unknown" + ratio = sum(1 for s in sectors if s.get("change", 0) > 0) / len(sectors) + return "bullish" if ratio > 0.7 else "neutral" if ratio > 0.4 else "bearish" + +def main(): + sectors = fetch_sector_em() + if sectors is None: + sectors = fetch_sector_ths() + + concepts = fetch_concept_em() + if concepts is None: + concepts = fetch_concept_ths() + + sorted_sectors = sorted(sectors, key=lambda s: s.get("change", 0), reverse=True) + top_gainers = [s for s in sorted_sectors if s.get("change", 0) > 0][:5] + top_losers = [s for s in reversed(sorted_sectors) if s.get("change", 0) < 0][:3] + + market_data = { + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"), + "total_sectors": len(sectors), + "up_ratio": round(sum(1 for s in sectors if s.get("change", 0) > 0) / max(len(sectors), 1) * 100, 1), + "mood": get_market_mood(sectors), + "top_gainers": top_gainers, + "top_losers": top_losers, + "sectors": sectors, + "concepts": concepts, + } + + DATA_DIR.mkdir(parents=True, exist_ok=True) + with open(DATA_DIR / "market.json", "w", encoding="utf-8") as f: + json.dump(market_data, f, ensure_ascii=False, indent=2) + + # 安静:数据只写文件,不打印到stdout,避免cron输出被推送 + +if __name__ == "__main__": + main() diff --git a/src/post_mortem.py b/src/post_mortem.py new file mode 100644 index 0000000..9265ba4 --- /dev/null +++ b/src/post_mortem.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""post_mortem.py - 知微历史报告复盘 + 自我改进引擎 + +读取所有历史报告,提炼分析模式,生成升级方案。 +""" + +import json +import re +from collections import defaultdict +from datetime import datetime +from pathlib import Path + +DATA_DIR = Path(__file__).parent / "data" +REPORTS_DIR = DATA_DIR / "reports" + + +def load_reports(): + reports = [] + for f in sorted(REPORTS_DIR.iterdir()): + if f.suffix == ".json" and f.stat().st_size > 200: + try: + reports.append(json.loads(f.read_text())) + except: + pass + return reports + + +def analyze_stock_timeline(code): + """提取某只股票在所有报告中的分析时间线""" + entries = [] + for r in load_reports(): + content = r.get("content", "") + if code in content: + lines = content.split("\n") + for i, line in enumerate(lines): + if code in line and ("止损" in line or "止盈" in line or "补仓" in line or "买入" in line or "卖出" in line or "持有" in line or "减仓" in line): + entries.append({ + "time": r.get("created_at", ""), + "type": r.get("type", ""), + "title": r.get("title", ""), + "line": line.strip()[:120], + "context": "\n".join(lines[max(0,i-1):i+2]), + }) + return entries + + +def generate_retrospective(code, name): + """对某只股票过去所有建议做复盘""" + entries = analyze_stock_timeline(code) + if not entries: + return None + + result = { + "code": code, + "name": name, + "entry_count": len(entries), + "first_seen": entries[0]["time"], + "last_seen": entries[-1]["time"], + "timeline": entries, + } + return result + + +def identify_gaps(): + """识别知微的分析gap""" + reports = load_reports() + gaps = [] + + # Gap 1: 缺少止盈建议(对比止损) + stop_loss_count = 0 + take_profit_count = 0 + for r in reports: + c = r.get("content", "") + stop_loss_count += c.count("止损") + take_profit_count += c.count("止盈") + + gap_ratio = stop_loss_count / max(take_profit_count, 1) + if gap_ratio > 3: + gaps.append(f"止损共{stop_loss_count}次,止盈仅{take_profit_count}次,比例{gap_ratio:.1f}:1。止盈意识不足,应增加止盈建议频率") + + # Gap 2: 模糊用词过多 + vague_words = ["关注", "观望", "留意", "观察", "考虑"] + vague_count = sum(sum(r.get("content", "").count(w) for r in reports) for w in vague_words) + if vague_count > 30: + gaps.append(f"模糊用词(关注/观望/留意等)共{vague_count}次,应减少,改用具体动作+价格") + + # Gap 3: 跨报告连贯性 + all_codes = set() + for r in reports: + codes = re.findall(r'\b\d{5,6}\b', r.get("content", "")) + all_codes.update(codes) + + for code in list(all_codes)[:5]: + entries = analyze_stock_timeline(code) + action_count = len(entries) + if action_count > 3: + gaps.append(f"{code}出现{action_count}次,没有跟踪验证之前的建议是否有效") + + return gaps + + +if __name__ == "__main__": + print("=" * 60) + print("📋 知微复盘报告") + print(f" 生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M')}") + print("=" * 60) + + gaps = identify_gaps() + print(f"\n🔍 发现 {len(gaps)} 个改进点:") + for g in gaps: + print(f" ⚠️ {g}") + + print("\n📈 个股跟踪示例:") + for code, name in [("688411", "海博思创"), ("600563", "法拉电子"), ("600110", "诺德股份")]: + retro = generate_retrospective(code, name) + if retro: + print(f"\n {name}({code}): {retro['entry_count']}次提及") + for e in retro["timeline"][-3:]: + print(f" [{e['time'][:16]}] {e['line']}") + + print("\n✅ 复盘完成") \ No newline at end of file diff --git a/src/price_monitor.py b/src/price_monitor.py new file mode 100644 index 0000000..8f8181c --- /dev/null +++ b/src/price_monitor.py @@ -0,0 +1,342 @@ +#!/usr/bin/env python3 +"""price_monitor.py — 高频价格监控脚本(批量版) +规则:进入区间报一次,离开区间报一次,中间不重复。 +每次运行时一次性刷新所有持仓+自选股的实时价。 +""" +import json +import urllib.request +import os +import sys +import time +from datetime import datetime + +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" + +UA = "Mozilla/5.0" + +# ── 批量拉取价格 ────────────────────────────────────────────────────────── + +def fetch_all_prices(codes): + """腾讯批量行情API:一次请求拉取所有股票(A股+港股) + A股:sh600110 / sz000001 + 港股:hk00700 + 返回 {code: (price, change)} + """ + if not codes: + return {} + + # 构建批量查询串 + symbols = [] + code_map = {} # symbol -> original_code + for code in codes: + code_s = str(code).strip() + if len(code_s) == 6: + # A股:沪市以5/6/9开头,深市以0/3开头 + if code_s.startswith(('5', '6', '9')): + sym = f"sh{code_s}" + else: + sym = f"sz{code_s}" + else: + sym = f"hk{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"⚠️ 批量拉取失败: {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: + # 格式: v_sh600110="1~诺德股份~600110~11.84~11.90~..." + 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 + results[orig_code] = (price, change) + except (ValueError, IndexError): + continue + + return results + + +def refresh_data_prices(): + """一次性刷新portfolio.json和watchlist.json的所有实时价""" + all_codes = set() + + # 收集所有需要拉取的代码 + try: + pf = json.load(open(PORTFOLIO_PATH)) + for s in pf.get('holdings', []): + all_codes.add(s['code']) + except: + pf = {"holdings": []} + + try: + wl = json.load(open(WATCHLIST_PATH)) + for s in wl.get('stocks', []): + all_codes.add(s['code']) + except: + wl = {"stocks": []} + + if not all_codes: + return 0 + + # 一次性批量拉取 + prices = fetch_all_prices(list(all_codes)) + updated = 0 + + # 更新portfolio(只在价格变化时写入,避免触发文件变更通知) + changed = False + for s in pf.get('holdings', []): + if s['code'] in prices: + price, _ = prices[s['code']] + if price > 0: + old = s.get('price', 0) + if abs(old - price) > 0.001: + s['price'] = round(price, 2) + updated += 1 + changed = True + if changed: + json.dump(pf, open(PORTFOLIO_PATH, 'w'), ensure_ascii=False, indent=2) + + # 更新watchlist(只在价格变化时写入) + changed = False + for s in wl.get('stocks', []): + if s['code'] in prices: + price, _ = prices[s['code']] + if price > 0: + old = s.get('price', 0) + if abs(old - price) > 0.001: + s['price'] = round(price, 2) + updated += 1 + changed = True + if changed: + wl['updated_at'] = datetime.now().isoformat() + json.dump(wl, open(WATCHLIST_PATH, 'w'), ensure_ascii=False, indent=2) + + return updated + + +# ── 区间偏离检测 ────────────────────────────────────────────────────────── + +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""" + 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) + + +def get_trigger_zones(trigger): + """返回该trigger所有可监控的区间列表,跳过已执行的batch""" + zones = [] + for key, label in [ + ("entry_zone", "加仓区间"), + ("batch1_price", "试仓区间"), + ("batch2_price", "加仓区间"), + ("take_profit_zone", "止盈区间"), + ("watch_low", "关注区间"), + ("watch_high", "减仓区间"), + ("watch_break", "止损区间") + ]: + status_key = key.replace("_price", "_status") + if status_key in trigger and trigger[status_key] == "executed": + continue + val = trigger.get(key, "") + if val and "~" in val: + try: + parts = val.split("~") + lo, hi = float(parts[0]), float(parts[1]) + zones.append((key, label, lo, hi)) + except: + pass + sl = trigger.get("stop_loss", "") + if sl: + try: + sl_price = float(sl) if isinstance(sl, (int, float)) else float(sl) + zones.append(("stop_loss", "止损", 0, sl_price)) + except: + pass + return zones + + +def run_once(round_label=""): + """执行一轮完整的监控流程""" + label = f" [{round_label}]" if round_label else "" + start = time.time() + + # === 第一步:一次性刷新所有价格 === + refreshed = refresh_data_prices() + + # === 第二步:检查触发条件 === + try: + with open(DECISIONS_PATH) as f: + dec = json.load(f) + except: + print(f"❌{label} 无法读取decisions.json", file=sys.stderr) + return + + active = [d for d in dec.get("decisions", []) if d.get("status") == "active"] + state = load_state() + outputs = [] + state_updated = False + + # 收集所有需要检查的代码 + check_codes = set() + for d in active: + trig = d.get("trigger", {}) + if trig: + check_codes.add(d["code"]) + + # 批量拉取这些股票的价格 + prices = fetch_all_prices(list(check_codes)) + + for d in active: + code = d["code"] + trig = d.get("trigger", {}) + if not trig: + continue + + zones = get_trigger_zones(trig) + 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": + outputs.append(f"⚠️ {name}({code}) {price} → 跌破止损{hi}!") + record_event(code, name, "stop_loss", price, str(hi)) + else: + extra = "" + if "_price" in key: + batch_shares = trig.get(key.replace("_price", "_shares"), "") + action = trig.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 = trig.get("take_profit_action", "") + if act: + extra = f"({act})" + outputs.append(f"⚡ {name}({code}) {price} → 进入{label}{lo}~{hi}{extra}") + 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 + + # === 第三步:输出 === + now_str = datetime.now().strftime("%H:%M:%S") + elapsed = time.time() - start + + if outputs: + print(f"\n🔔 {now_str}{label}") + for o in outputs: + print(o) + print(f"\n{json.dumps({'type':'价格监控','time':now_str,'triggers':outputs}, ensure_ascii=False)}") + else: + # 无触发时 SILENT(中继不推送) + print(f"[SILENT]{label} 价格正常 | {refreshed}只已刷新 | {elapsed:.1f}s") + + if state_updated: + save_state(state) + + # 输出耗时 + print(f"⏱{label} {elapsed:.1f}s", flush=True) + + +def main(): + """每cron触发跑一轮""" + run_once() + + +if __name__ == "__main__": + main() diff --git a/src/prompt_manager/__init__.py b/src/prompt_manager/__init__.py new file mode 100644 index 0000000..341922d --- /dev/null +++ b/src/prompt_manager/__init__.py @@ -0,0 +1,14 @@ +"""MoFin 提示词管理器 — 集中管理所有知微用到的提示词 + +功能: +1. 提示词注册表 — 所有提示词分类管理 +2. 版本管理 — 每次修改记录版本号/变更日志/内容 +3. 策略关联 — 每只股票的策略关联生成它的提示词版本 +4. 统计分析 — 哪个版本的提示词产生的策略最有效 +""" + +from .registry import PromptRegistry +from .tracking import PromptTracker +from .analytics import PromptAnalytics + +__all__ = ["PromptRegistry", "PromptTracker", "PromptAnalytics"] diff --git a/src/prompt_manager/analytics.py b/src/prompt_manager/analytics.py new file mode 100644 index 0000000..11551e5 --- /dev/null +++ b/src/prompt_manager/analytics.py @@ -0,0 +1,186 @@ +"""策略→提示词版本分析引擎 + +核心功能:将策略评估结果按提示词版本聚合,计算每个版本的准确率。 +""" + +import json +from pathlib import Path +from datetime import datetime +from typing import Optional + +from .tracking import load_associations, get_associations_for_prompt_version +from .registry import get_prompt, get_version_history + +PROJECT_DIR = Path("/home/hmo/projects/MoFin") +DECISIONS_PATH = PROJECT_DIR / "data" / "decisions.json" +ACCURACY_PATH = PROJECT_DIR / "data" / "accuracy_stats.json" +EVAL_PATH = PROJECT_DIR / "data" / "evaluation.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 analyze_prompt_version_effectiveness() -> dict: + """按提示词版本聚合策略评估结果 + + 关联 decisions.json 中的 evaluation 字段和 associations.json 中的版本记录, + 计算出每个提示词版本的: + - 生成的策略总数 + - 达到止盈数(成功) + - 跌破止损数(失败) + - 待验证数 + - 盈亏比平均值 + """ + # 加载数据 + decisions = _load_json(DECISIONS_PATH, {"decisions": []}) + associations = load_associations().get("associations", []) + + # 建立 code → 最新关联的映射 + code_to_pv = {} # code -> {prompt_id, version} + for a in associations: + code = a.get("code") + if code and code not in code_to_pv: + code_to_pv[code] = { + "prompt_id": a.get("prompt_id"), + "version": a.get("prompt_version"), + } + + # 按 prompt_id@version 分组统计 + version_stats = {} + + for d in decisions.get("decisions", []): + code = d.get("code") + if not code: + continue + + pv = code_to_pv.get(code) + if not pv: + continue + + key = f"{pv['prompt_id']}@{pv['version']}" + if key not in version_stats: + version_stats[key] = { + "prompt_id": pv["prompt_id"], + "version": pv["version"], + "total": 0, + "take_profit_hit": 0, + "stop_loss_hit": 0, + "in_entry_zone": 0, + "pending": 0, + "avg_rr": 0.0, + "rr_sum": 0.0, + "rr_count": 0, + "stocks": [], + } + + vs = version_stats[key] + vs["total"] += 1 + vs["stocks"].append(code) + + # 从 evaluation 中读取状态 + evals = d.get("evaluation", []) + for ev in evals: + if isinstance(ev, dict) and ev.get("phase") == 1: + theo = ev.get("theoretical", {}) + status = theo.get("status", "") + if status == "take_profit_hit": + vs["take_profit_hit"] += 1 + elif status == "stop_loss_hit": + vs["stop_loss_hit"] += 1 + elif status == "in_entry_zone": + vs["in_entry_zone"] += 1 + else: + vs["pending"] += 1 + + # 盈亏比 + rr = d.get("rr_ratio") + if rr is not None and isinstance(rr, (int, float)): + vs["rr_sum"] += rr + vs["rr_count"] += 1 + + # 计算平均盈亏比和成功率 + for key, vs in version_stats.items(): + if vs["rr_count"] > 0: + vs["avg_rr"] = round(vs["rr_sum"] / vs["rr_count"], 2) + + total_outcome = vs["take_profit_hit"] + vs["stop_loss_hit"] + if total_outcome > 0: + vs["success_rate"] = round(vs["take_profit_hit"] / total_outcome * 100, 1) + else: + vs["success_rate"] = None + + del vs["rr_sum"] + + return version_stats + + +def get_prompt_version_comparison() -> dict: + """生成版本对比报告""" + version_stats = analyze_prompt_version_effectiveness() + + # 补充每个版本的标签信息 + for key, vs in version_stats.items(): + prompt = get_prompt(vs["prompt_id"]) + if prompt: + for v in prompt.versions: + if v.version == vs["version"]: + vs["label"] = v.label + vs["changelog"] = v.changelog + vs["tags"] = v.tags + break + if "label" not in vs: + vs["label"] = vs["version"] + + return version_stats + + +def generate_report() -> str: + """生成版本有效性报告文本""" + comparison = get_prompt_version_comparison() + + lines = [] + lines.append("📊 提示词版本有效性分析 | " + datetime.now().strftime("%Y-%m-%d")) + lines.append("") + + if not comparison: + lines.append("暂无数据 — 请先运行策略生成和评估后重试") + return "\n".join(lines) + + # 按 prompt_id 分组显示 + by_prompt = {} + for key, vs in comparison.items(): + pid = vs["prompt_id"] + by_prompt.setdefault(pid, []).append(vs) + + for pid, versions in sorted(by_prompt.items()): + prompt = get_prompt(pid) + lines.append(f"\n## {prompt.name if prompt else pid}") + lines.append(f" {prompt.description if prompt else ''}") + lines.append("") + + # 按版本号排序 + versions.sort(key=lambda x: x["version"]) + + header = f" {'版本':<10} {'标签':<20} {'策略数':<8} {'止盈':<8} {'止损':<8} {'成功率':<10} {'平均R/R':<10}" + lines.append(header) + lines.append(" " + "-" * len(header)) + + for vs in versions: + sr = f"{vs['success_rate']}%" if vs['success_rate'] is not None else "-" + label = vs.get("label", "")[:18] + lines.append( + f" {vs['version']:<10} {label:<20} " + f"{vs['total']:<8} {vs['take_profit_hit']:<8} " + f"{vs['stop_loss_hit']:<8} {sr:<10} " + f"{vs['avg_rr']:<10}" + ) + + lines.append("") + lines.append("---") + lines.append("注:数据来自 decisions.json evaluation + associations.json") + return "\n".join(lines) diff --git a/src/prompt_manager/dashboard_views.py b/src/prompt_manager/dashboard_views.py new file mode 100644 index 0000000..637b0e7 --- /dev/null +++ b/src/prompt_manager/dashboard_views.py @@ -0,0 +1,363 @@ +"""Dashboard 提示词管理视图 —— 集成到 MoFin Web Dashboard + +注册 API 路由和前端页面。 +""" + +import json +from pathlib import Path +from datetime import datetime + +from .registry import ( + list_prompts, get_prompt, add_prompt, update_prompt, + add_version, set_active_version, get_version_history, get_categories, +) +from .tracking import ( + get_strategy_version_stats, get_associations_for_stock, +) +from .analytics import ( + get_prompt_version_comparison, generate_report, +) +from .models import PromptDef, PromptVersion + + +def register_routes(server): + """在主 server.py 中调用,注册所有 /api/prompts/* 路由 + + 用法: + from prompt_manager.dashboard_views import register_routes + register_routes(app) # app = Flask instance + """ + + # ═══════════════════════════════════════════ + # API: 提示词列表 + # ═══════════════════════════════════════════ + + @server.route("/api/prompts") + def api_prompts_list(): + from flask import request, jsonify + category = request.args.get("category") + prompts = list_prompts(category) + return jsonify({ + "prompts": prompts, + "categories": get_categories(), + }) + + # ═══════════════════════════════════════════ + # API: 单个提示词详情 + # ═══════════════════════════════════════════ + + @server.route("/api/prompts/") + def api_prompt_detail(prompt_id): + from flask import jsonify + prompt = get_prompt(prompt_id) + if not prompt: + return jsonify({"error": "not found"}), 404 + history = get_version_history(prompt_id) + return jsonify({ + "prompt": prompt.to_dict(), + "version_history": history, + }) + + # ═══════════════════════════════════════════ + # API: 新增提示词 + # ═══════════════════════════════════════════ + + @server.route("/api/prompts", methods=["POST"]) + def api_prompt_create(): + from flask import request, jsonify + data = request.get_json() + if not data or "id" not in data: + return jsonify({"error": "缺少 id"}), 400 + + now = datetime.now().isoformat() + prompt = PromptDef( + id=data["id"], + name=data.get("name", data["id"]), + description=data.get("description", ""), + category=data.get("category", ""), + locations=data.get("locations", []), + versions=[], + created_at=now, + updated_at=now, + current_version="", + ) + add_prompt(prompt) + return jsonify({"status": "ok", "prompt": prompt.to_dict()}) + + # ═══════════════════════════════════════════ + # API: 添加版本 + # ═══════════════════════════════════════════ + + @server.route("/api/prompts//versions", methods=["POST"]) + def api_prompt_add_version(prompt_id): + from flask import request, jsonify + data = request.get_json() + if not data or "version" not in data: + return jsonify({"error": "缺少 version"}), 400 + + now = datetime.now().isoformat() + version = PromptVersion( + version=data["version"], + label=data.get("label", data["version"]), + created_at=now, + changelog=data.get("changelog", ""), + content=data.get("content", ""), + content_path="", + author=data.get("author", "知微"), + status=data.get("status", "active"), + tags=data.get("tags", []), + ) + try: + add_version(prompt_id, version) + return jsonify({"status": "ok"}) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + + # ═══════════════════════════════════════════ + # API: 切换活跃版本 + # ═══════════════════════════════════════════ + + @server.route("/api/prompts//activate", methods=["POST"]) + def api_prompt_activate(prompt_id): + from flask import request, jsonify + data = request.get_json() + if not data or "version" not in data: + return jsonify({"error": "缺少 version"}), 400 + try: + set_active_version(prompt_id, data["version"]) + return jsonify({"status": "ok"}) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + + # ═══════════════════════════════════════════ + # API: 版本统计 + # ═══════════════════════════════════════════ + + @server.route("/api/prompts/stats") + def api_prompt_stats(): + from flask import jsonify + version_stats = get_strategy_version_stats() + return jsonify(version_stats) + + # ═══════════════════════════════════════════ + # API: 版本有效性对比 + # ═══════════════════════════════════════════ + + @server.route("/api/prompts/effectiveness") + def api_prompt_effectiveness(): + from flask import jsonify + comparison = get_prompt_version_comparison() + return jsonify(comparison) + + # ═══════════════════════════════════════════ + # API: 生成分析报告(文本) + # ═══════════════════════════════════════════ + + @server.route("/api/prompts/report") + def api_prompt_report(): + from flask import jsonify + report = generate_report() + return jsonify({"report": report}) + + # ═══════════════════════════════════════════ + # API: 获取某只股票的策略关联记录 + # ═══════════════════════════════════════════ + + @server.route("/api/prompts/associations/") + def api_prompt_associations(code): + from flask import jsonify + records = get_associations_for_stock(code) + return jsonify({"code": code, "records": records}) + + +# ═══════════════════════════════════════════ +# 前端页面模板 +# ═══════════════════════════════════════════ + +def get_dashboard_tab_html(): + """返回提示词管理 Tab 的 HTML,嵌入到 Dashboard 的
中""" + return """\ +
+
+
+
+
📝 提示词管理
+
+ +
+
+
+
加载中...
+
+
+
+
+ + + + + + + + +""" diff --git a/src/prompt_manager/models.py b/src/prompt_manager/models.py new file mode 100644 index 0000000..6b4078f --- /dev/null +++ b/src/prompt_manager/models.py @@ -0,0 +1,115 @@ +"""提示词管理数据模型""" + +from dataclasses import dataclass, field, asdict +from typing import Optional +from datetime import datetime +import json + + +# ═══════════════════════════════════════════ +# 枚举/常量 +# ═══════════════════════════════════════════ + +PROMPT_CATEGORIES = { + "strategy": "策略生成 — 制定买入区/止损/止盈的规则集", + "scan": "快速盯盘 — 交易时段行情监控报告", + "evaluation": "策略评估 — 每日/每周策略效果评估", + "knowledge": "知识萃取 — 经验沉淀到知识日志", + "review": "持仓复查 — 定期全面持仓分析", + "health": "系统健康检查 — 每日开盘前检查", + "format": "报告格式规范 — 三段式输出格式规则", + "analysis": "分析规则 — 单股/行业分析流程", +} + +VERSION_STATUS = { + "active": "当前使用中", + "deprecated": "已弃用(不再使用)", + "archived": "已归档(历史版本,仅保留记录)", + "experimental": "实验版本(临时测试)", +} + + +# ═══════════════════════════════════════════ +# 数据类 +# ═══════════════════════════════════════════ + +@dataclass +class PromptVersion: + """单个提示词版本""" + version: str # v1, v2, v2.1 ... + label: str # 人类可读的名称 + created_at: str # ISO datetime + changelog: str # 变更说明 + content: str # 提示词完整内容(或摘要) + content_path: str # 提示词文件路径(完整内容存储处) + author: str = "知微" # 修改者 + status: str = "active" # active/deprecated/archived/experimental + tags: list = field(default_factory=list) # 标签:如 "技术面", "R/R阈值" + + def to_dict(self): + return asdict(self) + + @classmethod + def from_dict(cls, d): + return cls(**d) + + +@dataclass +class PromptDef: + """一条提示词定义""" + id: str # 唯一标识,如 "strategy-generation" + name: str # 显示名称 + description: str # 用途说明 + category: str # 分类 + locations: list # 存放位置(文件路径、cron job ID等) + versions: list # PromptVersion 列表 + created_at: str # 首次创建时间 + updated_at: str # 最近更新时间 + current_version: str # 当前活跃版本号 + + def to_dict(self): + return { + "id": self.id, + "name": self.name, + "description": self.description, + "category": self.category, + "locations": self.locations, + "versions": [v.to_dict() for v in self.versions], + "created_at": self.created_at, + "updated_at": self.updated_at, + "current_version": self.current_version, + } + + @classmethod + def from_dict(cls, d): + versions = [PromptVersion.from_dict(v) for v in d.get("versions", [])] + return cls( + id=d["id"], + name=d["name"], + description=d.get("description", ""), + category=d.get("category", ""), + locations=d.get("locations", []), + versions=versions, + created_at=d.get("created_at", ""), + updated_at=d.get("updated_at", ""), + current_version=d.get("current_version", ""), + ) + + +@dataclass +class StrategyLink: + """策略→提示词版本关联记录""" + code: str # 股票代码 + name: str # 股票名称 + prompt_id: str # 提示词ID + prompt_version: str # 提示词版本号 + strategy_action: str # 生成的策略文本 + generated_at: str # 生成时间 + evaluation_result: Optional[dict] = None # 评估结果(后续补充) + + def to_dict(self): + return asdict(self) + + @classmethod + def from_dict(cls, d): + return cls(**d) diff --git a/src/prompt_manager/registry.py b/src/prompt_manager/registry.py new file mode 100644 index 0000000..fd7eaba --- /dev/null +++ b/src/prompt_manager/registry.py @@ -0,0 +1,194 @@ +"""提示词注册表 — 所有提示词的CRUD和版本管理""" + +import json +import shutil +from pathlib import Path +from datetime import datetime +from typing import Optional + +from .models import PromptDef, PromptVersion, PROMPT_CATEGORIES + +# 数据文件路径 +DATA_DIR = Path("/home/hmo/projects/MoFin/data/prompts") +REGISTRY_PATH = DATA_DIR / "registry.json" +VERSIONS_DIR = DATA_DIR / "versions" + + +def ensure_dirs(): + DATA_DIR.mkdir(parents=True, exist_ok=True) + VERSIONS_DIR.mkdir(parents=True, exist_ok=True) + + +def load_registry() -> dict: + """加载注册表""" + ensure_dirs() + if not REGISTRY_PATH.exists(): + return {"prompts": [], "updated_at": datetime.now().isoformat()} + try: + return json.loads(REGISTRY_PATH.read_text(encoding="utf-8")) + except (json.JSONDecodeError, FileNotFoundError): + return {"prompts": [], "updated_at": datetime.now().isoformat()} + + +def save_registry(data: dict): + ensure_dirs() + data["updated_at"] = datetime.now().isoformat() + REGISTRY_PATH.write_text( + json.dumps(data, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + +# ═══════════════════════════════════════════ +# CRUD +# ═══════════════════════════════════════════ + +def list_prompts(category: str = None) -> list: + """列出所有提示词,可选按分类过滤""" + reg = load_registry() + prompts = [] + for p in reg.get("prompts", []): + if category and p.get("category") != category: + continue + prompts.append(dict(PromptDef.from_dict(p).to_dict())) + return prompts + + +def get_prompt(prompt_id: str) -> Optional[PromptDef]: + """获取单个提示词""" + reg = load_registry() + for p in reg.get("prompts", []): + if p["id"] == prompt_id: + return PromptDef.from_dict(p) + return None + + +def add_prompt(prompt_def: PromptDef): + """添加新提示词""" + reg = load_registry() + # 检查是否已存在 + for i, p in enumerate(reg.get("prompts", [])): + if p["id"] == prompt_def.id: + reg["prompts"][i] = prompt_def.to_dict() + break + else: + reg.setdefault("prompts", []).append(prompt_def.to_dict()) + save_registry(reg) + + +def update_prompt(prompt_id: str, updates: dict): + """更新提示词元数据""" + reg = load_registry() + for p in reg.get("prompts", []): + if p["id"] == prompt_id: + for k, v in updates.items(): + if k not in ("id", "versions", "created_at"): + p[k] = v + break + save_registry(reg) + + +def delete_prompt(prompt_id: str): + """删除提示词""" + reg = load_registry() + reg["prompts"] = [p for p in reg.get("prompts", []) if p["id"] != prompt_id] + save_registry(reg) + + +def get_version_content(prompt_id: str, version: str) -> Optional[str]: + """读取版本内容文件""" + content_path = VERSIONS_DIR / f"{prompt_id}-{version}.md" + if content_path.exists(): + return content_path.read_text(encoding="utf-8") + # 回退:从版本记录的 content 字段读取 + prompt = get_prompt(prompt_id) + if prompt: + for v in prompt.versions: + if v.version == version: + return v.content + return None + + +# ═══════════════════════════════════════════ +# 版本管理 +# ═══════════════════════════════════════════ + +def add_version(prompt_id: str, version: PromptVersion): + """添加新版本到提示词""" + prompt = get_prompt(prompt_id) + if not prompt: + raise ValueError(f"提示词 '{prompt_id}' 不存在") + + # 检查版本号是否已存在 + for i, v in enumerate(prompt.versions): + if v.version == version.version: + prompt.versions[i] = version + break + else: + prompt.versions.append(version) + + # 保存版本内容到独立文件 + content_path = VERSIONS_DIR / f"{prompt_id}-{version.version}.md" + content_path.write_text(version.content, encoding="utf-8") + + # 更新 content_path 只存路径,content 字段只存摘要 + version.content_path = str(content_path) + version.content = version.content[:200] + "..." if len(version.content) > 200 else version.content + + # 设置当前版本 + prompt.current_version = version.version + prompt.updated_at = datetime.now().isoformat() + + # 保存 + add_prompt(prompt) + + +def set_active_version(prompt_id: str, version_str: str): + """切换当前活跃版本""" + prompt = get_prompt(prompt_id) + if not prompt: + raise ValueError(f"提示词 '{prompt_id}' 不存在") + + # 验证版本存在 + version_exists = any(v.version == version_str for v in prompt.versions) + if not version_exists: + raise ValueError(f"版本 '{version_str}' 不存在于 '{prompt_id}'") + + # 将所有版本标记为非活跃,目标版本标记为活跃 + for v in prompt.versions: + if v.version == version_str: + v.status = "active" + elif v.status == "active": + v.status = "deprecated" + + prompt.current_version = version_str + prompt.updated_at = datetime.now().isoformat() + add_prompt(prompt) + + +# ═══════════════════════════════════════════ +# 分类信息 +# ═══════════════════════════════════════════ + +def get_categories() -> dict: + """获取所有分类""" + return dict(PROMPT_CATEGORIES) + + +def get_version_history(prompt_id: str) -> list: + """获取某个提示词的所有版本历史""" + prompt = get_prompt(prompt_id) + if not prompt: + return [] + history = [] + for v in prompt.versions: + history.append({ + "version": v.version, + "label": v.label, + "status": v.status, + "created_at": v.created_at, + "changelog": v.changelog, + "tags": v.tags, + "is_current": v.version == prompt.current_version, + }) + return sorted(history, key=lambda x: x["version"]) diff --git a/src/prompt_manager/tracking.py b/src/prompt_manager/tracking.py new file mode 100644 index 0000000..77ef581 --- /dev/null +++ b/src/prompt_manager/tracking.py @@ -0,0 +1,122 @@ +"""策略→提示词版本关联追踪 + +当 strategy_lifecycle.py 生成策略时,记录当前使用的提示词版本, +将每只股票的策略与生成它的提示词版本关联起来。 +""" + +import json +from pathlib import Path +from datetime import datetime +from typing import Optional + +from .models import StrategyLink +from .registry import get_prompt + +DATA_DIR = Path("/home/hmo/projects/MoFin/data/prompts") +ASSOCIATIONS_PATH = DATA_DIR / "associations.json" + + +def load_associations() -> dict: + """加载关联记录""" + DATA_DIR.mkdir(parents=True, exist_ok=True) + if not ASSOCIATIONS_PATH.exists(): + return {"associations": [], "updated_at": datetime.now().isoformat()} + try: + return json.loads(ASSOCIATIONS_PATH.read_text(encoding="utf-8")) + except (json.JSONDecodeError, FileNotFoundError): + return {"associations": [], "updated_at": datetime.now().isoformat()} + + +def save_associations(data: dict): + DATA_DIR.mkdir(parents=True, exist_ok=True) + data["updated_at"] = datetime.now().isoformat() + ASSOCIATIONS_PATH.write_text( + json.dumps(data, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + +def get_current_strategy_prompt_version() -> dict: + """获取当前活跃的策略生成提示词版本 + + 返回 {prompt_id, version, label} 或 None + """ + prompt = get_prompt("strategy-generation") + if not prompt: + return None + for v in prompt.versions: + if v.version == prompt.current_version: + return { + "prompt_id": prompt.id, + "version": v.version, + "label": v.label, + } + return None + + +def record_strategy_generation(code: str, name: str, strategy_action: str) -> StrategyLink: + """记录策略生成事件 + + 由 strategy_lifecycle.py 在生成策略时调用 + """ + pv = get_current_strategy_prompt_version() + if not pv: + # 如果没有提示词版本记录,创建一个默认记录 + pv = {"prompt_id": "strategy-generation", "version": "unknown", "label": "未知版本"} + + link = StrategyLink( + code=code, + name=name, + prompt_id=pv["prompt_id"], + prompt_version=pv["version"], + strategy_action=strategy_action[:500], # 截断保护 + generated_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + ) + + data = load_associations() + data.setdefault("associations", []).append(link.to_dict()) + + # 只保留最近的10000条记录 + if len(data["associations"]) > 10000: + data["associations"] = data["associations"][-10000:] + + save_associations(data) + return link + + +def get_associations_for_stock(code: str, max_results: int = 10) -> list: + """获取某只股票的所有策略关联记录""" + data = load_associations() + records = [a for a in data.get("associations", []) if a["code"] == code] + return sorted(records, key=lambda x: x.get("generated_at", ""), reverse=True)[:max_results] + + +def get_associations_for_prompt_version(prompt_id: str, version: str) -> list: + """获取某个提示词版本生成的所有策略""" + data = load_associations() + return [a for a in data.get("associations", []) + if a.get("prompt_id") == prompt_id and a.get("prompt_version") == version] + + +def get_strategy_version_stats() -> dict: + """统计每个提示词版本生成的策略数量""" + data = load_associations() + stats = {} + for a in data.get("associations", []): + key = f"{a.get('prompt_id', '?')}@{a.get('prompt_version', '?')}" + if key not in stats: + stats[key] = { + "prompt_id": a.get("prompt_id"), + "version": a.get("prompt_version"), + "count": 0, + "stocks": set(), + "last_generated": "", + } + stats[key]["count"] += 1 + stats[key]["stocks"].add(a.get("code")) + if a.get("generated_at", "") > stats[key]["last_generated"]: + stats[key]["last_generated"] = a.get("generated_at", "") + # 转换 set 为 list 以便 JSON 序列化 + for k in stats: + stats[k]["stocks"] = sorted(stats[k]["stocks"]) + return stats diff --git a/src/regenerate_strategies.py b/src/regenerate_strategies.py new file mode 100644 index 0000000..16c2856 --- /dev/null +++ b/src/regenerate_strategies.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""批量再生所有持仓+自选策略,结合技术面支撑/压力位""" + +import json +import sys +sys.path.insert(0, '/home/hmo/web-dashboard') + +from technical_analysis import full_analysis +from strategy_lifecycle import reassess_strategy + +PF = '/home/hmo/web-dashboard/data/portfolio.json' +WL = '/home/hmo/web-dashboard/data/watchlist.json' + +def main(): + # 持仓 + pf = json.load(open(PF)) + for s in pf['holdings']: + code = s['code'] + name = s['name'] + price = s.get('price', 0) + cost = s.get('cost', 0) + shares = s.get('shares', 0) + + if not price: + continue + + print(f" {name}({code}) 现价{price} 成本{cost}...", end=' ') + + try: + tech = full_analysis(code) + except: + tech = None + + result = reassess_strategy( + code, name, price, cost, shares, + current_action=s.get('analysis', {}).get('action', '') + ) + + if 'analysis' not in s: + s['analysis'] = {} + s['analysis']['stop_loss'] = result['stop_loss'] + s['analysis']['take_profit'] = result['take_profit'] + s['analysis']['entry_low'] = result['entry_low'] + s['analysis']['entry_high'] = result['entry_high'] + s['analysis']['action'] = result['action'] + s['analysis']['status'] = result['status'] + s['analysis']['reassessed_at'] = result['reassessed_at'] + + print(f"损{result['stop_loss']} 盈{result['take_profit']} 区{result['entry_low']}~{result['entry_high']}") + + json.dump(pf, open(PF, 'w'), ensure_ascii=False, indent=2) + print(f"\n持仓策略已更新: {len(pf['holdings'])} 条") + + # 自选股 - 简单重新计算买入区 + wl = json.load(open(WL)) + updated = 0 + for s in wl['stocks']: + code = s['code'] + price = s.get('price', 0) + if not price: + continue + tech = None + try: + tech = full_analysis(code) + except: + pass + + # 买入区 = 弱支撑~弱压力 + if tech: + sr = tech.get('support_resistance', {}) + ws = sr.get('weak_support') or price * 0.95 + wr = sr.get('weak_resist') or price * 1.05 + else: + ws = price * 0.92 + wr = price * 1.08 + + if 'analysis' not in s: + s['analysis'] = {} + s['analysis']['buy_low'] = round(ws, 2) + s['analysis']['buy_high'] = round(wr, 2) + if tech: + s['analysis']['tech_levels'] = { + 'strong_support': sr.get('strong_support'), + 'weak_support': sr.get('weak_support'), + 'weak_resist': sr.get('weak_resist'), + 'strong_resist': sr.get('strong_resist'), + } + updated += 1 + print(f" {s['name']}({code}) 买入区={ws:.2f}~{wr:.2f}") + + json.dump(wl, open(WL, 'w'), ensure_ascii=False, indent=2) + print(f"\n自选策略已更新: {updated} 条") + print("\n✅ 全部策略再生完成") + +if __name__ == '__main__': + main() diff --git a/src/server.py b/src/server.py new file mode 100644 index 0000000..6a0f9d0 --- /dev/null +++ b/src/server.py @@ -0,0 +1,844 @@ +#!/usr/bin/env python3 +"""MoFin Dashboard - 莫荷持仓情报可视化系统""" + +import base64 +import json +import os +import re +import uuid +import urllib.request +from datetime import datetime +from pathlib import Path + +from flask import Flask, jsonify, send_from_directory, request + +app = Flask(__name__, static_folder="static", static_url_path="") + +DATA_DIR = Path(__file__).parent / "data" +UPLOAD_DIR = Path(__file__).parent / "uploads" + +# Hermes Gateway +GATEWAY = "http://localhost:8642/v1/chat/completions" +API_KEY = "hermes123" + + +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): + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + +# ── API 路由 ────────────────────────────────────────── + +@app.route("/") +def index(): + return send_from_directory(app.static_folder, "index.html") + + +@app.route("/api/portfolio") +def api_portfolio(): + """持仓列表""" + data = _load_json(DATA_DIR / "portfolio.json") + return jsonify(data) + + +@app.route("/api/watchlist") +def api_watchlist(): + """自选列表""" + data = _load_json(DATA_DIR / "watchlist.json") + return jsonify(data) + + +@app.route("/api/overview") +def api_overview(): + """概览数据""" + portfolio = _load_json(DATA_DIR / "portfolio.json", []) + market = _load_json(DATA_DIR / "market.json", {}) + alerts = _load_json(DATA_DIR / "alerts.json", []) + + total_assets = portfolio.get("total_assets", 0) + stock_value = portfolio.get("stock_value", 0) + cash = portfolio.get("cash", 0) + position_pct = portfolio.get("position_pct", 0) + total_pnl = portfolio.get("total_pnl", 0) + holdings = portfolio.get("holdings", []) + + top_movers = sorted( + [h for h in holdings if abs(h.get("change_pct", 0)) >= 3], + key=lambda x: abs(x.get("change_pct", 0)), + reverse=True, + )[:5] + + return jsonify({ + "total_assets": total_assets, + "stock_value": stock_value, + "cash": cash, + "position_pct": position_pct, + "total_pnl": total_pnl, + "top_movers": top_movers, + "market": market, + "alerts": alerts[:10], + "updated_at": portfolio.get("updated_at", ""), + }) + + +@app.route("/api/reports") +def api_reports(): + """历史报告列表""" + reports_dir = DATA_DIR / "reports" + reports = [] + if reports_dir.exists(): + for f in sorted(reports_dir.iterdir(), reverse=True)[:100]: + if f.suffix == ".json": + data = _load_json(f) + reports.append({ + "id": f.stem, + "title": data.get("title", f.stem), + "type": data.get("type", "未知"), + "created_at": data.get("created_at", ""), + "summary": data.get("summary", ""), + }) + return jsonify(reports) + + +@app.route("/api/report/") +def api_report(report_id): + """单个报告详情""" + # Try exact file first + path = DATA_DIR / "reports" / f"{report_id}.json" + if path.exists(): + return jsonify(_load_json(path)) + # Try prefix match + reports_dir = DATA_DIR / "reports" + if reports_dir.exists(): + for f in reports_dir.iterdir(): + if f.stem.startswith(report_id) and f.suffix == ".json": + return jsonify(_load_json(f)) + return jsonify({"error": "report not found"}), 404 + + +@app.route("/api/stock/") +def api_stock(code): + """个股详情 + 操作建议历史""" + stock_data = _load_json(DATA_DIR / "stocks" / f"{code}.json", {}) + return jsonify(stock_data) + + +@app.route("/api/market") +def api_market(): + """市场观察""" + data = _load_json(DATA_DIR / "market.json", {}) + return jsonify(data) + + +# ── 数据写入API(供 cron/update_data.py 调用) ────────── + +@app.route("/api/update/portfolio", methods=["POST"]) +def update_portfolio(): + data = request.get_json(force=True) + _save_json(DATA_DIR / "portfolio.json", data) + return jsonify({"status": "ok"}) + + +@app.route("/api/update/watchlist", methods=["POST"]) +def update_watchlist(): + data = request.get_json(force=True) + _save_json(DATA_DIR / "watchlist.json", data) + return jsonify({"status": "ok"}) + + +@app.route("/api/update/report", methods=["POST"]) +def update_report(): + data = request.get_json(force=True) + report_id = data.pop("_id", datetime.now().strftime("%Y%m%d_%H%M%S")) + data["created_at"] = data.get("created_at", datetime.now().isoformat()) + _save_json(DATA_DIR / "reports" / f"{report_id}.json", data) + return jsonify({"status": "ok", "id": report_id}) + + +@app.route("/api/update/stock/", methods=["POST"]) +def update_stock(code): + data = request.get_json(force=True) + existing = _load_json(DATA_DIR / "stocks" / f"{code}.json", {}) + history = existing.get("history", []) + if data.get("entry"): + history.append({ + "time": datetime.now().isoformat(), + "price": data.get("price"), + "recommendation": data.get("recommendation"), + "stop_loss": data.get("stop_loss"), + "take_profit": data.get("take_profit"), + "reason": data.get("reason"), + }) + existing.update(data) + existing["history"] = history[-50:] + _save_json(DATA_DIR / "stocks" / f"{code}.json", existing) + return jsonify({"status": "ok"}) + + +@app.route("/api/update/market", methods=["POST"]) +def update_market(): + data = request.get_json(force=True) or {} + _save_json(DATA_DIR / "market.json", data) + return jsonify({"status": "ok"}) + + +# ── 知微分析结果写入API ── +@app.route("/api/analysis/batch", methods=["POST"]) +def analysis_batch(): + """接收知微cron的分析结果,写回持仓/自选JSON的analysis字段""" + data = request.get_json(force=True) or {} + + # 更新持仓 + if "holdings" in data: + pf = _load_json(DATA_DIR / "portfolio.json", {}) + idx = {h["code"]: i for i, h in enumerate(pf.get("holdings", []))} + for item in data["holdings"]: + code = item.get("code", "") + if code not in idx: + continue + h = pf["holdings"][idx[code]] + h["analysis"] = { + "suggestion": item.get("suggestion"), + "stop_loss": item.get("stop_loss"), + "take_profit": item.get("take_profit"), + "buy_zone_low": item.get("buy_zone_low"), + "buy_zone_high": item.get("buy_zone_high"), + "position_suggested": item.get("position_suggested"), + "reason": item.get("reason"), + "updated_at": datetime.now().isoformat(), + } + _save_json(DATA_DIR / "portfolio.json", pf) + + # 更新自选 + if "watchlist" in data: + wl = _load_json(DATA_DIR / "watchlist.json", {}) + idx = {s["code"]: i for i, s in enumerate(wl.get("stocks", []))} + for item in data["watchlist"]: + code = item.get("code", "") + if code not in idx: + continue + s = wl["stocks"][idx[code]] + s["analysis"] = { + "buy_low": item.get("buy_low"), + "buy_high": item.get("buy_high"), + "position_recommend": item.get("position_recommend"), + "reason": item.get("reason"), + "updated_at": datetime.now().isoformat(), + } + _save_json(DATA_DIR / "watchlist.json", wl) + + return jsonify({"status": "ok", "updated_at": datetime.now().isoformat()}) + + +# ── 操作决策库API ── +@app.route("/api/decisions", methods=["GET"]) +def get_decisions(): + """返回决策库数据,统一新旧格式""" + raw = _load_json(DATA_DIR / "decisions.json", {"decisions": []}) + decisions = raw.get("decisions", []) + if not decisions and isinstance(raw, list): + decisions = raw + + # portfolio 用来判断是持仓还是自选 + portfolio = _load_json(DATA_DIR / "portfolio.json", {"holdings": []}) + watchlist = _load_json(DATA_DIR / "watchlist.json", {"stocks": []}) + holding_codes = {h.get("code","") for h in portfolio.get("holdings",[])} + watch_codes = {s.get("code","") for s in watchlist.get("stocks",[])} + + normalized = [] + for d in decisions: + if not isinstance(d, dict): + continue + + # 检测新旧格式:新格式有 stop_loss 顶层字段,旧格式有 trigger 对象 + is_new = "stop_loss" in d and "trigger" not in d + + if is_new: + code = d.get("code", "") + name = d.get("name", "") + price = d.get("price", 0) + sl = d.get("stop_loss") + tp = d.get("take_profit") + el = d.get("entry_low") + eh = d.get("entry_high") + ts = d.get("tech_snapshot", "") + + # type: 持仓还是自选 + if code in holding_codes: + dtype = "持仓策略" + elif code in watch_codes: + dtype = "自选策略" + else: + dtype = "—" + + # 判断 active + status_raw = d.get("status", "") + status = "active" if status_raw in ("active", "updated", "") else "superseded" + + # trigger 对象 + entry_zone_str = "" + if el and eh: + entry_zone_str = f"¥{el}~¥{eh}" + elif el: + entry_zone_str = f"≥¥{el}" + + trigger = {} + if sl: + trigger["stop_loss"] = f"¥{sl}" if isinstance(sl, (int,float)) else str(sl) + if tp: + trigger["take_profit"] = f"¥{tp}" if isinstance(tp, (int,float)) else str(tp) + if entry_zone_str: + trigger["entry_zone"] = entry_zone_str + + # current + current = "" + if price: + current = f"现价¥{price}" if code and not code.startswith(("0","1")) else f"¥{price}" + + # zone_breach + zone_breach = d.get("zone_breach", "") + + # updated_reason + note = d.get("note", "") + timing = d.get("timing_signal", "") + reason_parts = [] + if note: + reason_parts.append(note) + if timing and timing != "neutral": + reason_parts.append(f"时机:{timing}") + if d.get("rr_ratio"): + reason_parts.append(f"盈亏比:{d['rr_ratio']}") + + # advice_timeline - 从新格式重建 + timeline = [] + + entry = { + "code": code, + "name": name, + "type": dtype, + "status": status, + "tag": d.get("tag", ""), + "action": d.get("action", ""), + "trigger": trigger, + "current": current, + "zone_breach": zone_breach, + "updated_reason": " | ".join(reason_parts) if reason_parts else "", + "advice_timeline": timeline, + "changelog": d.get("changelog", []), + "execution": d.get("execution", {}), + "analysis": d.get("analysis", {}), + "tech_snapshot": ts, + "timestamp": d.get("timestamp", ""), + "updated_by": "知微", + } + # 保留原始数据供前端扩展 + entry["_raw_action"] = d.get("action", "") + normalized.append(entry) + else: + # 旧格式:已有 trigger 等字段,直接保留 + entry = dict(d) + # 确保 status 正确 + if entry.get("status") not in ("active", "superseded"): + entry["status"] = "active" + if not entry.get("type"): + code = entry.get("code", "") + if code in holding_codes: + entry["type"] = "持仓策略" + elif code in watch_codes: + entry["type"] = "自选策略" + else: + entry["type"] = "—" + normalized.append(entry) + + # 添加 execution 和 analysis 信息,按执行状态排序 + for n in normalized: + code = n.get("code", "") + # 从原始数据中找到 execution 和 analysis + raw_entry = next((d for d in decisions if isinstance(d, dict) and d.get("code") == code), {}) + n["execution"] = raw_entry.get("execution", {"status": "none"}) + n["analysis"] = raw_entry.get("analysis", {}) + + # 排序规则:推荐>执行中>观察>无标签 + def sort_key(x): + tag = x.get("tag", "") + exec_status = x.get("execution", {}).get("status", "none") + # 标签优先级(current_recommend才靠前,active_manual只是记录不升序) + tag_order = {"current_recommend": 0} + tag_priority = tag_order.get(tag, 50) + # 执行状态优先级 + exec_order = {"partial_exit": 0, "executing": 1, "observing": 2, "none": 99} + exec_priority = exec_order.get(exec_status, 99) + # 组合:先按标签排,再按执行状态排 + return (tag_priority, exec_priority, x.get("code", "")) + + normalized.sort(key=sort_key) + + return jsonify({ + "decisions": normalized, + "total": len(normalized), + "regenerated_at": raw.get("regenerated_at", ""), + }) + + +@app.route("/api/decisions/add", methods=["POST"]) +def add_decision(): + """新增/更新一条决策(新格式)""" + data = request.get_json(force=True) or {} + code = data.get("code", "") + if not code: + return jsonify({"status": "error", "message": "code required"}), 400 + + d = _load_json(DATA_DIR / "decisions.json", {"decisions": []}) + + # 同一股票旧决策标记为superseded + for e in d["decisions"]: + if e["code"] == code and e.get("status") in ("active", "updated"): + e["status"] = "superseded" + + entry = { + "code": code, + "name": data.get("name", ""), + "price": data.get("price", 0), + "action": data.get("action", ""), + "stop_loss": data.get("stop_loss"), + "take_profit": data.get("take_profit"), + "entry_low": data.get("entry_low"), + "entry_high": data.get("entry_high"), + "tech_snapshot": data.get("tech_snapshot", ""), + "timing_signal": data.get("timing_signal", ""), + "rr_ratio": data.get("rr_ratio"), + "tag": data.get("tag", ""), + "note": data.get("note", ""), + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"), + "updated_reason": data.get("updated_reason", ""), + "status": "updated", + "changelog": data.get("changelog", []), + "execution": data.get("execution", {"status": "none"}), + "analysis": data.get("analysis", {}), + } + d["decisions"].append(entry) + _save_json(DATA_DIR / "decisions.json", d) + return jsonify({"status": "ok", "entry": entry}) + + +@app.route("/api/decisions/tag", methods=["POST"]) +def set_decision_tag(): + """设置/清除某只股票的推荐标签""" + data = request.get_json(force=True) or {} + code = data.get("code", "") + tag = data.get("tag", "") # 'current_recommend', 'active_manual', or '' to clear + if not code: + return jsonify({"status": "error", "message": "code required"}), 400 + + d = _load_json(DATA_DIR / "decisions.json", {"decisions": []}) + found = False + for e in d.get("decisions", []): + if e.get("code") == code: + e["tag"] = tag + e["tag_updated"] = datetime.now().isoformat() + found = True + break + + if not found: + return jsonify({"status": "error", "message": f"stock {code} not found"}), 404 + + _save_json(DATA_DIR / "decisions.json", d) + return jsonify({"status": "ok", "code": code, "tag": tag}) + + +@app.route("/api/decisions/pending") +def get_pending_decisions(): + """返回所有有未确认建议的条目""" + d = _load_json(DATA_DIR / "decisions.json", {"decisions": []}) + pending = [] + for entry in d["decisions"]: + timeline = entry.get("advice_timeline", []) + unconfirmed = [a for a in timeline if a.get("status") in (None, "pending")] + if unconfirmed: + pending.append({ + "code": entry["code"], + "name": entry["name"], + "current": entry.get("current", ""), + "pending_advice": unconfirmed, + }) + return jsonify(pending) + + +@app.route("/api/advice/record", methods=["POST"]) +def record_advice(): + """记录一条分析建议到 decisions.json 的 advice_timeline""" + data = request.get_json(force=True) or {} + code = data.get("code", "") + if not code: + return jsonify({"status": "error", "message": "code required"}), 400 + + d = _load_json(DATA_DIR / "decisions.json", {"decisions": []}) + + entry = None + for e in d["decisions"]: + if e["code"] == code and e["status"] == "active": + entry = e + break + + if not entry: + return jsonify({"status": "error", "message": f"no active decision for {code}"}), 404 + + timeline = entry.setdefault("advice_timeline", []) + advice = { + "date": datetime.now().strftime("%Y-%m-%d %H:%M"), + "direction": data.get("direction", "持有"), + "price": data.get("price", ""), + "summary": data.get("summary", ""), + "status": "pending", + } + timeline.append(advice) + _save_json(DATA_DIR / "decisions.json", d) + return jsonify({"status": "ok", "advice": advice}) + + +@app.route("/api/advice/confirm", methods=["POST"]) +def confirm_advice(): + """确认/忽略一条建议""" + data = request.get_json(force=True) or {} + code = data.get("code", "") + idx = data.get("index", -1) + action = data.get("action", "confirmed") # confirmed | ignored + + d = _load_json(DATA_DIR / "decisions.json", {"decisions": []}) + for e in d["decisions"]: + if e["code"] == code and e["status"] == "active": + timeline = e.get("advice_timeline", []) + if 0 <= idx < len(timeline): + timeline[idx]["status"] = action + _save_json(DATA_DIR / "decisions.json", d) + return jsonify({"status": "ok"}) + return jsonify({"status": "error", "message": "not found"}), 404 + + +# ── 准确率统计API ── +@app.route("/api/stats/accuracy") +def get_accuracy_stats(): + data = _load_json(DATA_DIR / "accuracy_stats.json", {}) + return jsonify(data) + + +# ── 策略评估API ── +@app.route("/api/evaluation") +def get_evaluation(): + """返回所有策略的双维度评估结果""" + decisions = _load_json(DECISIONS_PATH if 'DECISIONS_PATH' in dir() else DATA_DIR / "decisions.json", {"decisions": []}) + evals = [] + for d in decisions.get("decisions", []): + e = d.get("evaluation", []) + if e: + evals.append({ + "code": d["code"], + "name": d["name"], + "type": d.get("type", ""), + "current": d.get("current", ""), + "evaluations": e, + }) + return jsonify(evals) + + +@app.route("/api/evaluation/trigger", methods=["POST"]) +def trigger_evaluation(): + """手动触发策略评估""" + import subprocess + try: + r = subprocess.run( + [sys.executable, str(DATA_DIR.parent / "strategy_evaluator.py")], + capture_output=True, timeout=60, text=True, + ) + return jsonify({"status": "ok", "output": r.stdout, "error": r.stderr}) + except Exception as e: + return jsonify({"status": "error", "message": str(e)}), 500 + + +# ── 策略反馈API ── +@app.route("/api/feedback") +def get_feedback(): + data = _load_json(DATA_DIR / "strategy_feedback.json", {}) + return jsonify(data) + + +# ── 持仓截图上传与解析 ──────────────────────────────── + + +@app.route("/upload") +def upload_page(): + return send_from_directory(app.static_folder, "upload.html") + + +def _ocr_image(image_path): + """用Tesseract OCR提取图片中的文字(预处理优化中文表格识别)""" + from PIL import Image, ImageEnhance, ImageFilter + import pytesseract + + img = Image.open(image_path) + + # 预处理:放大 + 锐化 + 二值化,提升小字识别率 + w, h = img.size + if w < 2000 or h < 2000: + scale = max(2, 2000 // min(w, h)) + img = img.resize((w * scale, h * scale), Image.LANCZOS) + + # 转灰度 + img = img.convert("L") + + # 增强对比度 + enhancer = ImageEnhance.Contrast(img) + img = enhancer.enhance(2.0) + + # 锐化 + img = img.filter(ImageFilter.SHARPEN) + + # 二值化(自适应阈值) + threshold = 128 + img = img.point(lambda x: 255 if x > threshold else 0) + + # OCR:chip_sim+eng,PSM 6(统一文本块) + text = pytesseract.image_to_string( + img, + lang="chi_sim+eng", + config="--psm 6 --oem 3", + ) + return text.strip() + + +ANALYZE_PROMPT = """你是股票持仓数据分析助手。以下是用户上传的持仓/自选截图经过OCR提取的文字,请从中提取所有股票信息。 + +判断这是「持仓截图」还是「自选截图」: +- 持仓截图:每支股票有"证券数量"(持股数)、成本价、盈亏 +- 自选截图:只有股票列表和价格,没有持股数/成本 + +股票代码格式: +- A股:6位数字(如 600519, 000858, 300750) +- 港股:纯数字代码(如 0700, 3690, 1211),不带HK前缀 + +⚠️ 重要:截图顶部通常有汇总数据,如总资产、股票市值、可用资金、当日盈亏等。 +如果OCR文字中有这些汇总数字,请一并提取到JSON的summary字段中。 +不要自己计算汇总值,直接从OCR原文中提取。 + +请严格按照以下JSON格式回复,只输出JSON: + +```json +{ + "type": "portfolio" 或 "watchlist", + "summary": { + "total_assets": "总资产数字(可选,从截图中提取)", + "stock_value": "股票市值/持仓市值数字(可选,从截图中提取)", + "cash": "可用资金/现金数字(可选,从截图中提取)", + "day_pnl": "当日盈亏金额(可选,从截图中提取)" + }, + "stocks": [ + { + "code": "股票代码", + "name": "股票名称(中文)", + "price": "现价(数字)", + "shares": "持股数量(数字,持仓截图才有)", + "cost": "成本价(数字,持仓截图才有)", + "pnl": "盈亏百分比如+15.1%(持仓截图才有)", + "position_pct": "仓位占比数字如12.5(可选)" + } + ] +} +``` + +OCR原文: +""" + + +@app.route("/api/upload/analyze", methods=["POST"]) +def upload_analyze(): + """接收图片,OCR提取文字 → LLM解析结构化数据""" + if "image" not in request.files: + return jsonify({"error": "请上传图片"}), 400 + + f = request.files["image"] + if not f.filename: + return jsonify({"error": "空文件"}), 400 + + # 保存到临时目录 + UPLOAD_DIR.mkdir(parents=True, exist_ok=True) + ext = Path(f.filename).suffix or ".png" + save_path = UPLOAD_DIR / f"{uuid.uuid4().hex}{ext}" + f.save(str(save_path)) + + try: + # 第一步:OCR提取文字 + raw_text = _ocr_image(str(save_path)) + if not raw_text: + return jsonify({"error": "OCR未识别到文字,请确认图片清晰"}), 400 + except Exception as e: + os.unlink(str(save_path)) + return jsonify({"error": f"OCR失败: {e}"}), 500 + + # 第二步:LLM解析结构化数据(走文本API,不走视觉) + llm_text = _llm_parse(raw_text, ANALYZE_PROMPT) + + os.unlink(str(save_path)) + + # 从LLM回复中提取JSON + json_match = re.search(r"```(?:json)?\s*({.*?})\s*```", llm_text, re.DOTALL) + if json_match: + try: + parsed = json.loads(json_match.group(1)) + except json.JSONDecodeError: + return jsonify({"error": f"LLM解析JSON失败: {llm_text[:500]}"}), 500 + else: + # 尝试直接找JSON(没被代码块包裹) + try: + parsed = json.loads(llm_text) + except json.JSONDecodeError: + return jsonify({"error": f"未提取到结构化数据: {raw_text[:300]}...\n\nLLM回复: {llm_text[:500]}"}), 500 + + return jsonify(parsed) + + +def _llm_parse(text, prompt_template): + """发送OCR文本到Hermes LLM解析,返回JSON字符串""" + payload = json.dumps({ + "model": "hermes-agent", + "messages": [ + {"role": "system", "content": "你是一个数据提取助手。从OCR文字中提取结构化JSON数据。"}, + {"role": "user", "content": prompt_template + "\n" + text}, + ], + "max_tokens": 4096, + }).encode() + + req = urllib.request.Request(GATEWAY, data=payload, method="POST") + req.add_header("Content-Type", "application/json") + req.add_header("Authorization", f"Bearer {API_KEY}") + req.add_header("X-Hermes-Session-Id", "upload-ocr-parse") + + try: + resp = urllib.request.urlopen(req, timeout=120) + data = json.loads(resp.read()) + return data.get("choices", [{}])[0].get("message", {}).get("content", "") + except Exception as e: + return f"ERROR: {e}" + + +@app.route("/api/upload/confirm", methods=["POST"]) +def upload_confirm(): + """确认解析结果,更新数据文件""" + data = request.get_json(force=True) + stocks = data.get("stocks", []) + doc_type = data.get("type", "portfolio") + + # 尝试获取实时行情补充数据 + try: + codes = [s["code"] for s in stocks if s.get("code")] + if codes: + qs = " ".join( + f"hk{c}" if len(c) == 5 # 港股5位代码 + else f"sz{c}" if c.startswith("0") or c.startswith("3") + else f"sh{c}" if c.startswith("6") + else f"hk{c}" + for c in codes + ) + url = f"https://qt.gtimg.cn/q={qs}" + req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) + resp = urllib.request.urlopen(req, timeout=10) + qt_text = resp.read().decode("gbk", errors="replace") + # map realtime prices + for stock in stocks: + code = stock.get("code", "") + prefix = "hk" if len(code) == 5 else "sz" if code.startswith(("0","3")) else "sh" if code.startswith("6") else "hk" + # 腾讯 API 格式: prefix+code="市场~名称~代码~当前价~昨收~今开~成交量~..." + m = re.search(rf'{prefix}{code}="([^"]+)"', qt_text) + if m: + fields = m.group(1).split('~') + name = fields[1] + price = fields[3] # 当前价 + if not stock.get("price"): + stock["price"] = price + if not stock.get("name"): + stock["name"] = name + except: + pass # 行情获取失败不影响主流程 + + # 更新对应数据文件 + if doc_type == "portfolio": + existing = _load_json(DATA_DIR / "portfolio.json", {}) + old_holdings = {h["code"]: h for h in existing.get("holdings", []) if h.get("code")} + new_holdings = [] + for s in stocks: + code = s.get("code", "") + old = old_holdings.get(code, {}) + new_shares = int(s["shares"]) if str(s.get("shares", "")).lstrip('-').isdigit() else old.get("shares", 0) + old_shares = old.get("shares", 0) + # 股数突变检测:旧200→新0是合理卖出,但旧0→新200可能是OCR错读 + if old_shares > 0 and new_shares == 0 and old_shares != new_shares: + print(f"[仓位变动] {code} {s.get('name','')}: {old_shares}→{new_shares} (卖出清仓)") + elif abs(new_shares - old_shares) > max(old_shares * 0.5, 100) and old_shares > 0: + print(f"[仓位变动] {code} {s.get('name','')}: {old_shares}→{new_shares} (变动较大)") + new_holdings.append({ + "code": code, + "name": s.get("name") or old.get("name", ""), + "shares": new_shares, + "price": float(s.get("price", 0)) or old.get("price", 0), + "cost": float(s.get("cost", 0)) if s.get("cost") else old.get("cost", 0), + "pnl": s.get("pnl") or old.get("pnl", ""), + "position_pct": float(s.get("position_pct", 0)) if s.get("position_pct") else old.get("position_pct", 0), + "change_pct": old.get("change_pct", 0), + }) + existing["holdings"] = new_holdings + + # 使用截图中的汇总数据(优先),没有则用旧数据 + summary = data.get("summary", {}) + if summary.get("stock_value"): + existing["stock_value"] = float(summary["stock_value"]) + else: + existing["stock_value"] = round( + sum(h["shares"] * h["price"] for h in existing["holdings"]), 2 + ) + if summary.get("cash"): + existing["cash"] = float(summary["cash"]) + if summary.get("total_assets"): + existing["total_assets"] = float(summary["total_assets"]) + else: + existing["total_assets"] = existing["stock_value"] + existing.get("cash", 0) + if summary.get("day_pnl"): + existing["day_pnl"] = float(summary["day_pnl"]) + existing["updated_at"] = datetime.now().isoformat() + # 计算仓位% + if existing["total_assets"] > 0: + existing["position_pct"] = round(existing["stock_value"] / existing["total_assets"] * 100, 2) + _save_json(DATA_DIR / "portfolio.json", existing) + msg = f"更新了 {len(stocks)} 只持仓股" + + elif doc_type == "watchlist": + existing = _load_json(DATA_DIR / "watchlist.json", {}) + existing["stocks"] = [ + { + "code": s.get("code", ""), + "name": s.get("name", ""), + "price": float(s.get("price", 0)) if s.get("price") else 0, + } + for s in stocks + ] + existing["updated_at"] = datetime.now().isoformat() + _save_json(DATA_DIR / "watchlist.json", existing) + msg = f"更新了 {len(stocks)} 只自选股" + + else: + return jsonify({"error": f"未知类型: {doc_type}"}), 400 + + return jsonify({"status": "ok", "message": msg}) + + +if __name__ == "__main__": + port = int(os.environ.get("PORT", 8899)) + print(f"🚀 MoFin Dashboard → http://0.0.0.0:{port}") + app.run(host="0.0.0.0", port=port, debug=False) \ No newline at end of file diff --git a/src/strategy_evaluator.py b/src/strategy_evaluator.py new file mode 100644 index 0000000..d255879 --- /dev/null +++ b/src/strategy_evaluator.py @@ -0,0 +1,318 @@ +#!/usr/bin/env python3 +"""strategy_evaluator.py — 策略双维度评估引擎 + +两阶段评估模型: + +阶段一(策略制定→价格达标): + 理论:策略设定的买入区/止损/止盈 → 股价是否达到过这些价位 → 理论盈亏 + 实际:老爸是否按策略执行 → 实际买入/卖出价格 → 实际盈亏 + +阶段二(价格回落后→新止损验证): + 理论:价格未按预期走 → 给出新止损 → 股价是否继续下跌验证止损正确性 + 实际:老爸实际卖出价格 → 对比新止损 → 验证止损有效性 + +输出:写入 decisions.json 的 evaluation 字段 + accuracy_stats.json +""" +import json +import urllib.request +import os +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" + +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_prices(codes): + """批量拉腾讯行情""" + if not codes: + return {} + symbols = [] + code_map = {} + for c in codes: + sym = f"hk{c}" if len(c) == 5 else f"sh{c}" if c.startswith(("5", "6", "9")) else f"sz{c}" + symbols.append(sym) + code_map[sym] = c + url = f"http://qt.gtimg.cn/q={','.join(symbols)}" + 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 {} + prices = {} + 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_") + orig = code_map.get(sym) + if not orig: + continue + prices[orig] = { + "name": fields[1], + "price": float(fields[3]) if fields[3] else 0, + "prev_close": float(fields[4]) if fields[4] else 0, + "change_pct": float(fields[32]) if fields[32] else 0, + "high": float(fields[33]) if fields[33] else 0, + "low": float(fields[34]) if fields[34] else 0, + } + return prices + + +def evaluate_phase1(decision, price_info, holding): + """ + 阶段一评估:策略制定→价格是否达到过目标价位 + 返回 evaluation dict + """ + trig = decision.get("trigger", {}) + code = decision["code"] + name = decision.get("name", code) + price = price_info.get("price", 0) + change = price_info.get("change_pct", 0) + + # 策略区间 + el = trig.get("entry_zone", "") + sl = trig.get("stop_loss", "") + tp = trig.get("take_profit", "") + + el_low = el_high = None + if el and "~" in str(el): + try: + parts = str(el).split("~") + el_low, el_high = float(parts[0]), float(parts[1]) + except: + pass + sl_p = float(sl) if sl else None + tp_p = float(tp) if tp else None + + # 持仓信息 + cost = holding.get("cost", 0) if holding else 0 + shares = holding.get("shares", 0) if holding else 0 + position_pct = holding.get("position_pct", 0) if holding else 0 + + # 理论盈亏计算(基于策略区间中值) + entry_mid = (el_low + el_high) / 2 if el_low and el_high else price + theoretical_pnl_pct = (tp_p - entry_mid) / entry_mid * 100 if tp_p and entry_mid else 0 + theoretical_pnl_amount = theoretical_pnl_pct / 100 * entry_mid * (shares or 100) / 100 if shares else 0 + + # 实际盈亏 + actual_pnl_pct = (price - cost) / cost * 100 if cost > 0 and price > 0 else 0 + actual_pnl_amount = actual_pnl_pct / 100 * cost * shares if cost > 0 and shares > 0 else 0 + + # 当前状态判断 + status = "safe" + if sl_p and price > 0 and price <= sl_p: + status = "stop_loss_hit" + elif tp_p and price > 0 and price >= tp_p: + status = "take_profit_hit" + elif el_low and el_high and price > 0 and el_low <= price <= el_high: + status = "in_entry_zone" + elif el_low and price > 0 and price < el_low: + status = "below_entry" + elif el_high and price > 0 and price > el_high: + status = "above_entry" + + # 理论阶段评估 + theoretical = { + "entry_zone": f"{el_low}~{el_high}" if el_low else "N/A", + "stop_loss": sl_p, + "take_profit": tp_p, + "entry_mid_price": round(entry_mid, 2), + "target_price": tp_p, + "theoretical_pnl_pct": round(theoretical_pnl_pct, 2), + "theoretical_pnl_amount": round(theoretical_pnl_amount, 2), + "status": status, + "current_price": price, + "current_change_pct": change, + } + + # 实际阶段评估 + actual = { + "cost_price": cost, + "shares": shares, + "position_pct": position_pct, + "actual_pnl_pct": round(actual_pnl_pct, 2), + "actual_pnl_amount": round(actual_pnl_amount, 2), + "status": status, + "current_price": price, + } + + return { + "code": code, + "name": name, + "evaluated_at": datetime.now().isoformat(), + "phase": 1, + "theoretical": theoretical, + "actual": actual, + "summary": f"{name}({code}) 策略区间{el_low}~{el_high} 现价{price}({change:+.2f}%) " + f"理论盈亏{theoretical_pnl_pct:+.1f}% 实际盈亏{actual_pnl_pct:+.1f}%", + } + + +def evaluate_phase2(decision, price_info, holding, prev_eval): + """ + 阶段二评估:价格回落后→新止损验证 + 需要 prev_eval 中记录了之前的目标价和新止损价 + """ + trig = decision.get("trigger", {}) + code = decision["code"] + name = decision.get("name", code) + price = price_info.get("price", 0) + + sl = trig.get("stop_loss", "") + sl_p = float(sl) if sl else None + + # 从 prev_eval 中获取阶段一的止损 + prev_sl = None + if prev_eval: + prev_sl = prev_eval.get("theoretical", {}).get("stop_loss") + + # 检查新止损是否被跌破 + new_sl_hit = False + days_to_hit = None + if sl_p and price > 0 and price <= sl_p: + new_sl_hit = True + # 无法精确知道多少天跌破,标记为当前 + days_to_hit = 0 + + result = { + "code": code, + "name": name, + "evaluated_at": datetime.now().isoformat(), + "phase": 2, + "new_stop_loss": sl_p, + "previous_stop_loss": prev_sl, + "current_price": price, + "new_sl_hit": new_sl_hit, + "days_to_hit": days_to_hit, + "summary": f"{name}({code}) 新止损{sl_p} {'已跌破' if new_sl_hit else '未触及'} 现价{price}", + } + return result + + +def run(): + decisions = load_json(DECISIONS_PATH, {"decisions": []}) + portfolio = load_json(PORTFOLIO_PATH, {"holdings": []}) + holdings_map = {h["code"]: h for h in portfolio.get("holdings", [])} + + # 收集所有代码 + all_codes = [d["code"] for d in decisions["decisions"]] + prices = fetch_prices(all_codes) + + results = [] + stats = { + "phase1_correct": 0, "phase1_wrong": 0, "phase1_pending": 0, + "phase2_correct": 0, "phase2_wrong": 0, "phase2_pending": 0, + } + + for d in decisions["decisions"]: + code = d["code"] + pi = prices.get(code, {}) + h = holdings_map.get(code) + + # 获取已有的 evaluation 记录 + existing_eval = d.get("evaluation", []) + + # 阶段一评估 + eval1 = evaluate_phase1(d, pi, h) + results.append(eval1) + + # 阶段二评估(如果有前次止损记录) + prev_eval = existing_eval[-1] if existing_eval else None + if prev_eval and prev_eval.get("phase") == 1: + eval2 = evaluate_phase2(d, pi, h, prev_eval) + results.append(eval2) + + # 更新 decisions.json 的 evaluation 字段 + d["evaluation"] = [e for e in [eval1] + ([eval2] if prev_eval and prev_eval.get("phase") == 1 else [])] + + # 保存更新 + save_json(DECISIONS_PATH, decisions) + + # 汇总统计 + for r in results: + phase = r["phase"] + if phase == 1: + status = r["theoretical"]["status"] + if status in ("take_profit_hit",): + stats["phase1_correct"] += 1 + elif status in ("stop_loss_hit",): + stats["phase1_wrong"] += 1 + else: + stats["phase1_pending"] += 1 + elif phase == 2: + if r.get("new_sl_hit"): + stats["phase2_correct"] += 1 + else: + stats["phase2_pending"] += 1 + + # 写入 accuracy_stats + accuracy = { + "updated_at": datetime.now().isoformat(), + "phase1": { + "correct": stats["phase1_correct"], + "wrong": stats["phase1_wrong"], + "pending": stats["phase1_pending"], + "accuracy_pct": round(stats["phase1_correct"] / max(stats["phase1_correct"] + stats["phase1_wrong"], 1) * 100, 1), + }, + "phase2": { + "correct": stats["phase2_correct"], + "wrong": stats["phase2_wrong"], + "pending": stats["phase2_pending"], + "accuracy_pct": round(stats["phase2_correct"] / max(stats["phase2_correct"] + stats["phase2_wrong"], 1) * 100, 1), + }, + "total_evaluated": len(results), + "details": [r["summary"] for r in results], + } + save_json(ACCURACY_PATH, accuracy) + + # 输出报告 + print("=" * 70) + print(f"策略双维度评估报告 | {datetime.now().strftime('%Y-%m-%d %H:%M')}") + print("=" * 70) + + print(f"\n📊 阶段一(策略制定→价格达标)") + print(f" 正确(达到止盈): {stats['phase1_correct']}") + print(f" 错误(跌破止损): {stats['phase1_wrong']}") + print(f" 待验证: {stats['phase1_pending']}") + print(f" 准确率: {accuracy['phase1']['accuracy_pct']}%") + + print(f"\n📊 阶段二(价格回落→新止损验证)") + print(f" 正确(新止损验证有效): {stats['phase2_correct']}") + print(f" 错误: {stats['phase2_wrong']}") + print(f" 待验证: {stats['phase2_pending']}") + + print(f"\n📋 逐股评估:") + for r in results: + print(f" {r['summary']}") + + print(f"\n✅ 评估完成,已写入 decisions.json 和 accuracy_stats.json") + + +if __name__ == "__main__": + run() diff --git a/src/strategy_feedback.py b/src/strategy_feedback.py new file mode 100644 index 0000000..a373fb5 --- /dev/null +++ b/src/strategy_feedback.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +"""strategy_feedback.py — 策略评估反馈引擎 + +从评估结果自动推导策略调整建议: +1. 阶段一完成(达到止盈)→ 标记成功,萃取经验 +2. 阶段一失败(跌破止损)→ 标记失败,分析原因 +3. 阶段二验证(新止损被跌破)→ 标记止损正确 +4. 长期未触及任何区间 → 建议重新评估策略区间 +5. 准确率趋势 → 调整策略参数(宽度、区间计算方法) + +输出:写入 decisions.json + 生成调整建议报告 +""" +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" +EVENTS_PATH = DATA_DIR / "price_events.json" +FEEDBACK_PATH = DATA_DIR / "strategy_feedback.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 check_phase_completion(decision, events): + """检查某条策略是否有阶段完成事件""" + code = decision["code"] + trig = decision.get("trigger", {}) + stock_events = [e for e in events.get("events", []) if e["code"] == code] + + el = trig.get("entry_zone", "") + sl = trig.get("stop_loss", "") + tp = trig.get("take_profit", "") + + el_low = el_high = None + if el and "~" in str(el): + try: + parts = str(el).split("~") + el_low, el_high = float(parts[0]), float(parts[1]) + except: + pass + sl_p = float(sl) if sl else None + tp_p = float(tp) if tp else None + + result = { + "phase1_completed": False, + "phase1_result": None, # "success" or "failure" + "phase1_completed_at": None, + "phase1_price_at_completion": None, + "phase2_completed": False, + "phase2_result": None, + "phase2_completed_at": None, + "days_in_phase1": None, + } + + # 检查价格事件中是否有止盈/止损触发 + for ev in stock_events: + ev_type = ev.get("event_type", "") + ev_price = ev.get("price", 0) + ev_time = ev.get("timestamp", "") + + if ev_type == "stop_loss" and sl_p and ev_price <= sl_p: + result["phase1_completed"] = True + result["phase1_result"] = "failure" + result["phase1_completed_at"] = ev_time + result["phase1_price_at_completion"] = ev_price + + # 止盈:检查是否达到或超过止盈价 + if tp_p and ev_price >= tp_p: + result["phase1_completed"] = True + result["phase1_result"] = "success" + result["phase1_completed_at"] = ev_time + result["phase1_price_at_completion"] = ev_price + + return result + + +def compute_accuracy_trend(accuracy_stats): + """计算准确率趋势,用于调整策略参数""" + details = accuracy_stats.get("details", []) + if not details: + return {"trend": "stable", "phase1_accuracy": 0, "phase2_accuracy": 0} + + phase1 = accuracy_stats.get("phase1", {}) + phase2 = accuracy_stats.get("phase2", {}) + + p1_acc = phase1.get("accuracy_pct", 0) + p2_acc = phase2.get("accuracy_pct", 0) + + if p1_acc >= 80: + trend = "improving" + elif p1_acc <= 50 and phase1.get("correct", 0) + phase1.get("wrong", 0) >= 3: + trend = "declining" + else: + trend = "stable" + + return { + "trend": trend, + "phase1_accuracy": p1_acc, + "phase2_accuracy": p2_acc, + "phase1_evaluated": phase1.get("correct", 0) + phase1.get("wrong", 0), + "phase2_evaluated": phase2.get("correct", 0) + phase2.get("wrong", 0), + } + + +def generate_adjustment(decision, phase_check, accuracy_trend): + """根据评估结果生成策略调整建议""" + code = decision["code"] + name = decision.get("name", code) + trig = decision.get("trigger", {}) + adjustments = [] + + el = trig.get("entry_zone", "") + sl = trig.get("stop_loss", "") + tp = trig.get("take_profit", "") + + # 1. 阶段一完成 → 萃取经验 + if phase_check["phase1_completed"]: + if phase_check["phase1_result"] == "success": + adjustments.append({ + "type": "phase1_success", + "message": f"{name}({code}) 阶段一成功:达到止盈{tp},价格{phase_check['phase1_price_at_completion']}", + "action": "mark_completed", + "knowledge": f"策略区间{el}有效,价格达到止盈位{tp}", + }) + elif phase_check["phase1_result"] == "failure": + adjustments.append({ + "type": "phase1_failure", + "message": f"{name}({code}) 阶段一失败:跌破止损{sl},价格{phase_check['phase1_price_at_completion']}", + "action": "reassess", + "knowledge": f"策略区间{el}失效,止损{sl}被跌破,需重新评估", + }) + + # 2. 长期未触发 → 建议重新评估 + # 检查策略创建时间 + created_at = decision.get("timestamp", "") + if created_at: + try: + created = datetime.fromisoformat(created_at) + days_since = (datetime.now() - created).days + if days_since >= 14 and not phase_check["phase1_completed"]: + adjustments.append({ + "type": "stale_strategy", + "message": f"{name}({code}) 策略已{days_since}天未触发任何区间,建议重新评估", + "action": "reassess", + }) + except: + pass + + # 3. 准确率趋势 → 调整策略宽度 + if accuracy_trend["trend"] == "declining" and accuracy_trend["phase1_evaluated"] >= 3: + adjustments.append({ + "type": "accuracy_declining", + "message": f"准确率下降至{accuracy_trend['phase1_accuracy']}%,建议收紧策略区间宽度", + "action": "tighten", + }) + + return adjustments + + +def run(): + decisions = load_json(DECISIONS_PATH, {"decisions": []}) + events = load_json(EVENTS_PATH, {"events": []}) + accuracy_stats = load_json(ACCURACY_PATH, {}) + + accuracy_trend = compute_accuracy_trend(accuracy_stats) + + all_feedback = [] + phase1_completed = [] + reassess_needed = [] + + for d in decisions["decisions"]: + code = d["code"] + name = d.get("name", code) + + # 检查阶段完成情况 + phase_check = check_phase_completion(d, events) + + # 生成调整建议 + adjustments = generate_adjustment(d, phase_check, accuracy_trend) + + feedback_entry = { + "code": code, + "name": name, + "evaluated_at": datetime.now().isoformat(), + "phase_check": phase_check, + "adjustments": adjustments, + } + all_feedback.append(feedback_entry) + + # 收集需要处理的策略 + if phase_check["phase1_completed"]: + phase1_completed.append(feedback_entry) + + if any(a["action"] in ("reassess", "tighten") for a in adjustments): + reassess_needed.append(feedback_entry) + + # 保存反馈结果 + feedback_data = { + "updated_at": datetime.now().isoformat(), + "accuracy_trend": accuracy_trend, + "total_strategies": len(decisions["decisions"]), + "phase1_completed_count": len(phase1_completed), + "reassess_needed_count": len(reassess_needed), + "feedback": all_feedback, + } + save_json(FEEDBACK_PATH, feedback_data) + + # 输出报告 + print("=" * 70) + print(f"策略反馈引擎报告 | {datetime.now().strftime('%Y-%m-%d %H:%M')}") + print("=" * 70) + + print(f"\n📈 准确率趋势: {accuracy_trend['trend']}") + print(f" 阶段一准确率: {accuracy_trend['phase1_accuracy']}% ({accuracy_trend['phase1_evaluated']}次评估)") + print(f" 阶段二准确率: {accuracy_trend['phase2_accuracy']}% ({accuracy_trend['phase2_evaluated']}次评估)") + + print(f"\n✅ 阶段一已完成: {len(phase1_completed)}只") + for fb in phase1_completed: + pc = fb["phase_check"] + icon = "🟢" if pc["phase1_result"] == "success" else "🔴" + print(f" {icon} {fb['name']}({fb['code']}) {pc['phase1_result']} 于{pc['phase1_completed_at'][:19]} 价格{pc['phase1_price_at_completion']}") + + print(f"\n🔄 需重新评估: {len(reassess_needed)}只") + for fb in reassess_needed: + for adj in fb["adjustments"]: + print(f" {adj['type']}: {adj['message']}") + + if not phase1_completed and not reassess_needed: + print(f"\n 无策略需要调整,所有策略正常运行中") + + print(f"\n✅ 反馈完成,已写入 {FEEDBACK_PATH}") + + +if __name__ == "__main__": + run() diff --git a/src/strategy_lifecycle.py b/src/strategy_lifecycle.py new file mode 100644 index 0000000..2b2852e --- /dev/null +++ b/src/strategy_lifecycle.py @@ -0,0 +1,590 @@ +#!/usr/bin/env python3 +"""策略生命周期管理系统 — 技术面驱动版本 v2 + +核心原则: +1. 止损放在合理的技术位,不拍数字 +2. 新买入推荐:止损=弱支撑(约3%跌幅),止盈=强压力,盈亏比≥2:1 +3. 已持仓:止损=强支撑(约5-8%跌幅),目标=强压力 +4. 买入区间:弱支撑~弱压力之间 +5. 买入时机:量价齐跌不买,缩量至支撑买,量价齐升追买 +""" + +import json +import urllib.request +import os +import sys +from datetime import datetime +import technical_analysis as ta + +PORTFOLIO_PATH = "/home/hmo/web-dashboard/data/portfolio.json" +WATCHLIST_PATH = "/home/hmo/web-dashboard/data/watchlist.json" +KNOWLEDGE_LOG = "/home/hmo/Obsidian/knowledge/finance/analyst-knowledge-log.md" + + +def get_price_tencent(code): + """获取实时价格,自动识别A股/港股""" + try: + raw_code = code.split('_')[0] + if not raw_code: + return None + if len(raw_code) == 5 and raw_code.isdigit(): + 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 + return { + "price": f(3), "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"): + """根据技术分析重评策略""" + + 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 = {} + + profit_pct = (price - cost) / cost * 100 if cost else 0 + is_new_entry = (cost == 0) or (shares == 0) + is_deep_loss = profit_pct < -20 + + # ----- 止损设置(含最小距离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 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) + + # 最小止损距离:止损不能高于现价的97%(即至少3%下行空间) + min_stop = round(price * 0.97, 2) + if new_stop > min_stop and not is_deep_loss: + new_stop = min_stop + + # ----- 止盈设置 ----- + if sr_resist and sr_resist > 0: + new_target = round(sr_resist, 2) + else: + new_target = round(price * 1.15, 2) + + # ----- 风险回报比校验 ----- + 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 = "" + + # ----- 买入区间(有盈亏比严格约束) ----- + 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) + + # ----- 买入时机信号 ----- + volume_signal = vol.get("volume_signal", "") + candlestick_sentiment = candle.get("sentiment", "neutral") + timing_signal = "neutral" + + 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 = "接近支撑位,关注" + else: + # 已持仓时机(用于加仓/减仓参考) + if 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 = "持有" + + # ----- 构造 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_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}") + + 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, + } + + +def regenerate_all(stdout=True): + """全量重评所有持仓+自选策略""" + pf = json.load(open(PORTFOLIO_PATH)) + wl_path = WATCHLIST_PATH + wl = json.load(open(wl_path)) if os.path.exists(wl_path) else {} + + 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 json.load(open(decisions_path)).get("decisions", []) if d.get("code")} + except: + existing_decisions = {} + + 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 = get_price_tencent(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: + result = reassess_strategy( + code, name, price, cost, shares, + current_action, volume_signal="中性", sentiment=sentiment, + ) + 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", ""), + "reassessed_at": result["reassessed_at"], + "status": result["status"], + **extra, + } + stock["analysis"] = analysis + 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") + new_entry = { + "code": code, "name": name, "price": price, + "action": result["action"], + "stop_loss": result["stop_loss"], + "take_profit": result["take_profit"], + "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"], + } + + # --- 变更追踪 --- + old_entry = existing_decisions.get(code, {}) + 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 old_target 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 --- + # 只有同时满足以下条件才标记推荐: + 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 = ( + not is_deep_loss_stock + and rr >= 1.5 + and ts != "neutral" + and "不建议" not in note + and (in_buy_zone or near_buy_zone_low) + ) + 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}") + + # 写回数据文件 + json.dump(pf, open(PORTFOLIO_PATH, "w"), ensure_ascii=False, indent=2) + json.dump(wl, open(wl_path, "w"), ensure_ascii=False, indent=2) + + # 写 decisions.json + decisions_path = "/home/hmo/web-dashboard/data/decisions.json" + decisions_data = { + "decisions": decisions, # 全部保留 + "total": len(decisions), + "regenerated_at": datetime.now().strftime('%Y-%m-%d %H:%M'), + } + json.dump(decisions_data, open(decisions_path, "w"), ensure_ascii=False, indent=2) + + 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/src/sync_dashboard.py b/src/sync_dashboard.py new file mode 100644 index 0000000..263554a --- /dev/null +++ b/src/sync_dashboard.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""sync_dashboard.py — 数据同步包装脚本 +1. 运行 update_data.py +2. 检查 server 是否正常 +3. server 挂了就重启 +""" +import subprocess +import sys +import os + +DASHBOARD_DIR = "/home/hmo/web-dashboard" +SERVER_PORT = 8899 + +def run_update(): + """运行 update_data.py,返回 stdout""" + try: + r = subprocess.run( + ["python3", "update_data.py"], + cwd=DASHBOARD_DIR, + capture_output=True, text=True, timeout=60, + ) + return r.stdout.strip() if r.returncode == 0 else None + except Exception as e: + print(f"❌ update_data.py 执行失败: {e}", file=sys.stderr) + return None + +def check_server(): + """检查 server 是否正常""" + import urllib.request + try: + req = urllib.request.Request(f"http://localhost:{SERVER_PORT}", + headers={"User-Agent": "Mozilla/5.0"}) + with urllib.request.urlopen(req, timeout=5) as r: + return r.status == 200 + except: + return False + +def restart_server(): + """重启 server""" + try: + subprocess.run( + ["python3", "server.py", str(SERVER_PORT)], + cwd=DASHBOARD_DIR, + capture_output=True, timeout=10, + ) + return True + except Exception as e: + print(f"❌ server 重启失败: {e}", file=sys.stderr) + return False + +def main(): + output = run_update() + server_ok = check_server() + + if output: + # update_data.py 有输出 → 转发 + print(output) + if not server_ok: + print("⚠️ 注意:server 似乎未响应,但数据已更新") + else: + if server_ok: + # 无输出 + server 正常 → SILENT + print("[SILENT] 数据无更新,server运行正常") + else: + # server 挂了 → 重启 + print("⚠️ server 无响应,尝试重启...") + if restart_server(): + print("✅ server 已重启") + # 再检查一次 + if check_server(): + print("✅ server 恢复运行") + else: + print("❌ server 仍未响应,需人工检查") + else: + print("❌ server 重启失败") + +if __name__ == "__main__": + main() diff --git a/src/system_health_check.py b/src/system_health_check.py new file mode 100644 index 0000000..5f33999 --- /dev/null +++ b/src/system_health_check.py @@ -0,0 +1,206 @@ +#!/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/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("【价格事件】") + 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.json 无事件记录,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("【数据新鲜度】") + for name, path in [("portfolio.json", PORTFOLIO_PATH), ("decisions.json", DECISIONS_PATH)]: + if path.exists(): + mtime = datetime.fromtimestamp(path.stat().st_mtime) + hours_ago = (now - mtime).total_seconds() / 3600 + fresh = hours_ago < 24 + lines.append(check(fresh, f"{name} 更新于 {hours_ago:.0f}小时前")) + if not fresh: + issues.append(f"{name} 超过24小时未更新") + 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() diff --git a/src/technical_analysis.py b/src/technical_analysis.py new file mode 100644 index 0000000..04b2d18 --- /dev/null +++ b/src/technical_analysis.py @@ -0,0 +1,381 @@ +#!/usr/bin/env python3 +"""technical_analysis.py — 技术面分析模块 v2 + +基于多日价格数据计算支撑位/压力位: +1. 缓存每日 HLC 到 price_history.json +2. 使用 5 日最高/最低计算枢轴点 +3. 结合振幅自动调整区间宽度 + +使用方式: + from technical_analysis import full_analysis + result = full_analysis("603259") # 自动识别A股/港股 +""" + +import json +import os +import urllib.request +from datetime import datetime, date + +# 腾讯API字段索引 +F = { + "name": 1, "code": 2, "price": 3, "close_yest": 4, "open": 5, + "volume": 6, "timestamp": 30, "change": 31, "change_pct": 32, + "high": 33, "low": 34, "amplitude": 43, + "turnover": 38, "pe": 39, "pb": 46, + "limit_up": 47, "limit_down": 48, + "avg_price": 51, "inner_vol": 52, "outer_vol": 53, +} + +HISTORY_PATH = "/home/hmo/web-dashboard/data/price_history.json" +HISTORY_DAYS = 10 # 使用最近 N 天的 HLC 数据 + + +def _load_history(): + """读取价格历史缓存""" + try: + return json.load(open(HISTORY_PATH)) + except (FileNotFoundError, json.JSONDecodeError): + return {} + + +def _save_history(h): + json.dump(h, open(HISTORY_PATH, "w"), ensure_ascii=False, indent=2) + + +def _market_prefix(code): + """根据代码确定腾讯API前缀""" + if code.startswith("sh") or code.startswith("sz") or code.startswith("hk"): + code = code[2:] if code[2:].isdigit() else code + raw = str(code).split("_")[0] + if len(raw) == 5 and raw.isdigit(): + return "hk" + if raw.startswith("6") or raw.startswith("5"): + return "sh" + return "sz" + + +def get_quote(code): + """获取腾讯API行情数据""" + raw = str(code).split("_")[0] + prefix = _market_prefix(code) + url = f"http://qt.gtimg.cn/q={prefix}{raw}" + try: + r = urllib.request.urlopen(url, timeout=5) + fields = r.read().decode("gbk").split('"')[1].split("~") + except Exception as e: + return {"code": code, "error": str(e)} + + def get(i): + try: + return float(fields[i]) if fields[i].strip() else None + except (IndexError, ValueError): + return None + + today_str = date.today().isoformat() + q = { + "code": raw, + "market": prefix, + "name": fields[F["name"]] if len(fields) > F["name"] else code, + "price": get(3), + "close_yest": get(4), + "open": get(5), + "high": get(33), + "low": get(34), + "volume": get(6), + "amount": get(37), + "change": get(31), + "change_pct": get(32), + "amplitude": get(43), + "turnover_rate": get(38), + "pe": get(39), + "pb": get(46), + "limit_up": get(47), + "limit_down": get(48), + "avg_price": get(51), + "inner_vol": get(52), + "outer_vol": get(53), + "timestamp": fields[F["timestamp"]] if len(fields) > F["timestamp"] else "", + "_date": today_str, + } + + # 写入价格历史缓存(每日一次) + h = get(33) # high + l = get(34) # low + c = get(3) # price / close + if h and l and c: + history = _load_history() + if raw not in history: + history[raw] = [] + days = history[raw] + # 如果今天已有记录,更新(盘中数据更精确) + if days and days[-1].get("date") == today_str: + days[-1]["high"] = max(days[-1]["high"], h) + days[-1]["low"] = min(days[-1]["low"], l) + days[-1]["close"] = c # 盘中用最新价,收盘后是收盘价 + else: + days.append({"date": today_str, "high": h, "low": l, "close": c}) + # 只保留最近 HISTORY_DAYS 天 + history[raw] = days[-HISTORY_DAYS:] + _save_history(history) + + return q + + +def calc_support_resistance(q): + """计算技术支撑位和压力位 — 多日枢轴点算法 + + 使用多个数据源确定有效区间: + 1. 当日波幅(H-L) + 2. 最近 N 日的最高/最低(从 price_history.json 读取) + 3. 价格基数的百分比(对大市值低波动股票有效) + """ + h = q.get("high") + l = q.get("low") + c = q.get("price") + yc = q.get("close_yest") + amplitude = q.get("amplitude") # 当日振幅% + code = q.get("code", "") + + if not all([h, l, c]): + return {"error": "数据不足"} + + # 多日最高/最低(从历史缓存读取) + history = _load_history() + hist_days = history.get(code, []) + multi_high = max(d["high"] for d in hist_days) if hist_days else h + multi_low = min(d["low"] for d in hist_days) if hist_days else l + + # 有效区间 = max(当日波幅, 多日波幅, 价格×5%) + daily_range = h - l + multi_range = multi_high - multi_low + min_range = c * 0.05 # 5%价格基数 + + effective_range = max(daily_range, multi_range, min_range) + + # 如果股价接近多日高点(>80%分位),说明在上升趋势中,扩大区间 + trend_position = (c - multi_low) / (multi_high - multi_low) if multi_high > multi_low else 0.5 + if trend_position > 0.8: + # 高位运行,扩大有效区间到价格的8%确保合理空间 + effective_range = max(effective_range, c * 0.08) + elif trend_position < 0.2: + # 低位运行,同样扩大 + effective_range = max(effective_range, c * 0.08) + + # 如果振幅数据可用且振幅较小(<3%),进一步扩大区间确保有效性 + if amplitude and amplitude > 0 and amplitude < 3: + # 低波动股票用 振幅×3 作为最小范围 + amp_based = c * amplitude / 100 * 3 + effective_range = max(effective_range, amp_based) + + # 枢轴点 (Pivot Point) + pp = (h + l + c) / 3 + + # 支撑位 + s1 = 2 * pp - h # 弱支撑 + s2 = pp - effective_range # 强支撑 + + # 压力位 + r1 = 2 * pp - l # 弱压力 + r2 = pp + effective_range # 强压力 + + # 参考昨收调整 + if yc: + if yc < s1: + s1 = yc + if yc > r1: + r1 = yc + + # A股涨停/跌停价作为极端边界 + limit_up = q.get("limit_up") + limit_down = q.get("limit_down") + market = q.get("market", "hk") + if market != "hk" and limit_up and limit_down: + # 注意:当现价逼近涨停/跌停时,limit不再是有效边界 + # 用有效区间判断:如果自然计算的r2/s2在合理范围内不截断 + natural_r2 = r2 + natural_s2 = s2 + # 涨停限制只对距离现价超过2%的强压位生效 + if limit_up < r2 and (limit_up - c) / c < 0.02: + # 涨停价离现价<2%,说明可能封板,不截断 + pass # 使用自然计算的r2 + elif limit_up < r2: + r2 = limit_up + if limit_down > s2 and (c - limit_down) / c < 0.02: + pass # 接近跌停,不截断 + elif limit_down > s2: + s2 = limit_down + + return { + "strong_support": round(s2, 2), + "weak_support": round(s1, 2), + "pivot": round(pp, 2), + "weak_resist": round(r1, 2), + "strong_resist": round(r2, 2), + "today_high": h, + "today_low": l, + "multi_high": multi_high, + "multi_low": multi_low, + "effective_range": round(effective_range, 2), + } + + +def analyze_candlestick(q): + """判断K线形态""" + o = q.get("open") + c = q.get("price") + h = q.get("high") + l = q.get("low") + yc = q.get("close_yest") + + if not all([o, c, h, l]): + return {"pattern": "unknown", "sentiment": "neutral"} + + if c >= o: + body = c - o + upper = h - c + lower = o - l + is_green = True + else: + body = o - c + upper = h - o + lower = c - l + is_green = False + + total_range = h - l + if total_range == 0: + return {"pattern": "平盘", "sentiment": "neutral"} + + body_pct = body / total_range * 100 + upper_pct = upper / total_range * 100 + lower_pct = lower / total_range * 100 + + if body_pct < 5: + if upper_pct > 60: + pattern = "倒T线/射击之星" + sentiment = "bearish" + elif lower_pct > 60: + pattern = "锤子线/T字线" + sentiment = "bullish" + else: + pattern = "十字星" + sentiment = "neutral" + elif body_pct < 30: + if upper_pct > 40 and lower_pct > 40: + pattern = "长影星线" + sentiment = "neutral" + elif upper_pct > 40: + pattern = "倒T线/射击之星" + sentiment = "bearish" if is_green else "bearish" + elif lower_pct > 40: + pattern = "锤子线/T字线" + sentiment = "bullish" if is_green else "bullish" + else: + pattern = "小阳线" if is_green else "小阴线" + sentiment = "bullish" if is_green else "bearish" + else: + if upper_pct > 30: + pattern = "带上影阳线" if is_green else "带上影阴线" + sentiment = "neutral" if is_green else "bearish" + elif lower_pct > 30: + pattern = "带下影阳线" if is_green else "带下影阴线" + sentiment = "bullish" if is_green else "neutral" + else: + pattern = "光头光脚阳线" if is_green else "光头光脚阴线" + sentiment = "bullish" if is_green else "bearish" + + gap_up = "" + gap_down = "" + if yc: + if o > yc * 1.01: + gap_up = "跳空高开" + if not is_green: + sentiment = "neutral" + elif o < yc * 0.99: + gap_down = "跳空低开" + if is_green: + sentiment = "neutral" + + return { + "pattern": pattern, + "sentiment": sentiment, + "body_pct": round(body_pct, 1), + "upper_shadow_pct": round(upper_pct, 1), + "lower_shadow_pct": round(lower_pct, 1), + "is_green": is_green, + "gap": gap_up or gap_down or "无跳空", + } + + +def analyze_volume(q): + """量价分析""" + outer = q.get("outer_vol") + inner = q.get("inner_vol") + turnover = q.get("turnover_rate") + + result = {} + if outer and inner and (outer + inner) > 0: + ratio = outer / (outer + inner) + result["buy_sell_ratio"] = round(ratio, 2) + if ratio > 0.55: + result["volume_signal"] = "主动买盘占优" + elif ratio < 0.45: + result["volume_signal"] = "主动卖盘占优" + else: + result["volume_signal"] = "买卖均衡" + else: + result["volume_signal"] = "数据不足" + + if turnover: + result["turnover_rate"] = turnover + + return result + + +def full_analysis(code): + """完整技术分析(带30秒缓存,避免分钟级波动)""" + import time + _cache = full_analysis.__dict__.get("_cache", {}) + now = time.time() + cached = _cache.get(code) + if cached and (now - cached["ts"]) < 30: + return cached["data"] + + q = get_quote(code) + if not q or "error" in q: + return q + + sr = calc_support_resistance(q) + candle = analyze_candlestick(q) + vol = analyze_volume(q) + + result = { + "quote": { + "name": q["name"], + "price": q["price"], + "change_pct": q["change_pct"], + "open": q["open"], + "high": q["high"], + "low": q["low"], + "close_yest": q["close_yest"], + "volume": q["volume"], + "amplitude": q["amplitude"], + }, + "support_resistance": sr, + "candlestick": candle, + "volume": vol, + "analyzed_at": datetime.now().strftime("%H:%M"), + } + + # 写入缓存 + _cache[code] = {"ts": now, "data": result} + full_analysis.__dict__["_cache"] = _cache + return result + + +if __name__ == "__main__": + import sys + codes = sys.argv[1:] or ["603259", "002594", "00700"] + for c in codes: + r = full_analysis(c) + print(json.dumps(r, ensure_ascii=False, indent=2)) + print() diff --git a/src/update_data.py b/src/update_data.py new file mode 100644 index 0000000..a0014d7 --- /dev/null +++ b/src/update_data.py @@ -0,0 +1,317 @@ +#!/usr/bin/env python3 +"""update_data.py — 解析cron输出,更新dashboard数据层""" + +import json +import os +import re +from datetime import datetime +from pathlib import Path + +DATA_DIR = Path(__file__).parent / "data" + + +def _save(name, data): + path = DATA_DIR / name + os.makedirs(path.parent, exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + +def parse_report(markdown_text, source_file=None): + """解析cron输出的markdown报告,提取结构化数据""" + report = { + "title": "", + "type": "未知", + "created_at": datetime.now().isoformat(), + "summary": "", + "content": markdown_text, + "stocks_mentioned": [], + "structured": None, # 结构化数据优先 + } + + lines = markdown_text.split("\n") + + # 提取标题 + for line in lines: + m = re.match(r"^#\s+(.+)", line) + if m: + report["title"] = m.group(1).strip() + break + m = re.match(r"^📊\s+(.+)", line) + if m: + report["title"] = m.group(1).strip() + break + + # 判断类型 + if "盘中" in report["title"]: + report["type"] = "盘中" + elif "盘后" in report["title"] or "复盘" in report["title"]: + report["type"] = "盘后" + elif "盯盘" in report["title"]: + report["type"] = "盯盘" + elif "扫描" in report["title"]: + report["type"] = "盘前" + + # ★ 优先提取结构化JSON(如果知微输出了的话) + struct_match = re.search(r'\s*(\{.*?\})\s*', markdown_text, re.DOTALL) + if struct_match: + try: + parsed = json.loads(struct_match.group(1)) + report["structured"] = parsed + # 从结构化数据中直接取stock codes + codes = set() + for h in parsed.get("holdings", []): + c = h.get("code", "") + if c: + codes.add(c) + report["stocks_mentioned"] = sorted(codes) + except (json.JSONDecodeError, Exception) as e: + pass # JSON解析失败→走NLP兜底 + + # 摘要(前3非空行) + body_lines = [l.strip() for l in lines if l.strip() and not l.strip().startswith("#") and not l.strip().startswith("##")] + report["summary"] = "\n".join(body_lines[:5])[:200] + + # NLP兜底(仅当结构化数据没取到code时) + if not report["stocks_mentioned"]: + codes = set(re.findall(r'\b\d{6}\b', markdown_text)) + hk_codes = set(re.findall(r'\b\d{5}\b', markdown_text)) + report["stocks_mentioned"] = sorted(codes | hk_codes) + + return report + + +def import_cron_outputs(): + """从cron输出目录导入最新报告""" + cron_dir = Path.home() / ".hermes" / "cron" / "output" + reports_dir = DATA_DIR / "reports" + os.makedirs(reports_dir, exist_ok=True) + + count = 0 + if not cron_dir.exists(): + return count + + for job_dir in sorted(cron_dir.iterdir()): + if not job_dir.is_dir(): + continue + for f in sorted(job_dir.iterdir(), reverse=True)[:5]: # 每个job最近5个 + if f.suffix != ".md": + continue + # Skip if already imported + export_name = f"cron_{job_dir.name}_{f.stem}.json" + if (reports_dir / export_name).exists(): + continue + + content = f.read_text(encoding="utf-8", errors="replace") + report = parse_report(content, source_file=str(f)) + + # Extract response section + resp_match = re.search(r"## Response\n+(.*)", content, re.DOTALL) + if resp_match: + resp = resp_match.group(1).strip() + if resp == "[SILENT]": + continue # Skip SILENT reports + + report["_id"] = export_name.replace(".json", "") + _save(f"reports/{export_name}", report) + count += 1 + + return count + + +def extract_stock_mentions(): + """从报告中提取个股操作建议""" + reports_dir = DATA_DIR / "reports" + stocks_dir = DATA_DIR / "stocks" + os.makedirs(stocks_dir, exist_ok=True) + + stock_data = {} + + for f in sorted(reports_dir.iterdir()): + if f.suffix != ".json": + continue + try: + report = json.loads(f.read_text(encoding="utf-8")) + except: + continue + + content = report.get("content", "") + codes = report.get("stocks_mentioned", []) + + for code in codes: + if code not in stock_data: + stock_data[code] = {"code": code, "history": []} + + # Try to extract recommendation from content + # Look for patterns like "建议|止盈|止损|补仓|持有" + pattern = re.compile( + rf'.*?({code}).*?(建议|止盈|止损|补仓|持有|减仓|加仓|卖出|买入).*?(?:\n|$)', + re.IGNORECASE, + ) + for m in pattern.finditer(content): + stock_data[code]["history"].append({ + "time": report.get("created_at", ""), + "content": m.group(0).strip()[:100], + "report_id": report.get("_id", ""), + }) + + for code, data in stock_data.items(): + _save(f"stocks/{code}.json", data) + + return len(stock_data) + + +def sync_to_decisions(): + """将个股建议同步到决策库(advice_timeline),自动去重""" + decisions_path = DATA_DIR / "decisions.json" + if not decisions_path.exists(): + return 0 + + decisions = json.loads(decisions_path.read_text(encoding="utf-8")) + stocks_dir = DATA_DIR / "stocks" + synced = 0 + + for f in sorted(stocks_dir.iterdir()): + if f.suffix != ".json": + continue + try: + stock = json.loads(f.read_text(encoding="utf-8")) + except: + continue + + code = stock.get("code", "") + history = stock.get("history", []) + if not code or not history: + continue + + # 找决策库中是否有此股 + existing = None + for d in decisions["decisions"]: + if d["code"] == code: + existing = d + break + + if not existing: + # 无决策记录→生成inactive记录 + existing = { + "code": code, + "name": stock.get("name", ""), + "timestamp": datetime.now().isoformat(), + "type": "历史建议汇总", + "current": "自动从update_data同步", + "status": "inactive", + "updated_by": "system(update_data)", + "advice_timeline": [] + } + decisions["decisions"].append(existing) + + # 去重合并 + timeline = existing.setdefault("advice_timeline", []) + existing_keys = {(e["date"], e["direction"], e["summary"]) for e in timeline + if "date" in e and "direction" in e and "summary" in e} + + new_count = 0 + for entry in history: + content = entry.get("content", "") + # 判断方向 + direction = "其他" + if any(w in content for w in ["买入", "加仓", "入场", "🟢", "可加", "可入"]): + direction = "买入" + elif any(w in content for w in ["卖出", "止盈", "减仓", "止损", "清仓", "🔴", "锁定利润"]): + direction = "卖出" + elif any(w in content for w in ["持有", "观望", "👀", "🤝", "暂持", "继续持有"]): + direction = "持有" + + if direction == "其他": + continue + + # 提取日期 + rid = entry.get("report_id", "") + m = re.search(r'(\d{4}-\d{2}-\d{2})', rid) + date = m.group(1) if m else "unknown" + + key = (date, direction, content.strip()[:80]) + if key not in existing_keys: + existing_keys.add(key) + timeline.append({ + "date": date, + "direction": direction, + "summary": content.strip()[:120], + "report_id": rid + }) + new_count += 1 + + if new_count > 0: + # 按日期排序 + timeline.sort(key=lambda e: e.get("date", "")) + synced += new_count + + decisions_path.write_text( + json.dumps(decisions, ensure_ascii=False, indent=2), encoding="utf-8" + ) + return synced + + +def build_portfolio_from_obsidian(): + """读取Obsidian持仓数据,生成portfolio.json""" + import subprocess + # Attempt to read from Obsidian + obsidian_path = Path.home() / "Obsidian" / "knowledge" / "finance" + portfolio_file = obsidian_path / "dad-portfolio.md" + + holdings = [] + total_assets = 0 + stock_value = 0 + cash = 0 + + if portfolio_file.exists(): + content = portfolio_file.read_text(encoding="utf-8", errors="replace") + lines = content.split("\n") + + for line in lines: + m = re.match(r'\|.*?\|.*?(\d+)@(\d+\.?\d*)@.*?\|(\d+\.?\d*)%?\|', line) + if m: + # Parse holding lines from markdown table + parts = [p.strip() for p in line.split("|")] + if len(parts) >= 8: + name = parts[1] if len(parts) > 1 else "" + code = parts[2] if len(parts) > 2 else "" + if code: + holdings.append({ + "code": code, + "name": name, + "position_pct": 0, + "cost": 0, + "shares": 0, + "price": 0, + "change_pct": 0, + }) + + return { + "holdings": holdings, + "total_assets": total_assets, + "stock_value": stock_value, + "cash": cash, + "position_pct": 0, + "total_pnl": 0, + "updated_at": datetime.now().isoformat(), + } + + +if __name__ == "__main__": + count = import_cron_outputs() + if count > 0: + print(f"📥 新增报告: {count}篇") + + stocks = extract_stock_mentions() + if stocks > 0: + print(f"📊 个股数据: {stocks}条") + + synced = sync_to_decisions() + if synced > 0: + print(f"📋 决策库: 新增{synced}条建议") + + # 有实质更新才发汇总,否则安静 + if count > 0 or stocks > 0 or synced > 0: + print(f"✅ {datetime.now().strftime('%m/%d %H:%M')} 数据同步完成") + # 什么新数据都没有→安静,不输出任何内容 \ No newline at end of file